Merge branch 'feature' into 8853-api-tokens

This commit is contained in:
Arthur 2022-10-27 10:50:58 -07:00
commit 0615f8c134
188 changed files with 4986 additions and 3360 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.3.5 placeholder: v3.3.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

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

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.3.5 placeholder: v3.3.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,13 +1,14 @@
<!-- <!--
Thank you for your interest in contributing to NetBox! Please note that Thank you for your interest in contributing to NetBox! Please note that
our contribution policy requires that a feature request or bug report be our contribution policy requires that a feature request or bug report be
approved and assigned prior to filing a pull request. This helps avoid approved and assigned prior to opening a pull request. This helps avoid
wasting time and effort on something that we might not be able to accept. waste time and effort on a proposed change that we might not be able to
accept.
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
TO YOU, IT WE BE CLOSED AUTOMATICALLY. TO YOU, IT WILL BE CLOSED AUTOMATICALLY.
Specify your assigned issue number on the line below. Please specify your assigned issue number on the line below.
--> -->
### Fixes: #1234 ### Fixes: #1234

View File

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

View File

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

View File

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

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

View File

@ -20,12 +20,14 @@ To create a new object in NetBox, find the object type in the navigation menu an
## Bulk Import (CSV/YAML) ## Bulk Import (CSV/YAML)
NetBox supports the bulk import of new objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file. NetBox supports the bulk import of new objects, and updating of existing objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file.
When viewing the CSV import form for an object type, you'll notice that the headers for the required columns have been pre-populated. Each form has a table beneath it titled "CSV Field Options," which lists _all_ supported columns for your reference. (Generally, these map to the fields you see in the corresponding creation form for individual objects.) When viewing the CSV import form for an object type, you'll notice that the headers for the required columns have been pre-populated. Each form has a table beneath it titled "CSV Field Options," which lists _all_ supported columns for your reference. (Generally, these map to the fields you see in the corresponding creation form for individual objects.)
<!-- TODO: Screenshot --> <!-- TODO: Screenshot -->
If an "id" field is added the data will be used to update existing records instead of importing new objects.
Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components. Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components.
## Scripting ## Scripting

View File

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

View File

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

View File

@ -65,6 +65,10 @@ The height of the rack, measured in units.
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
### Mounting Depth
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
### Weight ### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds). The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).

View File

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

View File

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

View File

@ -49,6 +49,12 @@ class MyModel(NetBoxModel):
... ...
``` ```
### NetBoxModel Properties
#### `docs_url`
This attribute specifies the URL at which the documentation for this model can be reached. By default, it will return `/static/docs/models/<app_label>/<model_name>/`. Plugin models can override this to return a custom URL. For example, you might direct the user to your plugin's documentation hosted on [ReadTheDocs](https://readthedocs.org/).
### Enabling Features Individually ### Enabling Features Individually
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)

View File

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

View File

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

View File

@ -82,26 +82,28 @@ class ThingEditView(ObjectEditView):
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly. Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseObjectView ::: netbox.views.generic.base.BaseObjectView
options:
members:
- get_queryset
- get_object
- get_extra_context
::: netbox.views.generic.ObjectView ::: netbox.views.generic.ObjectView
selection: options:
members: members:
- get_object
- get_template_name - get_template_name
::: netbox.views.generic.ObjectEditView ::: netbox.views.generic.ObjectEditView
selection: options:
members: members:
- get_object
- alter_object - alter_object
::: netbox.views.generic.ObjectDeleteView ::: netbox.views.generic.ObjectDeleteView
selection: options:
members: members: false
- get_object
::: netbox.views.generic.ObjectChildrenView ::: netbox.views.generic.ObjectChildrenView
selection: options:
members: members:
- get_children - get_children
- prep_table_data - prep_table_data
@ -111,24 +113,28 @@ Below are the class definitions for NetBox's object views. These views handle CR
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly. Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseMultiObjectView ::: netbox.views.generic.base.BaseMultiObjectView
options:
members:
- get_queryset
- get_extra_context
::: 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 +143,12 @@ Below are the class definitions for NetBox's multi-object views. These views han
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path. These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
::: netbox.views.generic.ObjectChangeLogView ::: netbox.views.generic.ObjectChangeLogView
selection: options:
members: members:
- get_form - get_form
::: netbox.views.generic.ObjectJournalView ::: netbox.views.generic.ObjectJournalView
selection: options:
members: members:
- get_form - get_form

View File

@ -1,6 +1,35 @@
# NetBox v3.3 # NetBox v3.3
## v3.3.6 (FUTURE) ## v3.3.7 (FUTURE)
---
## v3.3.6 (2022-10-26)
### Enhancements
* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug
* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface
* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH`
* [#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
* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer
* [#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
* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists
* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view
* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation
* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs
* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list
* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view
--- ---

View File

@ -8,9 +8,14 @@
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error.
* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types.
### 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.
@ -18,14 +23,17 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
### Enhancements ### Enhancements
* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
* [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types
* [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
* [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects
* [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types
* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations * [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model
* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types
### Plugins API ### Plugins API
@ -33,13 +41,16 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter
* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation
* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
* [#10739](https://github.com/netbox-community/netbox/issues/10739) - Introduce `get_queryset()` method on generic views
### Other Changes ### Other Changes
* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model * [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function
### REST API Changes ### REST API Changes
@ -51,6 +62,10 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
* Added optional `weight` and `weight_unit` fields * Added optional `weight` and `weight_unit` fields
* dcim.Rack * dcim.Rack
* Added optional `weight` and `weight_unit` fields * Added optional `weight` and `weight_unit` fields
* extras.CustomLink
* Renamed `content_type` field to `content_types`
* extras.ExportTemplate
* Renamed `content_type` field to `content_types`
* ipam.FHRPGroup * ipam.FHRPGroup
* Added optional `name` field * Added optional `name` field
@ -67,6 +82,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
* Add `component` field * Add `component` field
* dcim.InventoryItemTemplate * dcim.InventoryItemTemplate
* Add `component` field * Add `component` field
* dcim.Rack
* Add `mounting_depth` field
* ipam.FHRPGroupAssignment * ipam.FHRPGroupAssignment
* Add `interface` field * Add `interface` field
* ipam.IPAddress * ipam.IPAddress

View File

@ -30,7 +30,7 @@ plugins:
- os.chdir('netbox/') - os.chdir('netbox/')
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
- django.setup() - django.setup()
rendering: options:
heading_level: 3 heading_level: 3
members_order: source members_order: source
show_root_heading: true show_root_heading: true
@ -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'

View File

@ -1,4 +1,4 @@
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .filtersets import * from .filtersets import *
from .models import * from .model_forms import *

View File

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

View File

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

View File

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

View File

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

View File

@ -50,6 +50,13 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Provider 6,provider-6", "Provider 6,provider-6",
) )
cls.csv_update_data = (
"id,name,comments",
f"{providers[0].pk},Provider 7,New comment7",
f"{providers[1].pk},Provider 8,New comment8",
f"{providers[2].pk},Provider 9,New comment9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'account': '5678', 'account': '5678',
'comments': 'New comments', 'comments': 'New comments',
@ -62,11 +69,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
CircuitType.objects.bulk_create([ circuit_types = (
CircuitType(name='Circuit Type 1', slug='circuit-type-1'), CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'), CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'), CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
]) )
CircuitType.objects.bulk_create(circuit_types)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -84,6 +93,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Circuit Type 6,circuit-type-6", "Circuit Type 6,circuit-type-6",
) )
cls.csv_update_data = (
"id,name,description",
f"{circuit_types[0].pk},Circuit Type 7,New description7",
f"{circuit_types[1].pk},Circuit Type 8,New description8",
f"{circuit_types[2].pk},Circuit Type 9,New description9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'Foo', 'description': 'Foo',
} }
@ -107,11 +123,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
CircuitType.objects.bulk_create(circuittypes) CircuitType.objects.bulk_create(circuittypes)
Circuit.objects.bulk_create([ circuits = (
Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]),
Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]),
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
]) )
Circuit.objects.bulk_create(circuits)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -136,6 +154,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Circuit 6,Provider 1,Circuit Type 1,active", "Circuit 6,Provider 1,Circuit Type 1,active",
) )
cls.csv_update_data = (
f"id,cid,description,status",
f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'provider': providers[1].pk, 'provider': providers[1].pk,
'type': circuittypes[1].pk, 'type': circuittypes[1].pk,
@ -159,11 +184,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
ProviderNetwork.objects.bulk_create([ provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]), ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[0]), ProviderNetwork(name='Provider Network 2', provider=providers[0]),
ProviderNetwork(name='Provider Network 3', provider=providers[0]), ProviderNetwork(name='Provider Network 3', provider=providers[0]),
]) )
ProviderNetwork.objects.bulk_create(provider_networks)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -182,6 +209,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Provider Network 6,Provider 1,Baz", "Provider Network 6,Provider 1,Baz",
) )
cls.csv_update_data = (
"id,name,description",
f"{provider_networks[0].pk},Provider Network 7,New description7",
f"{provider_networks[1].pk},Provider Network 8,New description8",
f"{provider_networks[2].pk},Provider Network 9,New description9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'provider': providers[1].pk, 'provider': providers[1].pk,
'description': 'New description', 'description': 'New description',

View File

@ -210,8 +210,8 @@ class RackSerializer(NetBoxModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width', 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'outer_depth', 'outer_unit', 'mounting_depth', 'comments', 'tags', 'custom_fields', 'created',
'powerfeed_count', 'last_updated', 'device_count', 'powerfeed_count',
] ]

View File

@ -320,7 +320,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'weight', 'weight_unit' 'outer_unit', 'mounting_depth', 'weight', 'weight_unit'
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -800,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
device_type = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug',
queryset=DeviceType.objects.all(),
to_field_name='slug',
label='Device type (slug)',
)
device_type_id = django_filters.ModelMultipleChoiceFilter( device_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
label='Device type (ID)', label='Device type (ID)',
@ -1360,7 +1366,7 @@ class InterfaceFilterSet(
try: try:
devices = Device.objects.filter(pk__in=id_list) devices = Device.objects.filter(pk__in=id_list)
for device in devices: for device in devices:
vc_interface_ids += device.vc_interfaces().values_list('id', flat=True) vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
return queryset.filter(pk__in=vc_interface_ids) return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist: except Device.DoesNotExist:
return queryset.none() return queryset.none()

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .object_create import * from .object_create import *
from .object_import import * from .object_import import *

View File

@ -281,6 +281,10 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
widget=StaticSelect() widget=StaticSelect()
) )
mounting_depth = forms.IntegerField(
required=False,
min_value=1
)
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=SmallTextarea,
label='Comments' label='Comments'
@ -300,11 +304,14 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = ( fieldsets = (
('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')),
('Location', ('region', 'site_group', 'site', 'location')), ('Location', ('region', 'site_group', 'site', 'location')),
('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), ('Hardware', (
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
)),
('Weight', ('weight', 'weight_unit')), ('Weight', ('weight', 'weight_unit')),
) )
nullable_fields = ( nullable_fields = (
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'weight', 'weight_unit' 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
'weight', 'weight_unit'
) )

View File

@ -196,7 +196,7 @@ class RackCSVForm(NetBoxModelCSVForm):
model = Rack model = Rack
fields = ( fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'comments',
) )
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -576,7 +576,7 @@ class PowerOutletCSVForm(NetBoxModelCSVForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Limit PowerPort choices to those belonging to this device (or VC master) # Limit PowerPort choices to those belonging to this device (or VC master)
if self.is_bound: if self.is_bound and 'device' in self.data:
try: try:
device = self.fields['device'].to_python(self.data['device']) device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError: except forms.ValidationError:
@ -711,7 +711,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Limit RearPort choices to those belonging to this device (or VC master) # Limit RearPort choices to those belonging to this device (or VC master)
if self.is_bound: if self.is_bound and 'device' in self.data:
try: try:
device = self.fields['device'].to_python(self.data['device']) device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError: except forms.ValidationError:
@ -782,7 +782,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Limit installed device choices to devices of the correct type and location # Limit installed device choices to devices of the correct type and location
if self.is_bound: if self.is_bound and 'device' in self.data:
try: try:
device = self.fields['device'].to_python(self.data['device']) device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError: except forms.ValidationError:

View File

@ -3,7 +3,7 @@ from django import forms
from circuits.models import Circuit, CircuitTermination, Provider from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import * from dcim.models import *
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .models import CableForm from .model_forms import CableForm
def get_cable_form(a_type, b_type): def get_cable_form(a_type, b_type):
@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type):
label='Power Feed', label='Power Feed',
disabled_indicator='_occupied', disabled_indicator='_occupied',
query_params={ query_params={
'powerpanel_id': f'$termination_{cable_end}_powerpanel', 'power_panel_id': f'$termination_{cable_end}_powerpanel',
} }
) )

View File

@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm):
) )
slug = SlugField() slug = SlugField()
fieldsets = (
('Region', (
'parent', 'name', 'slug', 'description', 'tags',
)),
)
class Meta: class Meta:
model = Region model = Region
fields = ( fields = (
@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
) )
slug = SlugField() slug = SlugField()
fieldsets = (
('Site Group', (
'parent', 'name', 'slug', 'description', 'tags',
)),
)
class Meta: class Meta:
model = SiteGroup model = SiteGroup
fields = ( fields = (
@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class RackRoleForm(NetBoxModelForm): class RackRoleForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = (
('Rack Role', (
'name', 'slug', 'color', 'description', 'tags',
)),
)
class Meta: class Meta:
model = RackRole model = RackRole
fields = [ fields = [
@ -260,7 +278,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [ fields = [
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'weight', 'weight_unit', 'comments', 'tags', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
@ -341,6 +359,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
class ManufacturerForm(NetBoxModelForm): class ManufacturerForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = (
('Manufacturer', (
'name', 'slug', 'description', 'tags',
)),
)
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ fields = [
@ -413,6 +437,12 @@ class ModuleTypeForm(NetBoxModelForm):
class DeviceRoleForm(NetBoxModelForm): class DeviceRoleForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = (
('Device Role', (
'name', 'slug', 'color', 'vm_role', 'description', 'tags',
)),
)
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = [ fields = [
@ -429,6 +459,13 @@ class PlatformForm(NetBoxModelForm):
max_length=64 max_length=64
) )
fieldsets = (
('Platform', (
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
)),
)
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
@ -1584,6 +1621,12 @@ class InventoryItemForm(DeviceComponentForm):
class InventoryItemRoleForm(NetBoxModelForm): class InventoryItemRoleForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = (
('Inventory Item Role', (
'name', 'slug', 'color', 'description', 'tags',
)),
)
class Meta: class Meta:
model = InventoryItemRole model = InventoryItemRole
fields = [ fields = [

View File

@ -3,7 +3,7 @@ from django import forms
from dcim.models import * from dcim.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from . import models as model_forms from . import model_forms
__all__ = ( __all__ = (
'ComponentCreateForm', 'ComponentCreateForm',

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.1 on 2022-10-27 14:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0163_rack_devicetype_moduletype_weights'),
]
operations = [
migrations.AddField(
model_name='rack',
name='mounting_depth',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
]

View File

@ -167,6 +167,14 @@ class Rack(NetBoxModel, WeightMixin):
choices=RackDimensionUnitChoices, choices=RackDimensionUnitChoices,
blank=True, blank=True,
) )
mounting_depth = models.PositiveSmallIntegerField(
blank=True,
null=True,
help_text=(
'Maximum depth of a mounted device, in millimeters. For four-post racks, this is the '
'distance between the front and rear rails.'
)
)
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
@ -187,7 +195,7 @@ class Rack(NetBoxModel, WeightMixin):
clone_fields = ( clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'weight', 'weight_unit', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit',
) )
class Meta: class Meta:

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import DEVICE_WEIGHT from .template_code import DEVICE_WEIGHT
__all__ = ( __all__ = (
@ -38,7 +38,7 @@ class RackRoleTable(NetBoxTable):
# Racks # Racks
# #
class RackTable(TenancyColumnsMixin, NetBoxTable): class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column( name = tables.Column(
order_by=('_name',), order_by=('_name',),
linkify=True linkify=True
@ -69,9 +69,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
orderable=False, orderable=False,
verbose_name='Power' verbose_name='Power'
) )
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:rack_list' url_name='dcim:rack_list'
) )
@ -92,8 +89,9 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
model = Rack model = Rack
fields = ( fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments', 'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight',
'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created',
'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

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

View File

@ -1670,6 +1670,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
device_types = DeviceType.objects.all()[:2] device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_type': [device_types[0].slug, device_types[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_devicerole(self): def test_devicerole(self):
device_roles = DeviceRole.objects.all()[:2] device_roles = DeviceRole.objects.all()[:2]

View File

@ -50,6 +50,13 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Region 6,region-6,Sixth region", "Region 6,region-6,Sixth region",
) )
cls.csv_update_data = (
"id,name,description",
f"{regions[0].pk},Region 7,Fourth region7",
f"{regions[1].pk},Region 8,Fifth region8",
f"{regions[2].pk},Region 0,Sixth region9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
@ -87,6 +94,13 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Site Group 6,site-group-6,Sixth site group", "Site Group 6,site-group-6,Sixth site group",
) )
cls.csv_update_data = (
"id,name,description",
f"{sitegroups[0].pk},Site Group 7,Fourth site group7",
f"{sitegroups[1].pk},Site Group 8,Fifth site group8",
f"{sitegroups[2].pk},Site Group 0,Sixth site group9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
@ -156,6 +170,13 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Site 6,site-6,staging", "Site 6,site-6,staging",
) )
cls.csv_update_data = (
"id,name,status",
f"{sites[0].pk},Site 7,staging",
f"{sites[1].pk},Site 8,planned",
f"{sites[2].pk},Site 9,active",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'status': SiteStatusChoices.STATUS_PLANNED, 'status': SiteStatusChoices.STATUS_PLANNED,
'region': regions[1].pk, 'region': regions[1].pk,
@ -202,6 +223,13 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
) )
cls.csv_update_data = (
"id,name,description",
f"{locations[0].pk},Location 7,Fourth location7",
f"{locations[1].pk},Location 8,Fifth location8",
f"{locations[2].pk},Location 0,Sixth location9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
@ -213,11 +241,12 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
RackRole.objects.bulk_create([ rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1'), RackRole(name='Rack Role 1', slug='rack-role-1'),
RackRole(name='Rack Role 2', slug='rack-role-2'), RackRole(name='Rack Role 2', slug='rack-role-2'),
RackRole(name='Rack Role 3', slug='rack-role-3'), RackRole(name='Rack Role 3', slug='rack-role-3'),
]) )
RackRole.objects.bulk_create(rack_roles)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -236,6 +265,13 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Rack Role 6,rack-role-6,0000ff", "Rack Role 6,rack-role-6,0000ff",
) )
cls.csv_update_data = (
"id,name,description",
f"{rack_roles[0].pk},Rack Role 7,New description7",
f"{rack_roles[1].pk},Rack Role 8,New description8",
f"{rack_roles[2].pk},Rack Role 9,New description9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'color': '00ff00', 'color': '00ff00',
'description': 'New description', 'description': 'New description',
@ -259,11 +295,12 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
rack = Rack(name='Rack 1', site=site, location=location) rack = Rack(name='Rack 1', site=site, location=location)
rack.save() rack.save()
RackReservation.objects.bulk_create([ rack_reservations = (
RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'), RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'),
RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'), RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'),
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
]) )
RackReservation.objects.bulk_create(rack_reservations)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -283,6 +320,13 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', 'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3',
) )
cls.csv_update_data = (
'id,description',
f'{rack_reservations[0].pk},New description1',
f'{rack_reservations[1].pk},New description2',
f'{rack_reservations[2].pk},New description3',
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
@ -315,11 +359,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
RackRole.objects.bulk_create(rackroles) RackRole.objects.bulk_create(rackroles)
Rack.objects.bulk_create(( racks = (
Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[0]), Rack(name='Rack 2', site=sites[0]),
Rack(name='Rack 3', site=sites[0]), Rack(name='Rack 3', site=sites[0]),
)) )
Rack.objects.bulk_create(racks)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -351,6 +396,13 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Site 2,Location 2,Rack 6,active,19,42", "Site 2,Location 2,Rack 6,active,19,42",
) )
cls.csv_update_data = (
"id,name,status",
f"{racks[0].pk},Rack 7,{RackStatusChoices.STATUS_DEPRECATED}",
f"{racks[1].pk},Rack 8,{RackStatusChoices.STATUS_DEPRECATED}",
f"{racks[2].pk},Rack 9,{RackStatusChoices.STATUS_DEPRECATED}",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'site': sites[1].pk, 'site': sites[1].pk,
'location': locations[1].pk, 'location': locations[1].pk,
@ -383,11 +435,12 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
Manufacturer.objects.bulk_create([ manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
]) )
Manufacturer.objects.bulk_create(manufacturers)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -405,6 +458,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Manufacturer 6,manufacturer-6,Sixth manufacturer", "Manufacturer 6,manufacturer-6,Sixth manufacturer",
) )
cls.csv_update_data = (
"id,name,description",
f"{manufacturers[0].pk},Manufacturer 7,Fourth manufacturer7",
f"{manufacturers[1].pk},Manufacturer 8,Fifth manufacturer8",
f"{manufacturers[2].pk},Manufacturer 9,Sixth manufacturer9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1444,11 +1504,12 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
DeviceRole.objects.bulk_create([ device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'), DeviceRole(name='Device Role 3', slug='device-role-3'),
]) )
DeviceRole.objects.bulk_create(device_roles)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -1468,6 +1529,13 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Device Role 6,device-role-6,0000ff", "Device Role 6,device-role-6,0000ff",
) )
cls.csv_update_data = (
"id,name,description",
f"{device_roles[0].pk},Device Role 7,New description7",
f"{device_roles[1].pk},Device Role 8,New description8",
f"{device_roles[2].pk},Device Role 9,New description9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'color': '00ff00', 'color': '00ff00',
'description': 'New description', 'description': 'New description',
@ -1482,11 +1550,12 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
Platform.objects.bulk_create([ platforms = (
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturer), Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturer),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer), Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
]) )
Platform.objects.bulk_create(platforms)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -1507,6 +1576,13 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Platform 6,platform-6,Sixth platform", "Platform 6,platform-6,Sixth platform",
) )
cls.csv_update_data = (
"id,name,description",
f"{platforms[0].pk},Platform 7,Fourth platform7",
f"{platforms[1].pk},Platform 8,Fifth platform8",
f"{platforms[2].pk},Platform 9,Sixth platform9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'napalm_driver': 'ios', 'napalm_driver': 'ios',
'description': 'New description', 'description': 'New description',
@ -1554,11 +1630,12 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Platform.objects.bulk_create(platforms) Platform.objects.bulk_create(platforms)
Device.objects.bulk_create([ devices = (
Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
]) )
Device.objects.bulk_create(devices)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -1595,6 +1672,13 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30", "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30",
) )
cls.csv_update_data = (
"id,status",
f"{devices[0].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}",
f"{devices[1].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}",
f"{devices[2].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'device_type': devicetypes[1].pk, 'device_type': devicetypes[1].pk,
'device_role': deviceroles[1].pk, 'device_role': deviceroles[1].pk,
@ -1815,6 +1899,13 @@ class ModuleTestCase(
"Device 2,Module Bay 3,Module Type 3,C,C", "Device 2,Module Bay 3,Module Type 3,C,C",
) )
cls.csv_update_data = (
"id,serial",
f"{modules[0].pk},Serial 2",
f"{modules[1].pk},Serial 3",
f"{modules[2].pk},Serial 1",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_component_replication(self): def test_module_component_replication(self):
self.add_permissions('dcim.add_module') self.add_permissions('dcim.add_module')
@ -1894,11 +1985,12 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
ConsolePort.objects.bulk_create([ console_ports = (
ConsolePort(device=device, name='Console Port 1'), ConsolePort(device=device, name='Console Port 1'),
ConsolePort(device=device, name='Console Port 2'), ConsolePort(device=device, name='Console Port 2'),
ConsolePort(device=device, name='Console Port 3'), ConsolePort(device=device, name='Console Port 3'),
]) )
ConsolePort.objects.bulk_create(console_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -1932,6 +2024,13 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Console Port 6", "Device 1,Console Port 6",
) )
cls.csv_update_data = (
"id,name,description",
f"{console_ports[0].pk},Console Port 7,New description7",
f"{console_ports[1].pk},Console Port 8,New description8",
f"{console_ports[2].pk},Console Port 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
consoleport = ConsolePort.objects.first() consoleport = ConsolePort.objects.first()
@ -1953,11 +2052,12 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
ConsoleServerPort.objects.bulk_create([ console_server_ports = (
ConsoleServerPort(device=device, name='Console Server Port 1'), ConsoleServerPort(device=device, name='Console Server Port 1'),
ConsoleServerPort(device=device, name='Console Server Port 2'), ConsoleServerPort(device=device, name='Console Server Port 2'),
ConsoleServerPort(device=device, name='Console Server Port 3'), ConsoleServerPort(device=device, name='Console Server Port 3'),
]) )
ConsoleServerPort.objects.bulk_create(console_server_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -1989,6 +2089,13 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Console Server Port 6", "Device 1,Console Server Port 6",
) )
cls.csv_update_data = (
"id,name,description",
f"{console_server_ports[0].pk},Console Server Port 7,New description 7",
f"{console_server_ports[1].pk},Console Server Port 8,New description 8",
f"{console_server_ports[2].pk},Console Server Port 9,New description 9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
consoleserverport = ConsoleServerPort.objects.first() consoleserverport = ConsoleServerPort.objects.first()
@ -2010,11 +2117,12 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
PowerPort.objects.bulk_create([ power_ports = (
PowerPort(device=device, name='Power Port 1'), PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'), PowerPort(device=device, name='Power Port 2'),
PowerPort(device=device, name='Power Port 3'), PowerPort(device=device, name='Power Port 3'),
]) )
PowerPort.objects.bulk_create(power_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2052,6 +2160,13 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Power Port 6", "Device 1,Power Port 6",
) )
cls.csv_update_data = (
"id,name,description",
f"{power_ports[0].pk},Power Port 7,New description7",
f"{power_ports[1].pk},Power Port 8,New description8",
f"{power_ports[2].pk},Power Port 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
powerport = PowerPort.objects.first() powerport = PowerPort.objects.first()
@ -2079,11 +2194,12 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
) )
PowerPort.objects.bulk_create(powerports) PowerPort.objects.bulk_create(powerports)
PowerOutlet.objects.bulk_create([ power_outlets = (
PowerOutlet(device=device, name='Power Outlet 1', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 1', power_port=powerports[0]),
PowerOutlet(device=device, name='Power Outlet 2', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 2', power_port=powerports[0]),
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
]) )
PowerOutlet.objects.bulk_create(power_outlets)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2121,6 +2237,13 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Power Outlet 6", "Device 1,Power Outlet 6",
) )
cls.csv_update_data = (
"id,name,description",
f"{power_outlets[0].pk},Power Outlet 7,New description7",
f"{power_outlets[1].pk},Power Outlet 8,New description8",
f"{power_outlets[2].pk},Power Outlet 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
poweroutlet = PowerOutlet.objects.first() poweroutlet = PowerOutlet.objects.first()
@ -2247,6 +2370,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
) )
cls.csv_update_data = (
"id,name,description",
f"{interfaces[0].pk},Interface 7,New description7",
f"{interfaces[1].pk},Interface 8,New description8",
f"{interfaces[2].pk},Interface 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
interface1, interface2 = Interface.objects.all()[:2] interface1, interface2 = Interface.objects.all()[:2]
@ -2274,11 +2404,12 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
) )
RearPort.objects.bulk_create(rearports) RearPort.objects.bulk_create(rearports)
FrontPort.objects.bulk_create([ front_ports = (
FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]), FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]),
FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]), FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]),
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
]) )
FrontPort.objects.bulk_create(front_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2313,6 +2444,13 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Front Port 6,8p8c,Rear Port 6,1", "Device 1,Front Port 6,8p8c,Rear Port 6,1",
) )
cls.csv_update_data = (
"id,name,description",
f"{front_ports[0].pk},Front Port 7,New description7",
f"{front_ports[1].pk},Front Port 8,New description8",
f"{front_ports[2].pk},Front Port 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
frontport = FrontPort.objects.first() frontport = FrontPort.objects.first()
@ -2334,11 +2472,12 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
RearPort.objects.bulk_create([ rear_ports = (
RearPort(device=device, name='Rear Port 1'), RearPort(device=device, name='Rear Port 1'),
RearPort(device=device, name='Rear Port 2'), RearPort(device=device, name='Rear Port 2'),
RearPort(device=device, name='Rear Port 3'), RearPort(device=device, name='Rear Port 3'),
]) )
RearPort.objects.bulk_create(rear_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2372,6 +2511,13 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Rear Port 6,8p8c,1", "Device 1,Rear Port 6,8p8c,1",
) )
cls.csv_update_data = (
"id,name,description",
f"{rear_ports[0].pk},Rear Port 7,New description7",
f"{rear_ports[1].pk},Rear Port 8,New description8",
f"{rear_ports[2].pk},Rear Port 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
rearport = RearPort.objects.first() rearport = RearPort.objects.first()
@ -2393,11 +2539,12 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
ModuleBay.objects.bulk_create([ module_bays = (
ModuleBay(device=device, name='Module Bay 1'), ModuleBay(device=device, name='Module Bay 1'),
ModuleBay(device=device, name='Module Bay 2'), ModuleBay(device=device, name='Module Bay 2'),
ModuleBay(device=device, name='Module Bay 3'), ModuleBay(device=device, name='Module Bay 3'),
]) )
ModuleBay.objects.bulk_create(module_bays)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2426,6 +2573,13 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Module Bay 6", "Device 1,Module Bay 6",
) )
cls.csv_update_data = (
"id,name,description",
f"{module_bays[0].pk},Module Bay 7,New description7",
f"{module_bays[1].pk},Module Bay 8,New description8",
f"{module_bays[2].pk},Module Bay 9,New description9",
)
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay model = DeviceBay
@ -2438,11 +2592,12 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
# Update the DeviceType subdevice role to allow adding DeviceBays # Update the DeviceType subdevice role to allow adding DeviceBays
DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT)
DeviceBay.objects.bulk_create([ device_bays = (
DeviceBay(device=device, name='Device Bay 1'), DeviceBay(device=device, name='Device Bay 1'),
DeviceBay(device=device, name='Device Bay 2'), DeviceBay(device=device, name='Device Bay 2'),
DeviceBay(device=device, name='Device Bay 3'), DeviceBay(device=device, name='Device Bay 3'),
]) )
DeviceBay.objects.bulk_create(device_bays)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2471,6 +2626,13 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Device Bay 6", "Device 1,Device Bay 6",
) )
cls.csv_update_data = (
"id,name,description",
f"{device_bays[0].pk},Device Bay 7,New description7",
f"{device_bays[1].pk},Device Bay 8,New description8",
f"{device_bays[2].pk},Device Bay 9,New description9",
)
class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = InventoryItem model = InventoryItem
@ -2487,9 +2649,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
) )
InventoryItemRole.objects.bulk_create(roles) InventoryItemRole.objects.bulk_create(roles)
InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) inventory_item1 = InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer)
InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) inventory_item2 = InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer)
InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) inventory_item3 = InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2533,6 +2695,13 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Inventory Item 6,Inventory Item 3", "Device 1,Inventory Item 6,Inventory Item 3",
) )
cls.csv_update_data = (
"id,name,description",
f"{inventory_item1.pk},Inventory Item 7,New description7",
f"{inventory_item2.pk},Inventory Item 8,New description8",
f"{inventory_item3.pk},Inventory Item 9,New description9",
)
class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = InventoryItemRole model = InventoryItemRole
@ -2540,11 +2709,12 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
InventoryItemRole.objects.bulk_create([ inventory_item_roles = (
InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'),
InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'),
InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'), InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'),
]) )
InventoryItemRole.objects.bulk_create(inventory_item_roles)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2563,6 +2733,13 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Inventory Item Role 6,inventory-item-role-6,0000ff", "Inventory Item Role 6,inventory-item-role-6,0000ff",
) )
cls.csv_update_data = (
"id,name,description",
f"{inventory_item_roles[0].pk},Inventory Item Role 7,New description7",
f"{inventory_item_roles[1].pk},Inventory Item Role 8,New description8",
f"{inventory_item_roles[2].pk},Inventory Item Role 9,New description9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'color': '00ff00', 'color': '00ff00',
'description': 'New description', 'description': 'New description',
@ -2615,9 +2792,12 @@ class CableTestCase(
) )
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save() cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6)
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save() cable1.save()
Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save() cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6)
cable2.save()
cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6)
cable3.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2643,6 +2823,13 @@ class CableTestCase(
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
) )
cls.csv_update_data = (
"id,label,color",
f"{cable1.pk},New label7,00ff00",
f"{cable2.pk},New label8,00ff00",
f"{cable3.pk},New label9,00ff00",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'type': CableTypeChoices.TYPE_CAT5E, 'type': CableTypeChoices.TYPE_CAT5E,
'status': LinkStatusChoices.STATUS_CONNECTED, 'status': LinkStatusChoices.STATUS_CONNECTED,
@ -2726,6 +2913,13 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VC6,Domain 6,Device 12", "VC6,Domain 6,Device 12",
) )
cls.csv_update_data = (
"id,name,domain",
f"{vc1.pk},VC7,Domain 7",
f"{vc2.pk},VC8,Domain 8",
f"{vc3.pk},VC9,Domain 9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'domain': 'domain-x', 'domain': 'domain-x',
} }
@ -2750,11 +2944,12 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
for location in locations: for location in locations:
location.save() location.save()
PowerPanel.objects.bulk_create(( power_panels = (
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'), PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'), PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'), PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
)) )
PowerPanel.objects.bulk_create(power_panels)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2772,6 +2967,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Site 1,Location 1,Power Panel 6", "Site 1,Location 1,Power Panel 6",
) )
cls.csv_update_data = (
"id,name",
f"{power_panels[0].pk},Power Panel 7",
f"{power_panels[1].pk},Power Panel 8",
f"{power_panels[2].pk},Power Panel 9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'site': sites[1].pk, 'site': sites[1].pk,
'location': locations[1].pk, 'location': locations[1].pk,
@ -2798,11 +3000,12 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
PowerFeed.objects.bulk_create(( power_feeds = (
PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]),
PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]),
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
)) )
PowerFeed.objects.bulk_create(power_feeds)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -2828,6 +3031,13 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Site 1,Power Panel 1,Power Feed 6,active,primary,ac,single-phase,120,20,80", "Site 1,Power Panel 1,Power Feed 6,active,primary,ac,single-phase,120,20,80",
) )
cls.csv_update_data = (
"id,name,status",
f"{power_feeds[0].pk},Power Feed 7,{PowerFeedStatusChoices.STATUS_PLANNED}",
f"{power_feeds[1].pk},Power Feed 8,{PowerFeedStatusChoices.STATUS_PLANNED}",
f"{power_feeds[2].pk},Power Feed 9,{PowerFeedStatusChoices.STATUS_PLANNED}",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'power_panel': powerpanels[1].pk, 'power_panel': powerpanels[1].pk,
'rack': racks[1].pk, 'rack': racks[1].pk,

View File

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

View File

@ -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):
@ -116,14 +117,15 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class CustomLinkSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
content_type = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
many=True
) )
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated', 'button_class', 'new_window', 'created', 'last_updated',
] ]
@ -134,14 +136,15 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class ExportTemplateSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
content_type = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
many=True
) )
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type',
'file_extension', 'as_attachment', 'created', 'last_updated', 'file_extension', 'as_attachment', 'created', 'last_updated',
] ]
@ -362,7 +365,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 +391,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 +426,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):

View File

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

View File

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

View File

@ -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):
@ -92,11 +93,15 @@ class CustomLinkFilterSet(BaseFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
content_type_id = MultiValueNumberFilter(
field_name='content_types__id'
)
content_types = ContentTypeFilter()
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = [ fields = [
'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', 'id', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -115,10 +120,14 @@ class ExportTemplateFilterSet(BaseFilterSet):
method='search', method='search',
label='Search', label='Search',
) )
content_type_id = MultiValueNumberFilter(
field_name='content_types__id'
)
content_types = ContentTypeFilter()
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ['id', 'content_type', 'name', 'description'] fields = ['id', 'content_types', 'name', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -435,7 +444,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 +478,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)
) )

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *

View File

@ -53,11 +53,6 @@ class CustomLinkBulkEditForm(BulkEditForm):
queryset=CustomLink.objects.all(), queryset=CustomLink.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
) )
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'),
required=False
)
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()
@ -81,11 +76,6 @@ class ExportTemplateBulkEditForm(BulkEditForm):
queryset=ExportTemplate.objects.all(), queryset=ExportTemplate.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
) )
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
required=False
)
description = forms.CharField( description = forms.CharField(
max_length=200, max_length=200,
required=False required=False

View File

@ -46,38 +46,38 @@ 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',
) )
class CustomLinkCSVForm(CSVModelForm): class CustomLinkCSVForm(CSVModelForm):
content_type = CSVContentTypeField( content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'), limit_choices_to=FeatureQuery('custom_links'),
help_text="Assigned object type" help_text="One or more assigned object types"
) )
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = ( fields = (
'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'name', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text',
'link_url', 'link_url',
) )
class ExportTemplateCSVForm(CSVModelForm): class ExportTemplateCSVForm(CSVModelForm):
content_type = CSVContentTypeField( content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'), limit_choices_to=FeatureQuery('export_templates'),
help_text="Assigned object type" help_text="One or more assigned object types"
) )
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
) )

View File

@ -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,12 +66,64 @@ 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',)),
('Attributes', ('content_type', 'enabled', 'new_window', 'weight')), ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
) )
content_type = ContentTypeChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'), limit_choices_to=FeatureQuery('custom_links'),
required=False required=False
@ -95,9 +148,9 @@ class CustomLinkFilterForm(FilterForm):
class ExportTemplateFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q',)),
('Attributes', ('content_type', 'mime_type', 'file_extension', 'as_attachment')), ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
) )
content_type = ContentTypeChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'), limit_choices_to=FeatureQuery('export_templates'),
required=False required=False

View File

@ -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')),
) )
@ -63,13 +63,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links') limit_choices_to=FeatureQuery('custom_links')
) )
fieldsets = ( fieldsets = (
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Custom Link', ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
('Templates', ('link_text', 'link_url')), ('Templates', ('link_text', 'link_url')),
) )
@ -89,13 +89,13 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
class ExportTemplateForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates') limit_choices_to=FeatureQuery('export_templates')
) )
fieldsets = ( fieldsets = (
('Export Template', ('name', 'content_type', 'description')), ('Export Template', ('name', 'content_types', 'description')),
('Template', ('template_code',)), ('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
) )

View 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",
)

View File

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

View File

@ -35,7 +35,7 @@ class CustomLinkType(ObjectType):
class Meta: class Meta:
model = models.CustomLink model = models.CustomLink
fields = '__all__' exclude = ('content_types', )
filterset_class = filtersets.CustomLinkFilterSet filterset_class = filtersets.CustomLinkFilterSet
@ -43,7 +43,7 @@ class ExportTemplateType(ObjectType):
class Meta: class Meta:
model = models.ExportTemplate model = models.ExportTemplate
fields = '__all__' exclude = ('content_types', )
filterset_class = filtersets.ExportTemplateFilterSet filterset_class = filtersets.ExportTemplateFilterSet

View File

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

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

View File

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

View 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']},
),
]

View 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'),
},
),
]

View File

@ -0,0 +1,32 @@
from django.db import migrations, models
def copy_content_types(apps, schema_editor):
CustomLink = apps.get_model('extras', 'CustomLink')
for customlink in CustomLink.objects.all():
customlink.content_types.set([customlink.content_type])
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0080_search'),
]
operations = [
migrations.AddField(
model_name='customlink',
name='content_types',
field=models.ManyToManyField(related_name='custom_links', to='contenttypes.contenttype'),
),
migrations.RunPython(
code=copy_content_types,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='customlink',
name='content_type',
),
]

View File

@ -0,0 +1,40 @@
from django.db import migrations, models
def copy_content_types(apps, schema_editor):
ExportTemplate = apps.get_model('extras', 'ExportTemplate')
for et in ExportTemplate.objects.all():
et.content_types.set([et.content_type])
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0081_customlink_content_types'),
]
operations = [
migrations.AddField(
model_name='exporttemplate',
name='content_types',
field=models.ManyToManyField(related_name='export_templates', to='contenttypes.contenttype'),
),
migrations.RunPython(
code=copy_content_types,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveConstraint(
model_name='exporttemplate',
name='extras_exporttemplate_unique_content_type_name',
),
migrations.RemoveField(
model_name='exporttemplate',
name='content_type',
),
migrations.AlterModelOptions(
name='exporttemplate',
options={'ordering': ('name',)},
),
]

View File

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

View File

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

View File

@ -197,10 +197,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context. code to be rendered with an object as context.
""" """
content_type = models.ForeignKey( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
on_delete=models.CASCADE, related_name='custom_links',
limit_choices_to=FeatureQuery('custom_links') help_text='The object type(s) to which this link applies.'
) )
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
@ -236,7 +236,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
) )
clone_fields = ( clone_fields = (
'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
) )
class Meta: class Meta:
@ -268,10 +268,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
content_type = models.ForeignKey( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
on_delete=models.CASCADE, related_name='export_templates',
limit_choices_to=FeatureQuery('export_templates') help_text='The object type(s) to which this template applies.'
) )
name = models.CharField( name = models.CharField(
max_length=100 max_length=100
@ -301,16 +301,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
) )
class Meta: class Meta:
ordering = ['content_type', 'name'] ordering = ('name',)
constraints = (
models.UniqueConstraint(
fields=('content_type', 'name'),
name='%(app_label)s_%(class)s_unique_content_type_name'
),
)
def __str__(self): def __str__(self):
return f"{self.content_type}: {self.name}" return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extras:exporttemplate', args=[self.pk]) return reverse('extras:exporttemplate', args=[self.pk])
@ -505,6 +499,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 +523,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 +563,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 +571,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 +583,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

View 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}'

View File

@ -5,8 +5,8 @@ from packaging import version
from django.apps import AppConfig from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.module_loading import import_string
from extras.plugins.utils import import_object
from extras.registry import registry from extras.registry import registry
from netbox.navigation import MenuGroup from netbox.navigation import MenuGroup
from netbox.search import register_search from netbox.search import register_search
@ -71,31 +71,46 @@ class PluginConfig(AppConfig):
def ready(self): def ready(self):
plugin_name = self.name.rsplit('.', 1)[-1] plugin_name = self.name.rsplit('.', 1)[-1]
# Search extensions # Register search extensions (if defined)
search_indexes = import_object(f"{self.__module__}.{self.search_indexes}") or [] try:
for idx in search_indexes: search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
register_search()(idx) for idx in search_indexes:
register_search(idx)
except ImportError:
pass
# Register template content (if defined) # Register template content (if defined)
template_extensions = import_object(f"{self.__module__}.{self.template_extensions}") try:
if template_extensions is not None: template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
register_template_extensions(template_extensions) register_template_extensions(template_extensions)
except ImportError:
pass
# Register navigation menu or menu items (if defined) # Register navigation menu and/or menu items (if defined)
if menu := import_object(f"{self.__module__}.{self.menu}"): try:
menu = import_string(f"{self.__module__}.{self.menu}")
register_menu(menu) register_menu(menu)
if menu_items := import_object(f"{self.__module__}.{self.menu_items}"): except ImportError:
pass
try:
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
register_menu_items(self.verbose_name, menu_items) register_menu_items(self.verbose_name, menu_items)
except ImportError:
pass
# Register GraphQL schema (if defined) # Register GraphQL schema (if defined)
graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}") try:
if graphql_schema is not None: graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
register_graphql_schema(graphql_schema) register_graphql_schema(graphql_schema)
except ImportError:
pass
# Register user preferences (if defined) # Register user preferences (if defined)
user_preferences = import_object(f"{self.__module__}.{self.user_preferences}") try:
if user_preferences is not None: user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
register_user_preferences(plugin_name, user_preferences) register_user_preferences(plugin_name, user_preferences)
except ImportError:
pass
@classmethod @classmethod
def validate(cls, user_config, netbox_version): def validate(cls, user_config, netbox_version):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
# #
@ -47,17 +69,17 @@ class CustomLinkTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
content_type = columns.ContentTypeColumn() content_types = columns.ContentTypesColumn()
enabled = columns.BooleanColumn() enabled = columns.BooleanColumn()
new_window = columns.BooleanColumn() new_window = columns.BooleanColumn()
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CustomLink model = CustomLink
fields = ( fields = (
'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'pk', 'id', 'name', 'content_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated', 'button_class', 'new_window', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window')
# #
@ -68,17 +90,17 @@ class ExportTemplateTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
content_type = columns.ContentTypeColumn() content_types = columns.ContentTypesColumn()
as_attachment = columns.BooleanColumn() as_attachment = columns.BooleanColumn()
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
'created', 'last_updated', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
) )

View File

@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from extras.models import CustomLink from extras.models import CustomLink
from utilities.utils import render_jinja2
register = template.Library() register = template.Library()
@ -34,7 +33,7 @@ def custom_links(context, obj):
Render all applicable links for the given object. Render all applicable links for the given object.
""" """
content_type = ContentType.objects.get_for_model(obj) content_type = ContentType.objects.get_for_model(obj)
custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True)
if not custom_links: if not custom_links:
return '' return ''

View File

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

View File

@ -137,21 +137,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'content_type': 'dcim.site', 'content_types': ['dcim.site'],
'name': 'Custom Link 4', 'name': 'Custom Link 4',
'enabled': True, 'enabled': True,
'link_text': 'Link 4', 'link_text': 'Link 4',
'link_url': 'http://example.com/?4', 'link_url': 'http://example.com/?4',
}, },
{ {
'content_type': 'dcim.site', 'content_types': ['dcim.site'],
'name': 'Custom Link 5', 'name': 'Custom Link 5',
'enabled': True, 'enabled': True,
'link_text': 'Link 5', 'link_text': 'Link 5',
'link_url': 'http://example.com/?5', 'link_url': 'http://example.com/?5',
}, },
{ {
'content_type': 'dcim.site', 'content_types': ['dcim.site'],
'name': 'Custom Link 6', 'name': 'Custom Link 6',
'enabled': False, 'enabled': False,
'link_text': 'Link 6', 'link_text': 'Link 6',
@ -169,21 +169,18 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
custom_links = ( custom_links = (
CustomLink( CustomLink(
content_type=site_ct,
name='Custom Link 1', name='Custom Link 1',
enabled=True, enabled=True,
link_text='Link 1', link_text='Link 1',
link_url='http://example.com/?1', link_url='http://example.com/?1',
), ),
CustomLink( CustomLink(
content_type=site_ct,
name='Custom Link 2', name='Custom Link 2',
enabled=True, enabled=True,
link_text='Link 2', link_text='Link 2',
link_url='http://example.com/?2', link_url='http://example.com/?2',
), ),
CustomLink( CustomLink(
content_type=site_ct,
name='Custom Link 3', name='Custom Link 3',
enabled=False, enabled=False,
link_text='Link 3', link_text='Link 3',
@ -191,6 +188,8 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
), ),
) )
CustomLink.objects.bulk_create(custom_links) CustomLink.objects.bulk_create(custom_links)
for i, custom_link in enumerate(custom_links):
custom_link.content_types.set([site_ct])
class ExportTemplateTest(APIViewTestCases.APIViewTestCase): class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
@ -198,17 +197,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']
create_data = [ create_data = [
{ {
'content_type': 'dcim.device', 'content_types': ['dcim.device'],
'name': 'Test Export Template 4', 'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}, },
{ {
'content_type': 'dcim.device', 'content_types': ['dcim.device'],
'name': 'Test Export Template 5', 'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}, },
{ {
'content_type': 'dcim.device', 'content_types': ['dcim.device'],
'name': 'Test Export Template 6', 'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}, },
@ -219,26 +218,23 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Device)
export_templates = ( export_templates = (
ExportTemplate( ExportTemplate(
content_type=ct,
name='Export Template 1', name='Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
), ),
ExportTemplate( ExportTemplate(
content_type=ct,
name='Export Template 2', name='Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
), ),
ExportTemplate( ExportTemplate(
content_type=ct,
name='Export Template 3', name='Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
), ),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates:
et.content_types.set([ContentType.objects.get_for_model(Device)])
class TagTest(APIViewTestCases.APIViewTestCase): class TagTest(APIViewTestCases.APIViewTestCase):

View File

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

View File

@ -168,7 +168,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
custom_links = ( custom_links = (
CustomLink( CustomLink(
name='Custom Link 1', name='Custom Link 1',
content_type=content_types[0],
enabled=True, enabled=True,
weight=100, weight=100,
new_window=False, new_window=False,
@ -177,7 +176,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
), ),
CustomLink( CustomLink(
name='Custom Link 2', name='Custom Link 2',
content_type=content_types[1],
enabled=True, enabled=True,
weight=200, weight=200,
new_window=False, new_window=False,
@ -186,7 +184,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
), ),
CustomLink( CustomLink(
name='Custom Link 3', name='Custom Link 3',
content_type=content_types[2],
enabled=False, enabled=False,
weight=300, weight=300,
new_window=True, new_window=True,
@ -195,13 +192,17 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
), ),
) )
CustomLink.objects.bulk_create(custom_links) CustomLink.objects.bulk_create(custom_links)
for i, custom_link in enumerate(custom_links):
custom_link.content_types.set([content_types[i]])
def test_name(self): def test_name(self):
params = {'name': ['Custom Link 1', 'Custom Link 2']} params = {'name': ['Custom Link 1', 'Custom Link 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self): def test_content_types(self):
params = {'content_type': ContentType.objects.get(model='site').pk} params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_weight(self): def test_weight(self):
@ -227,22 +228,25 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'), ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'), ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'),
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), ExportTemplate(name='Export Template 3', template_code='TESTING'),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
for i, et in enumerate(export_templates):
et.content_types.set([content_types[i]])
def test_name(self): def test_name(self):
params = {'name': ['Export Template 1', 'Export Template 2']} params = {'name': ['Export Template 1', 'Export Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self): def test_content_types(self):
params = {'content_type': ContentType.objects.get(model='site').pk} params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self): def test_description(self):

View File

@ -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,18 @@ 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 = (
'id,label',
f'{custom_fields[0].pk},New label 1',
f'{custom_fields[1].pk},New label 2',
f'{custom_fields[2].pk},New label 3',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -58,17 +66,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
CustomLink.objects.bulk_create(( custom_links = (
CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'), CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'), CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'),
CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'), CustomLink(name='Custom Link 3', enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
)) )
CustomLink.objects.bulk_create(custom_links)
for i, custom_link in enumerate(custom_links):
custom_link.content_types.set([site_ct])
cls.form_data = { cls.form_data = {
'name': 'Custom Link X', 'name': 'Custom Link X',
'content_type': site_ct.pk, 'content_types': [site_ct.pk],
'enabled': False, 'enabled': False,
'weight': 100, 'weight': 100,
'button_class': CustomLinkButtonClassChoices.DEFAULT, 'button_class': CustomLinkButtonClassChoices.DEFAULT,
@ -77,12 +87,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"name,content_type,enabled,weight,button_class,link_text,link_url", "name,content_types,enabled,weight,button_class,link_text,link_url",
"Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
"Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
"Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
) )
cls.csv_update_data = (
"id,name",
f"{custom_links[0].pk},Custom Link 7",
f"{custom_links[1].pk},Custom Link 8",
f"{custom_links[2].pk},Custom Link 9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'button_class': CustomLinkButtonClassChoices.CYAN, 'button_class': CustomLinkButtonClassChoices.CYAN,
'enabled': False, 'enabled': False,
@ -95,28 +112,38 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
ExportTemplate.objects.bulk_create((
ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE), export_templates = (
ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE),
)) ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE),
)
ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates:
et.content_types.set([site_ct])
cls.form_data = { cls.form_data = {
'name': 'Export Template X', 'name': 'Export Template X',
'content_type': site_ct.pk, 'content_types': [site_ct.pk],
'template_code': TEMPLATE_CODE, 'template_code': TEMPLATE_CODE,
} }
cls.csv_data = ( cls.csv_data = (
"name,content_type,template_code", "name,content_types,template_code",
f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
f"Export Template 6,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
) )
cls.csv_update_data = (
"id,name",
f"{export_templates[0].pk},Export Template 7",
f"{export_templates[1].pk},Export Template 8",
f"{export_templates[2].pk},Export Template 9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'mime_type': 'text/html', 'mime_type': 'text/html',
'file_extension': 'html', 'file_extension': 'html',
@ -159,6 +186,13 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json", "Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json",
) )
cls.csv_update_data = (
"id,name",
f"{webhooks[0].pk},Webhook 7",
f"{webhooks[1].pk},Webhook 8",
f"{webhooks[2].pk},Webhook 9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'enabled': False, 'enabled': False,
'type_create': False, 'type_create': False,
@ -174,11 +208,12 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
Tag.objects.bulk_create(( tags = (
Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'), Tag(name='Tag 3', slug='tag-3'),
)) )
Tag.objects.bulk_create(tags)
cls.form_data = { cls.form_data = {
'name': 'Tag X', 'name': 'Tag X',
@ -194,6 +229,13 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Tag 6,tag-6,0000ff,Sixth tag", "Tag 6,tag-6,0000ff,Sixth tag",
) )
cls.csv_update_data = (
"id,name,description",
f"{tags[0].pk},Tag 7,Fourth tag7",
f"{tags[1].pk},Tag 8,Fifth tag8",
f"{tags[2].pk},Tag 9,Sixth tag9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'color': '00ff00', 'color': '00ff00',
} }
@ -326,13 +368,13 @@ class CustomLinkTest(TestCase):
def test_view_object_with_custom_link(self): def test_view_object_with_custom_link(self):
customlink = CustomLink( customlink = CustomLink(
content_type=ContentType.objects.get_for_model(Site),
name='Test', name='Test',
link_text='FOO {{ obj.name }} BAR', link_text='FOO {{ obj.name }} BAR',
link_url='http://example.com/?site={{ obj.slug }}', link_url='http://example.com/?site={{ obj.slug }}',
new_window=False new_window=False
) )
customlink.save() customlink.save()
customlink.content_types.set([ContentType.objects.get_for_model(Site)])
site = Site(name='Test Site', slug='test-site') site = Site(name='Test Site', slug='test-site')
site.save() site.save()

View File

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

View File

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

View File

@ -1,4 +1,4 @@
from .models import * from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_create import * from .bulk_create import *
from .bulk_edit import * from .bulk_edit import *

View File

@ -298,13 +298,13 @@ class IPAddressCSVForm(NetBoxModelCSVForm):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Set interface assignment # Set interface assignment
if self.cleaned_data['interface']: if self.cleaned_data.get('interface'):
self.instance.assigned_object = self.cleaned_data['interface'] self.instance.assigned_object = self.cleaned_data['interface']
ipaddress = super().save(*args, **kwargs) ipaddress = super().save(*args, **kwargs)
# Set as primary for device/VM # Set as primary for device/VM
if self.cleaned_data['is_primary']: if self.cleaned_data.get('is_primary'):
parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
if self.instance.address.version == 4: if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress parent.primary_ip4 = ipaddress

View File

@ -3,14 +3,12 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
from extras.models import Tag
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.formfields import IPNetworkFormField from ipam.formfields import IPNetworkFormField
from ipam.models import * from ipam.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.exceptions import PermissionsViolation from utilities.exceptions import PermissionsViolation
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
@ -88,6 +86,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 +168,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 = [
@ -540,6 +550,7 @@ class FHRPGroupForm(NetBoxModelForm):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs) instance = super().save(*args, **kwargs)
user = getattr(instance, '_user', None) # Set under FHRPGroupEditView.alter_object()
# Check if we need to create a new IPAddress for the group # Check if we need to create a new IPAddress for the group
if self.cleaned_data.get('ip_address'): if self.cleaned_data.get('ip_address'):
@ -553,7 +564,7 @@ class FHRPGroupForm(NetBoxModelForm):
ipaddress.save() ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions # Check that the new IPAddress conforms with any assigned object-level permissions
if not IPAddress.objects.filter(pk=ipaddress.pk).first(): if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first():
raise PermissionsViolation() raise PermissionsViolation()
return instance return instance
@ -784,6 +795,12 @@ class ServiceTemplateForm(NetBoxModelForm):
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
) )
fieldsets = (
('Service Template', (
'name', 'protocol', 'ports', 'description', 'tags',
)),
)
class Meta: class Meta:
model = ServiceTemplate model = ServiceTemplate
fields = ('name', 'protocol', 'ports', 'description', 'tags') fields = ('name', 'protocol', 'ports', 'description', 'tags')

View File

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

View File

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

View File

@ -375,7 +375,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
) )
assigned = columns.BooleanColumn( assigned = columns.BooleanColumn(
accessor='assigned_object_id', accessor='assigned_object_id',
linkify=True, linkify=lambda record: record.assigned_object.get_absolute_url(),
verbose_name='Assigned' verbose_name='Assigned'
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -60,6 +60,13 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"4200000002,RFC 6996", "4200000002,RFC 6996",
) )
cls.csv_update_data = (
"id,description",
f"{asns[0].pk},New description1",
f"{asns[1].pk},New description2",
f"{asns[2].pk},New description3",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'rir': rirs[1].pk, 'rir': rirs[1].pk,
'description': 'Next description', 'description': 'Next description',
@ -78,11 +85,12 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
VRF.objects.bulk_create([ vrfs = (
VRF(name='VRF 1', rd='65000:1'), VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'), VRF(name='VRF 2', rd='65000:2'),
VRF(name='VRF 3', rd='65000:3'), VRF(name='VRF 3', rd='65000:3'),
]) )
VRF.objects.bulk_create(vrfs)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -102,6 +110,13 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VRF 6", "VRF 6",
) )
cls.csv_update_data = (
"id,name",
f"{vrfs[0].pk},VRF 7",
f"{vrfs[1].pk},VRF 8",
f"{vrfs[2].pk},VRF 9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'tenant': tenants[1].pk, 'tenant': tenants[1].pk,
'enforce_unique': False, 'enforce_unique': False,
@ -143,6 +158,13 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"65000:1006,,No tenant", "65000:1006,,No tenant",
) )
cls.csv_update_data = (
"id,name,description",
f"{route_targets[0].pk},65000:1007,New description1",
f"{route_targets[1].pk},65000:1008,New description2",
f"{route_targets[2].pk},65000:1009,New description3",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'tenant': tenants[1].pk, 'tenant': tenants[1].pk,
'description': 'New description', 'description': 'New description',
@ -155,11 +177,12 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
RIR.objects.bulk_create([ rirs = (
RIR(name='RIR 1', slug='rir-1'), RIR(name='RIR 1', slug='rir-1'),
RIR(name='RIR 2', slug='rir-2'), RIR(name='RIR 2', slug='rir-2'),
RIR(name='RIR 3', slug='rir-3'), RIR(name='RIR 3', slug='rir-3'),
]) )
RIR.objects.bulk_create(rirs)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -178,6 +201,13 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"RIR 6,rir-6,Sixth RIR", "RIR 6,rir-6,Sixth RIR",
) )
cls.csv_update_data = (
"id,name,description",
f"{rirs[0].pk},RIR 7,Fourth RIR7",
f"{rirs[1].pk},RIR 8,Fifth RIR8",
f"{rirs[2].pk},RIR 9,Sixth RIR9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
@ -195,11 +225,12 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
RIR.objects.bulk_create(rirs) RIR.objects.bulk_create(rirs)
Aggregate.objects.bulk_create([ aggregates = (
Aggregate(prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]),
Aggregate(prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]),
Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
]) )
Aggregate.objects.bulk_create(aggregates)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -218,6 +249,13 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"10.6.0.0/16,RIR 1", "10.6.0.0/16,RIR 1",
) )
cls.csv_update_data = (
"id,description",
f"{aggregates[0].pk},New description1",
f"{aggregates[1].pk},New description2",
f"{aggregates[2].pk},New description3",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'rir': rirs[1].pk, 'rir': rirs[1].pk,
'date_added': datetime.date(2020, 1, 1), 'date_added': datetime.date(2020, 1, 1),
@ -246,11 +284,12 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
Role.objects.bulk_create([ roles = (
Role(name='Role 1', slug='role-1'), Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'), Role(name='Role 2', slug='role-2'),
Role(name='Role 3', slug='role-3'), Role(name='Role 3', slug='role-3'),
]) )
Role.objects.bulk_create(roles)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -269,6 +308,13 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Role 6,role-6,1000", "Role 6,role-6,1000",
) )
cls.csv_update_data = (
"id,name,description",
f"{roles[0].pk},Role 7,New description7",
f"{roles[1].pk},Role 8,New description8",
f"{roles[2].pk},Role 9,New description9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
@ -298,11 +344,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Role.objects.bulk_create(roles) Role.objects.bulk_create(roles)
Prefix.objects.bulk_create([ prefixes = (
Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
]) )
Prefix.objects.bulk_create(prefixes)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -326,6 +373,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VRF 1,10.6.0.0/16,active", "VRF 1,10.6.0.0/16,active",
) )
cls.csv_update_data = (
"id,description,status",
f"{prefixes[0].pk},New description 7,{PrefixStatusChoices.STATUS_RESERVED}",
f"{prefixes[1].pk},New description 8,{PrefixStatusChoices.STATUS_RESERVED}",
f"{prefixes[2].pk},New description 9,{PrefixStatusChoices.STATUS_RESERVED}",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'site': sites[1].pk, 'site': sites[1].pk,
'vrf': vrfs[1].pk, 'vrf': vrfs[1].pk,
@ -428,6 +482,13 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VRF 1,10.3.0.1/16,10.3.9.254/16,active", "VRF 1,10.3.0.1/16,10.3.9.254/16,active",
) )
cls.csv_update_data = (
"id,description,status",
f"{ip_ranges[0].pk},New description 7,{IPRangeStatusChoices.STATUS_RESERVED}",
f"{ip_ranges[1].pk},New description 8,{IPRangeStatusChoices.STATUS_RESERVED}",
f"{ip_ranges[2].pk},New description 9,{IPRangeStatusChoices.STATUS_RESERVED}",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'vrf': vrfs[1].pk, 'vrf': vrfs[1].pk,
'tenant': None, 'tenant': None,
@ -467,11 +528,12 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
VRF.objects.bulk_create(vrfs) VRF.objects.bulk_create(vrfs)
IPAddress.objects.bulk_create([ ipaddresses = (
IPAddress(address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]),
IPAddress(address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]),
IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
]) )
IPAddress.objects.bulk_create(ipaddresses)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -494,6 +556,13 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VRF 1,192.0.2.6/24,active", "VRF 1,192.0.2.6/24,active",
) )
cls.csv_update_data = (
"id,description,status",
f"{ipaddresses[0].pk},New description 7,{IPAddressStatusChoices.STATUS_RESERVED}",
f"{ipaddresses[1].pk},New description 8,{IPAddressStatusChoices.STATUS_RESERVED}",
f"{ipaddresses[2].pk},New description 9,{IPAddressStatusChoices.STATUS_RESERVED}",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'vrf': vrfs[1].pk, 'vrf': vrfs[1].pk,
'tenant': None, 'tenant': None,
@ -510,11 +579,12 @@ class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
FHRPGroup.objects.bulk_create(( fhrp_groups = (
FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'),
FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'),
FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30),
)) )
FHRPGroup.objects.bulk_create(fhrp_groups)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -535,6 +605,13 @@ class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"hsrp,60,,,", "hsrp,60,,,",
) )
cls.csv_update_data = (
"id,name,description",
f"{fhrp_groups[0].pk},FHRP Group 1,New description 1",
f"{fhrp_groups[1].pk},FHRP Group 2,New description 2",
f"{fhrp_groups[2].pk},FHRP Group 3,New description 3",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'protocol': FHRPGroupProtocolChoices.PROTOCOL_CARP, 'protocol': FHRPGroupProtocolChoices.PROTOCOL_CARP,
} }
@ -552,11 +629,12 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
VLANGroup.objects.bulk_create([ vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]), VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]),
]) )
VLANGroup.objects.bulk_create(vlan_groups)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -576,6 +654,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group", f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
) )
cls.csv_update_data = (
f"id,name,description",
f"{vlan_groups[0].pk},VLAN Group 7,Fourth VLAN group7",
f"{vlan_groups[1].pk},VLAN Group 8,Fifth VLAN group8",
f"{vlan_groups[2].pk},VLAN Group 9,Sixth VLAN group9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
@ -605,11 +690,12 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Role.objects.bulk_create(roles) Role.objects.bulk_create(roles)
VLAN.objects.bulk_create([ vlans = (
VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]),
VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]),
VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
]) )
VLAN.objects.bulk_create(vlans)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -632,6 +718,13 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"106,VLAN106,active", "106,VLAN106,active",
) )
cls.csv_update_data = (
"id,name,description",
f"{vlans[0].pk},VLAN107,New description 7",
f"{vlans[1].pk},VLAN108,New description 8",
f"{vlans[2].pk},VLAN109,New description 9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'site': sites[1].pk, 'site': sites[1].pk,
'group': vlangroups[1].pk, 'group': vlangroups[1].pk,
@ -647,11 +740,12 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
ServiceTemplate.objects.bulk_create([ service_templates = (
ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]),
ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
]) )
ServiceTemplate.objects.bulk_create(service_templates)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -670,6 +764,13 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Service Template 6,tcp,3,Third service template", "Service Template 6,tcp,3,Third service template",
) )
cls.csv_update_data = (
"id,name,description",
f"{service_templates[0].pk},Service Template 7,First service template7",
f"{service_templates[1].pk},Service Template 8,Second service template8",
f"{service_templates[2].pk},Service Template 9,Third service template9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
'ports': '106,107', 'ports': '106,107',
@ -689,11 +790,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
Service.objects.bulk_create([ services = (
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]),
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
]) )
Service.objects.bulk_create(services)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -715,6 +817,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Device 1,Service 3,udp,3,Third service", "Device 1,Service 3,udp,3,Third service",
) )
cls.csv_update_data = (
"id,name,description",
f"{services[0].pk},Service 7,First service7",
f"{services[1].pk},Service 8,Second service8",
f"{services[2].pk},Service 9,Third service9",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
'ports': '106,107', 'ports': '106,107',
@ -751,14 +860,6 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = L2VPN model = L2VPN
csv_data = (
'name,slug,type,identifier',
'L2VPN 5,l2vpn-5,vxlan,456',
'L2VPN 6,l2vpn-6,vxlan,444',
)
bulk_edit_data = {
'description': 'New Description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -773,9 +874,24 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'),
L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003')
) )
L2VPN.objects.bulk_create(l2vpns) L2VPN.objects.bulk_create(l2vpns)
cls.csv_data = (
'name,slug,type,identifier',
'L2VPN 5,l2vpn-5,vxlan,456',
'L2VPN 6,l2vpn-6,vxlan,444',
)
cls.csv_update_data = (
'id,name,description',
f'{l2vpns[0].pk},L2VPN 7,New description 7',
f'{l2vpns[1].pk},L2VPN 8,New description 8',
)
cls.bulk_edit_data = {
'description': 'New Description',
}
cls.form_data = { cls.form_data = {
'name': 'L2VPN 8', 'name': 'L2VPN 8',
'slug': 'l2vpn-8', 'slug': 'l2vpn-8',
@ -804,7 +920,7 @@ class L2VPNTerminationTestCase(
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset')
l2vpn = L2VPN.objects.create(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001) l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001)
vlans = ( vlans = (
VLAN(name='Vlan 1', vid=1001), VLAN(name='Vlan 1', vid=1001),
@ -836,6 +952,13 @@ class L2VPNTerminationTestCase(
"L2VPN 1,Vlan 6", "L2VPN 1,Vlan 6",
) )
cls.csv_update_data = (
"id,l2vpn",
f"{terminations[0].pk},L2VPN 2",
f"{terminations[1].pk},L2VPN 2",
f"{terminations[2].pk},L2VPN 2",
)
cls.bulk_edit_data = {} cls.bulk_edit_data = {}
# #

View File

@ -985,6 +985,12 @@ class FHRPGroupEditView(generic.ObjectEditView):
return return_url return return_url
def alter_object(self, obj, request, url_args, url_kwargs):
# Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that
# we can evaluate permissions during the creation of a new IPAddress within the form.
obj._user = request.user
return obj
@register_model_view(FHRPGroup, 'delete') @register_model_view(FHRPGroup, 'delete')
class FHRPGroupDeleteView(generic.ObjectDeleteView): class FHRPGroupDeleteView(generic.ObjectDeleteView):

View File

@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication):
if token.is_expired: if token.is_expired:
raise exceptions.AuthenticationFailed("Token expired") raise exceptions.AuthenticationFailed("Token expired")
if not token.user.is_active: user = token.user
raise exceptions.AuthenticationFailed("User inactive")
# When LDAP authentication is active try to load user data from LDAP directory # When LDAP authentication is active try to load user data from LDAP directory
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
from netbox.authentication import LDAPBackend from netbox.authentication import LDAPBackend
ldap_backend = LDAPBackend() ldap_backend = LDAPBackend()
# Load from LDAP if FIND_GROUP_PERMS is active # Load from LDAP if FIND_GROUP_PERMS is active
if ldap_backend.settings.FIND_GROUP_PERMS: # Always query LDAP when user is not active, otherwise it is never activated again
user = ldap_backend.populate_user(token.user.username) if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
ldap_user = ldap_backend.populate_user(token.user.username)
# If the user is found in the LDAP directory use it, if not fallback to the local user # If the user is found in the LDAP directory use it, if not fallback to the local user
if user: if ldap_user:
return user, token user = ldap_user
return token.user, token if not user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return user, token
class TokenPermissions(DjangoObjectPermissions): class TokenPermissions(DjangoObjectPermissions):

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404 from django.http import Http404
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
@ -142,7 +142,9 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
""" """
if 'export' in request.GET: if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()
if et is None:
raise Http404
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
return et.render_to_response(queryset) return et.render_to_response(queryset)

View File

@ -108,6 +108,5 @@ class ObjectValidationMixin:
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance): if conforming_count != len(instance):
raise ObjectDoesNotExist raise ObjectDoesNotExist
else: elif not self.queryset.filter(pk=instance.pk).exists():
# Check that the instance is matched by the view's queryset raise ObjectDoesNotExist
self.queryset.get(pk=instance.pk)

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
from django.conf import settings
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@ -26,6 +27,10 @@ class NetBoxFeatureSet(
class Meta: class Meta:
abstract = True abstract = True
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
@classmethod @classmethod
def get_prerequisite_models(cls): def get_prerequisite_models(cls):
""" """

View File

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

Some files were not shown because too many files have changed in this diff Show More