diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index a83e9b34e..a37c5dfb1 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -56,8 +56,3 @@ body:
placeholder: "A TypeError exception was raised"
validations:
required: true
- - type: markdown
- attributes:
- value: |
- ### Additional information
- You can use the space below to provide any additional information or to attach files.
diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml
index 3b2026b34..b480e629a 100644
--- a/.github/ISSUE_TEMPLATE/documentation_change.yaml
+++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml
@@ -14,25 +14,22 @@ body:
- Cleanup (formatting, typos, etc.)
validations:
required: true
- - type: checkboxes
+ - type: dropdown
attributes:
label: Area
- description: To what section(s) of the documentation does this change pertain?
+ description: To what section of the documentation does this change primarily pertain?
options:
- - label: Installation instructions
- - label: Configuration parameters
- - label: Functionality/features
- - label: REST API
- - label: Administration/development
- - label: Other
+ - Installation instructions
+ - Configuration parameters
+ - Functionality/features
+ - REST API
+ - Administration/development
+ - Other
+ validations:
+ required: true
- type: textarea
attributes:
label: Proposed Changes
description: "Describe the proposed changes and why they are necessary"
validations:
required: true
- - type: markdown
- attributes:
- value: |
- ### Additional information
- You can use the space below to provide any additional information or to attach files.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index efa83b376..6282eedde 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -51,8 +51,3 @@ body:
description: "List any new dependencies on external libraries or services that this
new feature would introduce. For example, does the proposal require the installation
of a new Python package? (Not all new features introduce new dependencies.)"
- - type: markdown
- attributes:
- value: |
- ### Additional information
- You can use the space below to provide any additional information or to attach files.
diff --git a/.github/ISSUE_TEMPLATE/housekeeping.yaml b/.github/ISSUE_TEMPLATE/housekeeping.yaml
index 0f466aa24..778dca235 100644
--- a/.github/ISSUE_TEMPLATE/housekeeping.yaml
+++ b/.github/ISSUE_TEMPLATE/housekeeping.yaml
@@ -20,8 +20,3 @@ body:
description: "Please provide justification for the proposed change(s)."
validations:
required: true
- - type: markdown
- attributes:
- value: |
- ### Additional information
- You can use the space below to provide any additional information or to attach files.
diff --git a/.github/stale.yml b/.github/stale.yml
deleted file mode 100644
index 92da07e6a..000000000
--- a/.github/stale.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-# Configuration for Stale (https://github.com/apps/stale)
-
-# Number of days of inactivity before an issue becomes stale
-daysUntilStale: 45
-
-# Number of days of inactivity before a stale issue is closed
-daysUntilClose: 15
-
-# Issues with these labels will never be considered stale
-exemptLabels:
- - "status: accepted"
- - "status: blocked"
- - "status: needs milestone"
-
-# Label to use when marking an issue as stale
-staleLabel: "pending closure"
-
-# Comment to post when marking an issue as stale. Set to `false` to disable
-markComment: >
- This issue has been automatically marked as stale because it has not had
- recent activity. It will be closed if no further activity occurs. NetBox
- is governed by a small group of core maintainers which means not all opened
- issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
-
-# Comment to post when closing a stale issue. Set to `false` to disable
-closeComment: >
- This issue has been automatically closed due to lack of activity. In an
- effort to reduce noise, please do not comment any further. Note that the
- core maintainers may elect to reopen this issue at a later date if deemed
- necessary.
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 000000000..8fc85ead6
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,34 @@
+# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
+name: 'Close stale issues/PRs'
+on:
+ schedule:
+ - cron: '0 4 * * *'
+
+jobs:
+ stale:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/stale@v3
+ with:
+ close-issue-message: >
+ This issue has been automatically closed due to lack of activity. In an
+ effort to reduce noise, please do not comment any further. Note that the
+ core maintainers may elect to reopen this issue at a later date if deemed
+ necessary.
+ close-pr-message: >
+ This PR has been automatically closed due to lack of activity.
+ days-before-stale: 45
+ days-before-close: 15
+ exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
+ remove-stale-when-updated: false
+ stale-issue-label: 'pending closure'
+ stale-issue-message: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed if no further activity occurs. NetBox
+ is governed by a small group of core maintainers which means not all opened
+ issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
+ stale-pr-label: 'pending closure'
+ stale-pr-message: >
+ This PR has been automatically marked as stale because it has not had
+ recent activity. It will be closed automatically if no further action is
+ taken.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cd62c5ab7..009e6586c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,7 +25,7 @@ discussions.
### Slack
-For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ).
+For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://slack.netbox.dev/).
Unfortunately, the Slack channel does not provide long-term retention of chat
history, so try to avoid it for any discussions would benefit from being
preserved for future reference.
diff --git a/README.md b/README.md
index 880fa8c08..e35b72c2e 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
### Discussion
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
-* [Slack](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
+* [Slack](https://slack.netbox.dev/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
### Build Status
diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md
index 359d65202..ebce63578 100644
--- a/docs/additional-features/caching.md
+++ b/docs/additional-features/caching.md
@@ -1,6 +1,6 @@
# Caching
-NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../../configuration/optional-settings/#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
+NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database.
diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md
index 5c74c6744..618d07052 100644
--- a/docs/additional-features/custom-fields.md
+++ b/docs/additional-features/custom-fields.md
@@ -38,9 +38,13 @@ NetBox supports limited custom validation for custom field values. Following are
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
-If a default value is specified for a selection field, it must exactly match one of the provided choices.
+If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
-The value of a multiple selection field will always return a list, even if only one value is selected.
+## Custom Fields in Templates
+
+Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`).
+
+For example, a custom field named `foo123` on the Site model is accessible on an instance as `{{ site.cf.foo123 }}`.
## Custom Fields and the REST API
diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md
index 1e0611f06..b3f585bee 100644
--- a/docs/additional-features/export-templates.md
+++ b/docs/additional-features/export-templates.md
@@ -2,7 +2,10 @@
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface.
-Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
+Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension.
+
+!!! note
+ The name `table` is reserved for internal use.
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
@@ -18,6 +21,16 @@ Height: {{ rack.u_height }}U
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
+If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
+```
+{% for server in queryset %}
+{% set data = server.get_config_context() %}
+{{ data.syslog }}
+{% endfor %}
+```
+
+The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.)
+
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
## Example
diff --git a/docs/additional-features/journaling.md b/docs/additional-features/journaling.md
new file mode 100644
index 000000000..ce126bf27
--- /dev/null
+++ b/docs/additional-features/journaling.md
@@ -0,0 +1,5 @@
+# Journaling
+
+All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object.
+
+Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created.
diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md
index c8a3665b9..957a5a214 100644
--- a/docs/additional-features/napalm.md
+++ b/docs/additional-features/napalm.md
@@ -2,6 +2,13 @@
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
+The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met:
+
+* Device status is "Active"
+* A primary IP has been assigned to the device
+* A platform with a NAPALM driver has been assigned
+* The authenticated user has the `dcim.napalm_read_device` permission
+
!!! note
To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.
@@ -22,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
## Authentication
-By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
+By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md
index 1266c22ec..bac1003fd 100644
--- a/docs/additional-features/reports.md
+++ b/docs/additional-features/reports.md
@@ -12,7 +12,7 @@ A NetBox report is a mechanism for validating the integrity of data within NetBo
## Writing Reports
-Reports must be saved as files in the [`REPORTS_ROOT`](../../configuration/optional-settings/#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
+Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
!!! warning
The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file.
diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
index c66c65543..c7c8996dc 100644
--- a/docs/administration/permissions.md
+++ b/docs/administration/permissions.md
@@ -10,7 +10,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's
| ----------- | ----------- |
| `{"status": "active"}` | Status is active |
| `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
-| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing |
+| `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing |
| `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
| `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
| `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |
diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md
index 23c1082bc..ee956edf5 100644
--- a/docs/administration/replicating-netbox.md
+++ b/docs/administration/replicating-netbox.md
@@ -12,13 +12,16 @@ NetBox employs a [PostgreSQL](https://www.postgresql.org/) database, so general
Use the `pg_dump` utility to export the entire database to a file:
```no-highlight
-pg_dump netbox > netbox.sql
+pg_dump --username netbox --password --host localhost netbox > netbox.sql
```
+!!! note
+ You may need to change the username, host, and/or database in the command above to match your installation.
+
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
```no-highlight
-pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql
+pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql
```
### Load an Exported Database
@@ -41,7 +44,7 @@ Keep in mind that PostgreSQL user accounts and permissions are not included with
If you want to export only the database schema, and not the data itself (e.g. for development reference), do the following:
```no-highlight
-pg_dump -s netbox > netbox_schema.sql
+pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.sql
```
---
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 4af83493e..4ed3d946e 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -281,6 +281,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
---
+## MAPS_URL
+
+Default: `https://maps.google.com/?q=` (Google Maps)
+
+This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
+
+---
+
## MAX_PAGE_SIZE
Default: 1000
@@ -301,7 +309,7 @@ The file path to the location where media files (such as image attachments) are
Default: False
-Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../../additional-features/prometheus-metrics/) documentation for more details.
+Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details.
---
diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md
index dba8cdc8c..3158fc73a 100644
--- a/docs/configuration/required-settings.md
+++ b/docs/configuration/required-settings.md
@@ -66,6 +66,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID
* `SSL` - Use SSL connection to Redis
+* `INSECURE_SKIP_TLS_VERIFY` - Set to `True` to **disable** TLS certificate verification (not recommended)
An example configuration is provided below:
diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md
index 43b911308..51261858c 100644
--- a/docs/core-functionality/circuits.md
+++ b/docs/core-functionality/circuits.md
@@ -1,6 +1,7 @@
# Circuits
{!docs/models/circuits/provider.md!}
+{!docs/models/circuits/providernetwork.md!}
---
diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md
index 96dcf866d..e05d6efd3 100644
--- a/docs/core-functionality/devices.md
+++ b/docs/core-functionality/devices.md
@@ -8,6 +8,8 @@
## Device Components
+Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources.
+
{!docs/models/dcim/consoleport.md!}
{!docs/models/dcim/consoleserverport.md!}
{!docs/models/dcim/powerport.md!}
diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md
index 9b2249653..beeae2ffb 100644
--- a/docs/development/getting-started.md
+++ b/docs/development/getting-started.md
@@ -5,8 +5,8 @@
Getting started with NetBox development is pretty straightforward, and should feel very familiar to anyone with Django development experience. There are a few things you'll need:
* A Linux system or environment
-* A PostgreSQL server, which can be installed locally [per the documentation](/installation/1-postgresql/)
-* A Redis server, which can also be [installed locally](/installation/2-redis/)
+* A PostgreSQL server, which can be installed locally [per the documentation](../installation/1-postgresql.md)
+* A Redis server, which can also be [installed locally](../installation/2-redis.md)
* A supported version of Python
### Fork the Repo
diff --git a/docs/development/index.md b/docs/development/index.md
index bbcb1eac8..e9758e74b 100644
--- a/docs/development/index.md
+++ b/docs/development/index.md
@@ -8,7 +8,7 @@ There are several official forums for communication among the developers and com
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
-* [#netbox on NetDev Community Slack](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
+* [#netbox on NetDev Community Slack](https://slack.netbox.dev/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
## Governance
diff --git a/docs/development/models.md b/docs/development/models.md
new file mode 100644
index 000000000..1e9b17d22
--- /dev/null
+++ b/docs/development/models.md
@@ -0,0 +1,96 @@
+# NetBox Models
+
+## Model Types
+
+A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type.
+
+### Features Matrix
+
+* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log
+* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
+* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields
+* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models
+* Tagging - The models can be tagged with user-defined tags
+* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
+* Nesting - These models can be nested recursively to create a hierarchy
+
+| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting |
+| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
+| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | |
+| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | |
+| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: |
+| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
+| Component Template | :material-check: | :material-check: | :material-check: | | | | |
+
+## Models Index
+
+### Primary Models
+
+* [circuits.Circuit](../models/circuits/circuit.md)
+* [circuits.Provider](../models/circuits/provider.md)
+* [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
+* [dcim.Cable](../models/dcim/cable.md)
+* [dcim.Device](../models/dcim/device.md)
+* [dcim.DeviceType](../models/dcim/devicetype.md)
+* [dcim.PowerFeed](../models/dcim/powerfeed.md)
+* [dcim.PowerPanel](../models/dcim/powerpanel.md)
+* [dcim.Rack](../models/dcim/rack.md)
+* [dcim.RackReservation](../models/dcim/rackreservation.md)
+* [dcim.Site](../models/dcim/site.md)
+* [dcim.VirtualChassis](../models/dcim/virtualchassis.md)
+* [ipam.Aggregate](../models/ipam/aggregate.md)
+* [ipam.IPAddress](../models/ipam/ipaddress.md)
+* [ipam.Prefix](../models/ipam/prefix.md)
+* [ipam.RouteTarget](../models/ipam/routetarget.md)
+* [ipam.Service](../models/ipam/service.md)
+* [ipam.VLAN](../models/ipam/vlan.md)
+* [ipam.VRF](../models/ipam/vrf.md)
+* [secrets.Secret](../models/secrets/secret.md)
+* [tenancy.Tenant](../models/tenancy/tenant.md)
+* [virtualization.Cluster](../models/virtualization/cluster.md)
+* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
+
+### Organizational Models
+
+* [circuits.CircuitType](../models/circuits/circuittype.md)
+* [dcim.DeviceRole](../models/dcim/devicerole.md)
+* [dcim.Manufacturer](../models/dcim/manufacturer.md)
+* [dcim.Platform](../models/dcim/platform.md)
+* [dcim.RackRole](../models/dcim/rackrole.md)
+* [ipam.RIR](../models/ipam/rir.md)
+* [ipam.Role](../models/ipam/role.md)
+* [ipam.VLANGroup](../models/ipam/vlangroup.md)
+* [secrets.SecretRole](../models/secrets/secretrole.md)
+* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md)
+* [virtualization.ClusterType](../models/virtualization/clustertype.md)
+
+### Nested Group Models
+
+* [dcim.Location](../models/dcim/location.md) (formerly RackGroup)
+* [dcim.Region](../models/dcim/region.md)
+* [dcim.SiteGroup](../models/dcim/sitegroup.md)
+* [tenancy.TenantGroup](../models/tenancy/tenantgroup.md)
+
+### Component Models
+
+* [dcim.ConsolePort](../models/dcim/consoleport.md)
+* [dcim.ConsoleServerPort](../models/dcim/consoleserverport.md)
+* [dcim.DeviceBay](../models/dcim/devicebay.md)
+* [dcim.FrontPort](../models/dcim/frontport.md)
+* [dcim.Interface](../models/dcim/interface.md)
+* [dcim.InventoryItem](../models/dcim/inventoryitem.md)
+* [dcim.PowerOutlet](../models/dcim/poweroutlet.md)
+* [dcim.PowerPort](../models/dcim/powerport.md)
+* [dcim.RearPort](../models/dcim/rearport.md)
+* [virtualization.VMInterface](../models/virtualization/vminterface.md)
+
+### Component Template Models
+
+* [dcim.ConsolePortTemplate](../models/dcim/consoleporttemplate.md)
+* [dcim.ConsoleServerPortTemplate](../models/dcim/consoleserverporttemplate.md)
+* [dcim.DeviceBayTemplate](../models/dcim/devicebaytemplate.md)
+* [dcim.FrontPortTemplate](../models/dcim/frontporttemplate.md)
+* [dcim.InterfaceTemplate](../models/dcim/interfacetemplate.md)
+* [dcim.PowerOutletTemplate](../models/dcim/poweroutlettemplate.md)
+* [dcim.PowerPortTemplate](../models/dcim/powerporttemplate.md)
+* [dcim.RearPortTemplate](../models/dcim/rearporttemplate.md)
diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index 1db490798..39008f188 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -7,8 +7,6 @@ This section entails the installation and configuration of a local PostgreSQL da
## Installation
-## Installation
-
=== "Ubuntu"
```no-highlight
@@ -26,14 +24,14 @@ This section entails the installation and configuration of a local PostgreSQL da
!!! info
PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/).
-CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
+ CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
-```no-highlight
-host all all 127.0.0.1/32 md5
-host all all ::1/128 md5
-```
+ ```no-highlight
+ host all all 127.0.0.1/32 md5
+ host all all ::1/128 md5
+ ```
-Then, start the service and enable it to run at boot:
+Once PostgreSQL has been installed, start the service and enable it to run at boot:
```no-highlight
sudo systemctl start postgresql
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 03e224e1f..649898e0a 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -9,13 +9,13 @@ Begin by installing all system packages required by NetBox and its dependencies.
!!! note
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8.
-=== Ubuntu
+=== "Ubuntu"
```no-highlight
sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
```
-=== CentOS
+=== "CentOS"
```no-highlight
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
@@ -57,13 +57,13 @@ sudo mkdir -p /opt/netbox/ && cd /opt/netbox/
If `git` is not already installed, install it:
-=== Ubuntu
+=== "Ubuntu"
```no-highlight
sudo apt install -y git
```
-=== CentOS
+=== "CentOS"
```no-highlight
sudo yum install -y git
@@ -89,14 +89,14 @@ Checking connectivity... done.
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files.
-=== Ubuntu
+=== "Ubuntu"
```
sudo adduser --system --group netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/
```
-=== CentOS
+=== "CentOS"
```
sudo groupadd --system netbox
@@ -113,7 +113,7 @@ cd /opt/netbox/netbox/netbox/
sudo cp configuration.example.py configuration.py
```
-Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations:
+Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
* `ALLOWED_HOSTS`
* `DATABASE`
@@ -136,7 +136,7 @@ ALLOWED_HOSTS = ['*']
### DATABASE
-This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](/configuration/required-settings/#database) for more detail on individual parameters.
+This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-settings.md#database) for more detail on individual parameters.
```python
DATABASE = {
@@ -151,7 +151,7 @@ DATABASE = {
### REDIS
-Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters.
+Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings.md#redis) for more detail on individual parameters.
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
@@ -203,7 +203,7 @@ sudo echo napalm >> /opt/netbox/local_requirements.txt
### Remote File Storage
-By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`.
+By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`.
```no-highlight
sudo echo django-storages >> /opt/netbox/local_requirements.txt
@@ -264,7 +264,7 @@ Quit the server with CONTROL-C.
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page.
-!!! warning
+!!! danger
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
!!! warning
diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md
index 1c5424595..5d5135b33 100644
--- a/docs/installation/6-ldap.md
+++ b/docs/installation/6-ldap.md
@@ -142,7 +142,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
-For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`:
+For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/optional-settings.md#logging) configuration to `configuration.py`:
```python
LOGGING = {
diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md
index cdd20f01b..e824ad7ab 100644
--- a/docs/installation/upgrading.md
+++ b/docs/installation/upgrading.md
@@ -2,7 +2,7 @@
## Review the Release Notes
-Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
+Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
## Update Dependencies to Required Versions
diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md
index 1c0dbfe18..beea2f85a 100644
--- a/docs/models/circuits/circuittermination.md
+++ b/docs/models/circuits/circuittermination.md
@@ -2,9 +2,9 @@
The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites.
-Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details.
+Each circuit termination is attached to either a site or to a provider network. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details.
-In adherence with NetBox's philosophy of closely modeling the real world, a circuit may terminate only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely.
+In adherence with NetBox's philosophy of closely modeling the real world, a circuit may be connected only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely.
!!! note
- A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure.
+ A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The provider network model is ideal for representing these networks.
diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md
new file mode 100644
index 000000000..970a9f8a8
--- /dev/null
+++ b/docs/models/circuits/providernetwork.md
@@ -0,0 +1,5 @@
+# Provider Network
+
+This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity.
+
+Each provider network must be assigned to a provider. A circuit may terminate to either a provider network or to a site.
diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md
index 2210e4028..a99078472 100644
--- a/docs/models/dcim/device.md
+++ b/docs/models/dcim/device.md
@@ -8,7 +8,7 @@ A device is said to be full-depth if its installation on one rack face prevents
Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.)
-Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or within a site. A platform, serial number, and asset tag may optionally be assigned to each device.
+Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device.
Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed.
diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md
index 756e320af..bd9975a72 100644
--- a/docs/models/dcim/interface.md
+++ b/docs/models/dcim/interface.md
@@ -2,11 +2,15 @@
Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management).
-Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces.
+!!! note
+ Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa.
+
+### Interface Types
+
+Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. Virtual interfaces, such as 802.1Q-tagged subinterfaces, may be assigned to physical parent interfaces.
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
-IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
+### IP Address Assignment
-!!! note
- Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa.
+IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md
index 710d8e8fe..16df208ac 100644
--- a/docs/models/dcim/location.md
+++ b/docs/models/dcim/location.md
@@ -1,5 +1,5 @@
# Locations
-Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar concept. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
+Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.)
diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md
index af81cfbf9..bb4a22e0d 100644
--- a/docs/models/extras/configcontext.md
+++ b/docs/models/extras/configcontext.md
@@ -3,11 +3,13 @@
Sometimes it is desirable to associate additional data with a group of devices or virtual machines to aid in automated configuration. For example, you might want to associate a set of syslog servers for all devices within a particular region. Context data enables the association of extra user-defined data with devices and virtual machines grouped by one or more of the following assignments:
* Region
+* Site group
* Site
+* Device type (devices only)
* Role
* Platform
-* Cluster group
-* Cluster
+* Cluster group (VMs only)
+* Cluster (VMs only)
* Tenant group
* Tenant
* Tag
diff --git a/docs/models/extras/journalentry.md b/docs/models/extras/journalentry.md
deleted file mode 100644
index c95340a01..000000000
--- a/docs/models/extras/journalentry.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Journal Entries
-
-All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire.
-
-Each journal entry has a user-populated `commnets` field. Each entry records the date and time, associated user, and object automatically upon being created.
diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md
index f252204c5..c7aa0d05f 100644
--- a/docs/models/ipam/vlan.md
+++ b/docs/models/ipam/vlan.md
@@ -1,6 +1,6 @@
# VLANs
-A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site, tenant, and/or VLAN group.
+A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into VLAN groups to define scope and to enforce uniqueness.
Each VLAN must be assigned one of the following operational statuses:
diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md
index 7a0bb80ff..819d45982 100644
--- a/docs/models/ipam/vlangroup.md
+++ b/docs/models/ipam/vlangroup.md
@@ -1,5 +1,5 @@
# VLAN Groups
-VLAN groups can be used to organize VLANs within NetBox. Each group may optionally be assigned to a specific site, but a group cannot belong to multiple sites.
+VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope.
Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group.
diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md
index 2b3792204..69db03724 100644
--- a/docs/release-notes/version-2.10.md
+++ b/docs/release-notes/version-2.10.md
@@ -1,5 +1,71 @@
# NetBox v2.10
+## v2.10.10 (FUTURE)
+
+### Bug Fixes
+
+* [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP
+* [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup
+* [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form
+* [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis
+
+---
+
+## v2.10.9 (2021-04-12)
+
+### Enhancements
+
+* [#5526](https://github.com/netbox-community/netbox/issues/5526) - Add MAC address search field to VM interfaces list
+* [#5756](https://github.com/netbox-community/netbox/issues/5756) - Omit child devices from non-racked devices list under rack view
+* [#5840](https://github.com/netbox-community/netbox/issues/5840) - Add column to cable termination objects to display cable color
+* [#6054](https://github.com/netbox-community/netbox/issues/6054) - Display NAPALM-enabled device tabs only when relevant
+* [#6083](https://github.com/netbox-community/netbox/issues/6083) - Support disabling TLS certificate validation for Redis
+
+### Bug Fixes
+
+* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations
+* [#6070](https://github.com/netbox-community/netbox/issues/6070) - Add missing `count_ipaddresses` attribute to VMInterface serializer
+* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission
+* [#6081](https://github.com/netbox-community/netbox/issues/6081) - Fix interface connections REST API endpoint
+* [#6082](https://github.com/netbox-community/netbox/issues/6082) - Support colons in webhook header values
+* [#6108](https://github.com/netbox-community/netbox/issues/6108) - Do not infer tenant assignment from parent objects for prefixes, IP addresses
+* [#6117](https://github.com/netbox-community/netbox/issues/6117) - Handle exception when attempting to assign an MPTT-enabled model as its own parent
+* [#6131](https://github.com/netbox-community/netbox/issues/6131) - Correct handling of boolean fields when cloning objects
+
+---
+
+## v2.10.8 (2021-03-26)
+
+### Bug Fixes
+
+* [#6060](https://github.com/netbox-community/netbox/issues/6060) - Fix exception on cable trace in UI (regression from #5650)
+
+---
+
+## v2.10.7 (2021-03-25)
+
+### Enhancements
+
+* [#5641](https://github.com/netbox-community/netbox/issues/5641) - Allow filtering device components by label
+* [#5723](https://github.com/netbox-community/netbox/issues/5723) - Allow customization of the geographic mapping service via `MAPS_URL` config parameter
+* [#5736](https://github.com/netbox-community/netbox/issues/5736) - Allow changing site assignment when bulk editing devices
+* [#5953](https://github.com/netbox-community/netbox/issues/5953) - Support Markdown rendering for custom script descriptions
+* [#6040](https://github.com/netbox-community/netbox/issues/6040) - Add UI search fields for asset tag for devices and racks
+
+### Bug Fixes
+
+* [#5595](https://github.com/netbox-community/netbox/issues/5595) - Restore ability to delete an uploaded device type image
+* [#5650](https://github.com/netbox-community/netbox/issues/5650) - Denote when the total length of a cable trace may exceed the indicated value
+* [#5962](https://github.com/netbox-community/netbox/issues/5962) - Ensure consistent display of change log action labels
+* [#5966](https://github.com/netbox-community/netbox/issues/5966) - Skip Markdown reference link when tabbing through form fields
+* [#5977](https://github.com/netbox-community/netbox/issues/5977) - Correct validation of `RELEASE_CHECK_URL` config parameter
+* [#6006](https://github.com/netbox-community/netbox/issues/6006) - Fix VLAN group/site association for bulk prefix import
+* [#6010](https://github.com/netbox-community/netbox/issues/6010) - Eliminate duplicate virtual chassis search results
+* [#6012](https://github.com/netbox-community/netbox/issues/6012) - Pre-populate attributes when creating an available child prefix via the UI
+* [#6023](https://github.com/netbox-community/netbox/issues/6023) - Fix display of bottom banner with uBlock Origin enabled
+
+---
+
## v2.10.6 (2021-03-09)
### Enhancements
@@ -19,6 +85,8 @@
* [#5935](https://github.com/netbox-community/netbox/issues/5935) - Fix filtering prefixes list by multiple prefix values
* [#5948](https://github.com/netbox-community/netbox/issues/5948) - Invalidate cached queries when running `renaturalize`
+---
+
## v2.10.5 (2021-02-24)
### Bug Fixes
diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md
index 6bfdd414b..9161401ee 100644
--- a/docs/release-notes/version-2.11.md
+++ b/docs/release-notes/version-2.11.md
@@ -1,25 +1,63 @@
# NetBox v2.11
-## v2.11-beta1 (FUTURE)
+## v2.11.0 (FUTURE)
+
+### Enhancements (from Beta)
+
+* [#5757](https://github.com/netbox-community/netbox/issues/5757) - Add unique identifier to every object view
+* [#5848](https://github.com/netbox-community/netbox/issues/5848) - Filter custom fields by content type in format `.`
+* [#6088](https://github.com/netbox-community/netbox/issues/6088) - Improved table configuration form
+* [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views
+* [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table
+* [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces
+* [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page
+* [#6146](https://github.com/netbox-community/netbox/issues/6146) - Add bulk disconnect support for power feeds
+* [#6149](https://github.com/netbox-community/netbox/issues/6149) - Support image attachments for locations
+* [#6150](https://github.com/netbox-community/netbox/issues/6150) - Enable change logging for journal entries
+
+### Bug Fixes (from Beta)
+
+* [#5583](https://github.com/netbox-community/netbox/issues/5583) - Eliminate redundant change records when adding/removing tags
+* [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link
+* [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table
+* [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view
+* [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface
+* [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form
+* [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export
+* [#6123](https://github.com/netbox-community/netbox/issues/6123) - Prevent device from being assigned to mismatched site and location
+* [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned)
+* [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list
+
+---
+
+## v2.11-beta1 (2021-04-06)
**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost.
-**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or
-later will be required.
+**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or later will be required.
+
+### Breaking Changes
+
+* All objects now use numeric IDs in their UI view URLs instead of slugs. You may need to update external references to NetBox objects. (Note that this does _not_ affect the REST API.)
+* The UI now uses numeric IDs when filtering object lists. You may need to update external links to filtered object lists. (Note that the slug- and name-based filters will continue to work, however the filter selection fields within the UI will not be automatically populated.)
+* The RackGroup model has been renamed to Location (see [#4971](https://github.com/netbox-community/netbox/issues/4971)). Its REST API endpoint has changed from `/api/dcim/rack-groups/` to `/api/dcim/locations/`.
+* The foreign key field `group` on dcim.Rack has been renamed to `location`.
+* The foreign key field `site` on ipam.VLANGroup has been replaced with the `scope` generic foreign key (see [#5284](https://github.com/netbox-community/netbox/issues/5284)).
+* Custom script ObjectVars no longer support the `queryset` parameter: Use `model` instead (see [#5995](https://github.com/netbox-community/netbox/issues/5995)).
### New Features
#### Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151))
-NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire.
+NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after some time, journal entries persist for the life of the associated object.
#### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519))
-Virtual interfaces can now be assigned to a "parent" physical interface, by setting the `parent` field on the Interface model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to the physical interface Gi0/0.
+Virtual device and VM interfaces can now be assigned to a "parent" interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0.
#### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451))
-In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include a pre- and post-change representation of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status:
+In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include both pre- and post-change representations of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status:
```json
"snapshots": {
@@ -38,11 +76,11 @@ In conjunction with the newly improved change logging functionality ([#5913](htt
}
```
-Note: The pre-change snapshot for an object creation will always be null, as will the post-change snapshot for an object deletion.
+Note: The pre-change snapshot for a newly created will always be null, as will the post-change snapshot for a deleted object.
#### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648))
-Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination.
+Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where we don't necessarily know or care what is connected to an attachment point, but still need to reflect the termination as being occupied.
In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true.
@@ -52,7 +90,7 @@ Devices can now be assigned to locations (formerly known as rack groups) within
#### Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999))
-When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the configuration of the current table. For example, if you modify the sites list to display on the site name, tenant, and status, the rendered CSV will include only these columns.
+When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the current configuration of the table being viewed. For example, if you modify the sites list to display only the site name, tenant, and status, the rendered CSV will include only these columns, and they will appear in the order chosen.
The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12.
@@ -64,33 +102,47 @@ For example, a VLAN within a group assigned to a location will be available only
#### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892))
-This release introduces the new Site Group model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user.
+This release introduces the new SiteGroup model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by functional role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user.
#### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913))
The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor.
+#### Provider Network Modeling ([#5986](https://github.com/netbox-community/netbox/issues/5986))
+
+A new provider network model has been introduced to represent the boundary of a network that exists outside the scope of NetBox. Each instance of this model must be assigned to a provider, and circuits can now terminate to either provider networks or to sites. The use of this model will likely be extended by future releases to support overlay and virtual circuit modeling.
+
### Enhancements
+* [#4833](https://github.com/netbox-community/netbox/issues/4833) - Allow assigning config contexts by device type
+* [#5344](https://github.com/netbox-community/netbox/issues/5344) - Add support for custom fields in tables
* [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models
* [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models
* [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models
+* [#5425](https://github.com/netbox-community/netbox/issues/5425) - Create separate tabs for VMs and devices under the cluster view
* [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields
* [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links
* [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks
+* [#5830](https://github.com/netbox-community/netbox/issues/5830) - Add `as_attachment` to ExportTemplate to control download behavior
* [#5891](https://github.com/netbox-community/netbox/issues/5891) - Add `display` field to all REST API serializers
* [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI
* [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location
* [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models
+* [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models
* [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models
-* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial vCPU allocations for virtual machines
+* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines
+* [#6001](https://github.com/netbox-community/netbox/issues/6001) - Paginate component tables under device views
+* [#6038](https://github.com/netbox-community/netbox/issues/6038) - Include tagged objects list on tag view
### Other Changes
* [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers
* [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs
+* [#5938](https://github.com/netbox-community/netbox/issues/5938) - Deprecated support for Python 3.6
* [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields
* [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead)
+* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view
+* [#6071](https://github.com/netbox-community/netbox/issues/6071) - Cable traces now traverse circuits
### REST API Changes
@@ -108,6 +160,11 @@ The ObjectChange model (which is used to record the creation, modification, and
* Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied
* Renamed RackGroup to Location
* The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
+* circuits.CircuitTermination
+ * Added the `provider_network` field
+ * Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
+* circuits.ProviderNetwork
+ * Added the `/api/circuits/provider-networks/` endpoint
* dcim.Device
* Added the `location` field
* dcim.Interface
@@ -126,6 +183,8 @@ The ObjectChange model (which is used to record the creation, modification, and
* Added new custom field type: `multi-select`
* extras.CustomLink
* Added the `/api/extras/custom-links/` endpoint
+* extras.ExportTemplate
+ * Added the `as_attachment` boolean field
* extras.ObjectChange
* Added the `prechange_data` field
* Renamed `object_data` to `postchange_data`
@@ -136,3 +195,5 @@ The ObjectChange model (which is used to record the creation, modification, and
* Dropped the `site` foreign key field
* virtualization.VirtualMachine
* `vcpus` has been changed from an integer to a decimal value
+* virtualization.VMInterface
+ * Added the `parent` field
diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md
index 5d5777483..7fb789e0f 100644
--- a/docs/rest-api/authentication.md
+++ b/docs/rest-api/authentication.md
@@ -20,7 +20,7 @@ http://netbox/api/dcim/sites/
}
```
-A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../../configuration/optional-settings/#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response:
+A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response:
```
$ curl http://netbox/api/dcim/sites/
diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md
index 290343aa6..088286e22 100644
--- a/docs/rest-api/overview.md
+++ b/docs/rest-api/overview.md
@@ -269,7 +269,7 @@ The brief format is supported for both lists and individual objects.
### Excluding Config Contexts
-When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext/) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views.
+When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views.
## Pagination
@@ -308,7 +308,7 @@ Vary: Accept
}
```
-The default page is determined by the [`PAGINATE_COUNT`](../../configuration/optional-settings/#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
+The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
```
http://netbox/api/dcim/devices/?limit=100
@@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a
}
```
-The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../../configuration/optional-settings/#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
+The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
!!! warning
Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
@@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.'
### Creating a New Object
-To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
+To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
```no-highlight
curl -s -X POST \
diff --git a/docs/rest-api/working-with-secrets.md b/docs/rest-api/working-with-secrets.md
index dafbb7239..5fbbf7355 100644
--- a/docs/rest-api/working-with-secrets.md
+++ b/docs/rest-api/working-with-secrets.md
@@ -4,7 +4,7 @@ As with most other objects, the REST API can be used to view, create, modify, an
## Generating a Session Key
-In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../../core-functionality/secrets/#user-keys). The private key must be POSTed with the name `private_key`.
+In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../core-functionality/secrets.md#user-keys). The private key must be POSTed with the name `private_key`.
```no-highlight
$ curl -X POST http://netbox/api/secrets/get-session-key/ \
diff --git a/mkdocs.yml b/mkdocs.yml
index 233a61fdf..fb5cf1890 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -12,6 +12,9 @@ markdown_extensions:
- admonition
- markdown_include.include:
headingOffset: 1
+ - pymdownx.emoji:
+ emoji_index: !!python/name:materialx.emoji.twemoji
+ emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.superfences
- pymdownx.tabbed
nav:
@@ -50,6 +53,7 @@ nav:
- Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md'
+ - Journaling: 'additional-features/journaling.md'
- NAPALM: 'additional-features/napalm.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
@@ -71,11 +75,13 @@ nav:
- Introduction: 'development/index.md'
- Getting Started: 'development/getting-started.md'
- Style Guide: 'development/style-guide.md'
+ - Models: 'development/models.md'
- Extending Models: 'development/extending-models.md'
- Application Registry: 'development/application-registry.md'
- User Preferences: 'development/user-preferences.md'
- Release Checklist: 'development/release-checklist.md'
- Release Notes:
+ - Version 2.11: 'release-notes/version-2.11.md'
- Version 2.10: 'release-notes/version-2.10.md'
- Version 2.9: 'release-notes/version-2.9.md'
- Version 2.8: 'release-notes/version-2.8.md'
diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py
index 7c7d371ad..fccf4a8b6 100644
--- a/netbox/circuits/api/nested_serializers.py
+++ b/netbox/circuits/api/nested_serializers.py
@@ -1,16 +1,29 @@
from rest_framework import serializers
-from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from circuits.models import *
from netbox.api import WritableNestedSerializer
__all__ = [
'NestedCircuitSerializer',
'NestedCircuitTerminationSerializer',
'NestedCircuitTypeSerializer',
+ 'NestedProviderNetworkSerializer',
'NestedProviderSerializer',
]
+#
+# Provider networks
+#
+
+class NestedProviderNetworkSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
+
+ class Meta:
+ model = Provider
+ fields = ['id', 'url', 'display', 'name']
+
+
#
# Providers
#
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index bae45e2b3..014ec0fc8 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -1,7 +1,7 @@
from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
-from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
+from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
from netbox.api import ChoiceField
@@ -28,6 +28,22 @@ class ProviderSerializer(PrimaryModelSerializer):
]
+#
+# Provider networks
+#
+
+class ProviderNetworkSerializer(PrimaryModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
+ provider = NestedProviderSerializer()
+
+ class Meta:
+ model = ProviderNetwork
+ fields = [
+ 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created',
+ 'last_updated',
+ ]
+
+
#
# Circuits
#
@@ -44,15 +60,15 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
]
-class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer):
+class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = NestedSiteSerializer()
+ provider_network = NestedProviderNetworkSerializer()
class Meta:
model = CircuitTermination
fields = [
- 'id', 'url', 'display', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint',
- 'connected_endpoint_type', 'connected_endpoint_reachable',
+ 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
]
@@ -74,16 +90,17 @@ class CircuitSerializer(PrimaryModelSerializer):
]
-class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
- site = NestedSiteSerializer()
+ site = NestedSiteSerializer(required=False)
+ provider_network = NestedProviderNetworkSerializer(required=False)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = CircuitTermination
fields = [
- 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id',
- 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
- 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied',
+ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
+ 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
+ '_occupied',
]
diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py
index b496796fe..5389e0bde 100644
--- a/netbox/circuits/api/urls.py
+++ b/netbox/circuits/api/urls.py
@@ -13,5 +13,8 @@ router.register('circuit-types', views.CircuitTypeViewSet)
router.register('circuits', views.CircuitViewSet)
router.register('circuit-terminations', views.CircuitTerminationViewSet)
+# Provider networks
+router.register('provider-networks', views.ProviderNetworkViewSet)
+
app_name = 'circuits-api'
urlpatterns = router.urls
diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py
index c2fe3d089..c037bc5fd 100644
--- a/netbox/circuits/api/views.py
+++ b/netbox/circuits/api/views.py
@@ -1,8 +1,7 @@
-from django.db.models import Prefetch
from rest_framework.routers import APIRootView
from circuits import filters
-from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
+from circuits.models import *
from dcim.api.views import PathEndpointMixin
from extras.api.views import CustomFieldModelViewSet
from netbox.api.views import ModelViewSet
@@ -48,8 +47,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related(
- Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')),
- 'type', 'tenant', 'provider',
+ 'type', 'tenant', 'provider', 'termination_a', 'termination_z'
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilterSet
@@ -61,8 +59,18 @@ class CircuitViewSet(CustomFieldModelViewSet):
class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
queryset = CircuitTermination.objects.prefetch_related(
- 'circuit', 'site', '_path__destination', 'cable'
+ 'circuit', 'site', 'provider_network', 'cable'
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet
brief_prefetch_fields = ['circuit']
+
+
+#
+# Provider networks
+#
+
+class ProviderNetworkViewSet(CustomFieldModelViewSet):
+ queryset = ProviderNetwork.objects.prefetch_related('tags')
+ serializer_class = serializers.ProviderNetworkSerializer
+ filterset_class = filters.ProviderNetworkFilterSet
diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py
index 03da662e7..f5d81c7bd 100644
--- a/netbox/circuits/filters.py
+++ b/netbox/circuits/filters.py
@@ -9,12 +9,13 @@ from utilities.filters import (
BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
)
from .choices import *
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
__all__ = (
'CircuitFilterSet',
'CircuitTerminationFilterSet',
'CircuitTypeFilterSet',
+ 'ProviderNetworkFilterSet',
'ProviderFilterSet',
)
@@ -79,6 +80,36 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated
)
+class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
+ provider_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Provider.objects.all(),
+ label='Provider (ID)',
+ )
+ provider = django_filters.ModelMultipleChoiceFilter(
+ field_name='provider__slug',
+ queryset=Provider.objects.all(),
+ to_field_name='slug',
+ label='Provider (slug)',
+ )
+ tag = TagFilter()
+
+ class Meta:
+ model = ProviderNetwork
+ fields = ['id', 'name']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ ).distinct()
+
+
class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
@@ -101,6 +132,11 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
to_field_name='slug',
label='Provider (slug)',
)
+ provider_network_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='terminations__provider_network',
+ queryset=ProviderNetwork.objects.all(),
+ label='ProviderNetwork (ID)',
+ )
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitType.objects.all(),
label='Circuit type (ID)',
@@ -171,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
).distinct()
-class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
+class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -190,6 +226,10 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path
to_field_name='slug',
label='Site (slug)',
)
+ provider_network_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ProviderNetwork.objects.all(),
+ label='ProviderNetwork (ID)',
+ )
class Meta:
model = CircuitTermination
diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py
index 002c73b9a..1b3eb3242 100644
--- a/netbox/circuits/forms.py
+++ b/netbox/circuits/forms.py
@@ -14,7 +14,7 @@ from utilities.forms import (
StaticSelect2, StaticSelect2Multiple, TagFilterField,
)
from .choices import CircuitStatusChoices
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
#
@@ -128,6 +128,83 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
tag = TagFilterField(model)
+#
+# Provider networks
+#
+
+class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
+ provider = DynamicModelChoiceField(
+ queryset=Provider.objects.all()
+ )
+ comments = CommentField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ model = ProviderNetwork
+ fields = [
+ 'provider', 'name', 'description', 'comments', 'tags',
+ ]
+ fieldsets = (
+ ('Provider Network', ('provider', 'name', 'description', 'tags')),
+ )
+
+
+class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
+ provider = CSVModelChoiceField(
+ queryset=Provider.objects.all(),
+ to_field_name='name',
+ help_text='Assigned provider'
+ )
+
+ class Meta:
+ model = ProviderNetwork
+ fields = [
+ 'provider', 'name', 'description', 'comments',
+ ]
+
+
+class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ProviderNetwork.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ provider = DynamicModelChoiceField(
+ queryset=Provider.objects.all(),
+ required=False
+ )
+ description = forms.CharField(
+ max_length=100,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
+
+ class Meta:
+ nullable_fields = [
+ 'description', 'comments',
+ ]
+
+
+class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldFilterForm):
+ model = ProviderNetwork
+ field_order = ['q', 'provider_id']
+ q = forms.CharField(
+ required=False,
+ label=_('Search')
+ )
+ provider_id = DynamicModelMultipleChoiceField(
+ queryset=Provider.objects.all(),
+ required=False,
+ label=_('Provider')
+ )
+ tag = TagFilterField(model)
+
+
#
# Circuit types
#
@@ -280,7 +357,8 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Circuit
field_order = [
- 'q', 'type_id', 'provider_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', 'commit_rate',
+ 'q', 'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id',
+ 'commit_rate',
]
q = forms.CharField(
required=False,
@@ -296,6 +374,14 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False,
label=_('Provider')
)
+ provider_network_id = DynamicModelMultipleChoiceField(
+ queryset=ProviderNetwork.objects.all(),
+ required=False,
+ query_params={
+ 'provider_id': '$provider_id'
+ },
+ label=_('Provider network')
+ )
status = forms.MultipleChoiceField(
choices=CircuitStatusChoices,
required=False,
@@ -346,14 +432,19 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
query_params={
'region_id': '$region',
'group_id': '$site_group',
- }
+ },
+ required=False
+ )
+ provider_network = DynamicModelChoiceField(
+ queryset=ProviderNetwork.objects.all(),
+ required=False
)
class Meta:
model = CircuitTermination
fields = [
- 'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed',
- 'xconnect_id', 'pp_info', 'description',
+ 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed',
+ 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
]
help_texts = {
'port_speed': "Physical circuit speed",
@@ -365,3 +456,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
'port_speed': SelectSpeedWidget(),
'upstream_speed': SelectSpeedWidget(),
}
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id)
diff --git a/netbox/circuits/migrations/0027_providernetwork.py b/netbox/circuits/migrations/0027_providernetwork.py
new file mode 100644
index 000000000..e8fbdb8d4
--- /dev/null
+++ b/netbox/circuits/migrations/0027_providernetwork.py
@@ -0,0 +1,65 @@
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0058_journalentry'),
+ ('circuits', '0026_mark_connected'),
+ ]
+
+ operations = [
+ # Create the new ProviderNetwork model
+ migrations.CreateModel(
+ name='ProviderNetwork',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='networks', to='circuits.provider')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'ordering': ('provider', 'name'),
+ },
+ ),
+ migrations.AddConstraint(
+ model_name='providernetwork',
+ constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_provider_name'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='providernetwork',
+ unique_together={('provider', 'name')},
+ ),
+
+ # Add ProviderNetwork FK to CircuitTermination
+ migrations.AddField(
+ model_name='circuittermination',
+ name='provider_network',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.providernetwork'),
+ ),
+ migrations.AlterField(
+ model_name='circuittermination',
+ name='site',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'),
+ ),
+
+ # Add FKs to CircuitTermination on Circuit
+ migrations.AddField(
+ model_name='circuit',
+ name='termination_a',
+ field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'),
+ ),
+ migrations.AddField(
+ model_name='circuit',
+ name='termination_z',
+ field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'),
+ ),
+ ]
diff --git a/netbox/circuits/migrations/0028_cache_circuit_terminations.py b/netbox/circuits/migrations/0028_cache_circuit_terminations.py
new file mode 100644
index 000000000..23734348e
--- /dev/null
+++ b/netbox/circuits/migrations/0028_cache_circuit_terminations.py
@@ -0,0 +1,37 @@
+import sys
+
+from django.db import migrations
+
+
+def cache_circuit_terminations(apps, schema_editor):
+ Circuit = apps.get_model('circuits', 'Circuit')
+ CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+
+ if 'test' not in sys.argv:
+ print(f"\n Caching circuit terminations...", flush=True)
+
+ a_terminations = {
+ ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='A')
+ }
+ z_terminations = {
+ ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='Z')
+ }
+ for circuit in Circuit.objects.all():
+ Circuit.objects.filter(pk=circuit.pk).update(
+ termination_a_id=a_terminations.get(circuit.pk),
+ termination_z_id=z_terminations.get(circuit.pk),
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('circuits', '0027_providernetwork'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=cache_circuit_terminations,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/circuits/migrations/0029_circuit_tracing.py b/netbox/circuits/migrations/0029_circuit_tracing.py
new file mode 100644
index 000000000..bddb38bb6
--- /dev/null
+++ b/netbox/circuits/migrations/0029_circuit_tracing.py
@@ -0,0 +1,32 @@
+from django.db import migrations
+from django.db.models import Q
+
+
+def delete_obsolete_cablepaths(apps, schema_editor):
+ """
+ Delete all CablePath instances which originate or terminate at a CircuitTermination.
+ """
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+ CablePath = apps.get_model('dcim', 'CablePath')
+
+ ct = ContentType.objects.get_for_model(CircuitTermination)
+ CablePath.objects.filter(Q(origin_type=ct) | Q(destination_type=ct)).delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('circuits', '0028_cache_circuit_terminations'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='circuittermination',
+ name='_path',
+ ),
+ migrations.RunPython(
+ code=delete_obsolete_cablepaths,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py
index d19841e4f..b2ffb3c09 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models.py
@@ -1,3 +1,4 @@
+from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@@ -8,13 +9,13 @@ from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
from .choices import *
-from .querysets import CircuitQuerySet
__all__ = (
'Circuit',
'CircuitTermination',
'CircuitType',
+ 'ProviderNetwork',
'Provider',
)
@@ -91,6 +92,63 @@ class Provider(PrimaryModel):
)
+#
+# Provider networks
+#
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class ProviderNetwork(PrimaryModel):
+ """
+ This represents a provider network which exists outside of NetBox, the details of which are unknown or
+ unimportant to the user.
+ """
+ name = models.CharField(
+ max_length=100
+ )
+ provider = models.ForeignKey(
+ to='circuits.Provider',
+ on_delete=models.PROTECT,
+ related_name='networks'
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ comments = models.TextField(
+ blank=True
+ )
+
+ csv_headers = [
+ 'provider', 'name', 'description', 'comments',
+ ]
+
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ('provider', 'name')
+ constraints = (
+ models.UniqueConstraint(
+ fields=('provider', 'name'),
+ name='circuits_providernetwork_provider_name'
+ ),
+ )
+ unique_together = ('provider', 'name')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('circuits:providernetwork', args=[self.pk])
+
+ def to_csv(self):
+ return (
+ self.provider.name,
+ self.name,
+ self.description,
+ self.comments,
+ )
+
+
@extras_features('custom_fields', 'export_templates', 'webhooks')
class CircuitType(OrganizationalModel):
"""
@@ -121,7 +179,7 @@ class CircuitType(OrganizationalModel):
return self.name
def get_absolute_url(self):
- return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
+ return reverse('circuits:circuittype', args=[self.pk])
def to_csv(self):
return (
@@ -181,7 +239,25 @@ class Circuit(PrimaryModel):
blank=True
)
- objects = CircuitQuerySet.as_manager()
+ # Cache associated CircuitTerminations
+ termination_a = models.ForeignKey(
+ to='circuits.CircuitTermination',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ editable=False,
+ blank=True,
+ null=True
+ )
+ termination_z = models.ForeignKey(
+ to='circuits.CircuitTermination',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ editable=False,
+ blank=True,
+ null=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
@@ -216,23 +292,9 @@ class Circuit(PrimaryModel):
def get_status_class(self):
return CircuitStatusChoices.CSS_CLASSES.get(self.status)
- def _get_termination(self, side):
- for ct in self.terminations.all():
- if ct.term_side == side:
- return ct
- return None
-
- @property
- def termination_a(self):
- return self._get_termination('A')
-
- @property
- def termination_z(self):
- return self._get_termination('Z')
-
@extras_features('webhooks')
-class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
+class CircuitTermination(ChangeLoggedModel, CableTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,
@@ -246,7 +308,16 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
- related_name='circuit_terminations'
+ related_name='circuit_terminations',
+ blank=True,
+ null=True
+ )
+ provider_network = models.ForeignKey(
+ to=ProviderNetwork,
+ on_delete=models.PROTECT,
+ related_name='circuit_terminations',
+ blank=True,
+ null=True
)
port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)',
@@ -281,7 +352,21 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
unique_together = ['circuit', 'term_side']
def __str__(self):
- return 'Side {}'.format(self.get_term_side_display())
+ return f'Termination {self.term_side}: {self.site or self.provider_network}'
+
+ def get_absolute_url(self):
+ if self.site:
+ return self.site.get_absolute_url()
+ return self.provider_network.get_absolute_url()
+
+ def clean(self):
+ super().clean()
+
+ # Must define either site *or* provider network
+ if self.site is None and self.provider_network is None:
+ raise ValidationError("A circuit termination must attach to either a site or a provider network.")
+ if self.site and self.provider_network:
+ raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
def to_objectchange(self, action):
# Annotate the parent Circuit
diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py
deleted file mode 100644
index 8a9bd50a4..000000000
--- a/netbox/circuits/querysets.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from django.db.models import OuterRef, Subquery
-
-from utilities.querysets import RestrictedQuerySet
-
-
-class CircuitQuerySet(RestrictedQuerySet):
-
- def annotate_sites(self):
- """
- Annotate the A and Z termination site names for ordering.
- """
- from circuits.models import CircuitTermination
- _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
- return self.annotate(
- a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]),
- z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]),
- )
diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py
index 86db21400..0a000fb2e 100644
--- a/netbox/circuits/signals.py
+++ b/netbox/circuits/signals.py
@@ -2,16 +2,28 @@ from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone
+from dcim.signals import rebuild_paths
from .models import Circuit, CircuitTermination
-@receiver((post_save, post_delete), sender=CircuitTermination)
+@receiver(post_save, sender=CircuitTermination)
def update_circuit(instance, **kwargs):
"""
- When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
+ When a CircuitTermination has been modified, update its parent Circuit.
"""
- circuits = Circuit.objects.filter(pk=instance.circuit_id)
- time = timezone.now()
- for circuit in circuits:
- circuit.last_updated = time
- circuit.save()
+ fields = {
+ 'last_updated': timezone.now(),
+ f'termination_{instance.term_side.lower()}': instance.pk,
+ }
+ Circuit.objects.filter(pk=instance.circuit_id).update(**fields)
+
+
+@receiver((post_save, post_delete), sender=CircuitTermination)
+def rebuild_cablepaths(instance, raw=False, **kwargs):
+ """
+ Rebuild any CablePaths which traverse the peer CircuitTermination.
+ """
+ if not raw:
+ peer_termination = instance.get_peer_termination()
+ if peer_termination:
+ rebuild_paths(peer_termination)
diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py
index efa7e4c49..41a3aed7f 100644
--- a/netbox/circuits/tables.py
+++ b/netbox/circuits/tables.py
@@ -3,7 +3,16 @@ from django_tables2.utils import Accessor
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
-from .models import Circuit, CircuitType, Provider
+from .models import *
+
+
+CIRCUITTERMINATION_LINK = """
+{% if value.site %}
+ {{ value.site }}
+{% elif value.provider_network %}
+ {{ value.provider_network }}
+{% endif %}
+"""
#
@@ -12,7 +21,9 @@ from .models import Circuit, CircuitType, Provider
class ProviderTable(BaseTable):
pk = ToggleColumn()
- name = tables.LinkColumn()
+ name = tables.Column(
+ linkify=True
+ )
circuit_count = tables.Column(
accessor=Accessor('count_circuits'),
verbose_name='Circuits'
@@ -29,13 +40,37 @@ class ProviderTable(BaseTable):
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
+#
+# Provider networks
+#
+
+class ProviderNetworkTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ provider = tables.Column(
+ linkify=True
+ )
+ tags = TagColumn(
+ url_name='circuits:providernetwork_list'
+ )
+
+ class Meta(BaseTable.Meta):
+ model = ProviderNetwork
+ fields = ('pk', 'name', 'provider', 'description', 'tags')
+ default_columns = ('pk', 'name', 'provider', 'description')
+
+
#
# Circuit types
#
class CircuitTypeTable(BaseTable):
pk = ToggleColumn()
- name = tables.LinkColumn()
+ name = tables.Column(
+ linkify=True
+ )
circuit_count = tables.Column(
verbose_name='Circuits'
)
@@ -53,7 +88,8 @@ class CircuitTypeTable(BaseTable):
class CircuitTable(BaseTable):
pk = ToggleColumn()
- cid = tables.LinkColumn(
+ cid = tables.Column(
+ linkify=True,
verbose_name='ID'
)
provider = tables.Column(
@@ -61,11 +97,13 @@ class CircuitTable(BaseTable):
)
status = ChoiceFieldColumn()
tenant = TenantColumn()
- a_side = tables.Column(
- verbose_name='A Side'
+ termination_a = tables.TemplateColumn(
+ template_code=CIRCUITTERMINATION_LINK,
+ verbose_name='Side A'
)
- z_side = tables.Column(
- verbose_name='Z Side'
+ termination_z = tables.TemplateColumn(
+ template_code=CIRCUITTERMINATION_LINK,
+ verbose_name='Side Z'
)
tags = TagColumn(
url_name='circuits:circuit_list'
@@ -74,7 +112,9 @@ class CircuitTable(BaseTable):
class Meta(BaseTable.Meta):
model = Circuit
fields = (
- 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate',
- 'description', 'tags',
+ 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
+ 'commit_rate', 'description', 'tags',
+ )
+ default_columns = (
+ 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
)
- default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description')
diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py
index 3341c72c3..424b13d40 100644
--- a/netbox/circuits/tests/test_api.py
+++ b/netbox/circuits/tests/test_api.py
@@ -1,7 +1,7 @@
from django.urls import reverse
from circuits.choices import *
-from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from circuits.models import *
from dcim.models import Site
from utilities.testing import APITestCase, APIViewTestCases
@@ -178,3 +178,43 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
cls.bulk_update_data = {
'port_speed': 123456
}
+
+
+class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
+ model = ProviderNetwork
+ brief_fields = ['display', 'id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+ providers = (
+ Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
+ )
+ Provider.objects.bulk_create(providers)
+
+ provider_networks = (
+ ProviderNetwork(name='Provider Network 1', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 2', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 3', provider=providers[0]),
+ )
+ ProviderNetwork.objects.bulk_create(provider_networks)
+
+ cls.create_data = [
+ {
+ 'name': 'Provider Network 4',
+ 'provider': providers[0].pk,
+ },
+ {
+ 'name': 'Provider Network 5',
+ 'provider': providers[0].pk,
+ },
+ {
+ 'name': 'Provider Network 6',
+ 'provider': providers[0].pk,
+ },
+ ]
+
+ cls.bulk_update_data = {
+ 'provider': providers[1].pk,
+ 'description': 'New description',
+ }
diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py
index b9e1eac45..448e42368 100644
--- a/netbox/circuits/tests/test_filters.py
+++ b/netbox/circuits/tests/test_filters.py
@@ -2,7 +2,7 @@ from django.test import TestCase
from circuits.choices import *
from circuits.filters import *
-from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from circuits.models import *
from dcim.models import Cable, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
@@ -186,6 +186,13 @@ class CircuitTestCase(TestCase):
)
Provider.objects.bulk_create(providers)
+ provider_networks = (
+ ProviderNetwork(name='Provider Network 1', provider=providers[1]),
+ ProviderNetwork(name='Provider Network 2', provider=providers[1]),
+ ProviderNetwork(name='Provider Network 3', provider=providers[1]),
+ )
+ ProviderNetwork.objects.bulk_create(provider_networks)
+
circuits = (
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE),
@@ -200,6 +207,9 @@ class CircuitTestCase(TestCase):
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'),
+ CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
+ CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
+ CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
@@ -226,6 +236,11 @@ class CircuitTestCase(TestCase):
params = {'provider': [provider.slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ def test_provider_network(self):
+ provider_networks = ProviderNetwork.objects.all()[:2]
+ params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_type(self):
circuit_type = CircuitType.objects.first()
params = {'type_id': [circuit_type.pk]}
@@ -281,14 +296,14 @@ class CircuitTerminationTestCase(TestCase):
def setUpTestData(cls):
sites = (
- Site(name='Test Site 1', slug='test-site-1'),
- Site(name='Test Site 2', slug='test-site-2'),
- Site(name='Test Site 3', slug='test-site-3'),
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
circuit_types = (
- CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
+ CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
)
CircuitType.objects.bulk_create(circuit_types)
@@ -297,10 +312,20 @@ class CircuitTerminationTestCase(TestCase):
)
Provider.objects.bulk_create(providers)
+ provider_networks = (
+ ProviderNetwork(name='Provider Network 1', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 2', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 3', provider=providers[0]),
+ )
+ ProviderNetwork.objects.bulk_create(provider_networks)
+
circuits = (
- Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'),
- Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'),
- Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'),
+ Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
+ Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'),
+ Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'),
+ Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
+ Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
+ Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
)
Circuit.objects.bulk_create(circuits)
@@ -311,6 +336,9 @@ class CircuitTerminationTestCase(TestCase):
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'),
+ CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
+ CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
+ CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
@@ -318,7 +346,7 @@ class CircuitTerminationTestCase(TestCase):
def test_term_side(self):
params = {'term_side': 'A'}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_port_speed(self):
params = {'port_speed': ['1000', '2000']}
@@ -344,12 +372,48 @@ class CircuitTerminationTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ def test_provider_network(self):
+ provider_networks = ProviderNetwork.objects.all()[:2]
+ params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_cabled(self):
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_connected(self):
- params = {'connected': True}
+
+class ProviderNetworkTestCase(TestCase):
+ queryset = ProviderNetwork.objects.all()
+ filterset = ProviderNetworkFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ providers = (
+ Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
+ Provider(name='Provider 3', slug='provider-3'),
+ )
+ Provider.objects.bulk_create(providers)
+
+ provider_networks = (
+ ProviderNetwork(name='Provider Network 1', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 2', provider=providers[1]),
+ ProviderNetwork(name='Provider Network 3', provider=providers[2]),
+ )
+ ProviderNetwork.objects.bulk_create(provider_networks)
+
+ def test_id(self):
+ params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_name(self):
+ params = {'name': ['Provider Network 1', 'Provider Network 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_provider(self):
+ providers = Provider.objects.all()[:2]
+ params = {'provider_id': [providers[0].pk, providers[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'connected': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py
index de0d2c970..62e3e3a22 100644
--- a/netbox/circuits/tests/test_views.py
+++ b/netbox/circuits/tests/test_views.py
@@ -1,8 +1,12 @@
import datetime
+from django.test import override_settings
+from django.urls import reverse
+
from circuits.choices import *
-from circuits.models import Circuit, CircuitType, Provider
-from utilities.testing import ViewTestCases
+from circuits.models import *
+from dcim.models import Cable, Interface, Site
+from utilities.testing import ViewTestCases, create_test_device
class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -133,3 +137,99 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
'comments': 'New comments',
}
+
+
+class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = ProviderNetwork
+
+ @classmethod
+ def setUpTestData(cls):
+
+ providers = (
+ Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
+ )
+ Provider.objects.bulk_create(providers)
+
+ ProviderNetwork.objects.bulk_create([
+ ProviderNetwork(name='Provider Network 1', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 2', provider=providers[0]),
+ ProviderNetwork(name='Provider Network 3', provider=providers[0]),
+ ])
+
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'Provider Network X',
+ 'provider': providers[1].pk,
+ 'description': 'A new provider network',
+ 'comments': 'Longer description goes here',
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,provider,description",
+ "Provider Network 4,Provider 1,Foo",
+ "Provider Network 5,Provider 1,Bar",
+ "Provider Network 6,Provider 1,Baz",
+ )
+
+ cls.bulk_edit_data = {
+ 'provider': providers[1].pk,
+ 'description': 'New description',
+ 'comments': 'New comments',
+ }
+
+
+class CircuitTerminationTestCase(
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+):
+ model = CircuitTermination
+
+ @classmethod
+ def setUpTestData(cls):
+
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+ circuits = (
+ Circuit(cid='Circuit 1', provider=provider, type=circuittype),
+ Circuit(cid='Circuit 2', provider=provider, type=circuittype),
+ Circuit(cid='Circuit 3', provider=provider, type=circuittype),
+ )
+ Circuit.objects.bulk_create(circuits)
+
+ circuit_terminations = (
+ CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]),
+ CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]),
+ CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]),
+ CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]),
+ )
+ CircuitTermination.objects.bulk_create(circuit_terminations)
+
+ cls.form_data = {
+ 'term_side': 'A',
+ 'site': sites[2].pk,
+ 'description': 'New description',
+ }
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_trace(self):
+ device = create_test_device('Device 1')
+
+ circuittermination = CircuitTermination.objects.first()
+ interface = Interface.objects.create(
+ device=device,
+ name='Interface 1'
+ )
+ Cable(termination_a=circuittermination, termination_b=interface).save()
+
+ response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
+ self.assertHttpStatus(response, 200)
diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py
index 0b47b4b2c..1cea1965e 100644
--- a/netbox/circuits/urls.py
+++ b/netbox/circuits/urls.py
@@ -2,8 +2,9 @@ from django.urls import path
from dcim.views import CableCreateView, PathTraceView
from extras.views import ObjectChangeLogView, ObjectJournalView
+from utilities.views import SlugRedirectView
from . import views
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
app_name = 'circuits'
urlpatterns = [
@@ -15,17 +16,31 @@ urlpatterns = [
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path('providers//', views.ProviderView.as_view(), name='provider'),
+ path('providers//', SlugRedirectView.as_view(), kwargs={'model': Provider}),
path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),
+ # Provider networks
+ path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'),
+ path('provider-networks/add/', views.ProviderNetworkEditView.as_view(), name='providernetwork_add'),
+ path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'),
+ path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'),
+ path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'),
+ path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'),
+ path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'),
+ path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'),
+ path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}),
+ path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}),
+
# Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
+ path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'),
path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index b3215c029..92e53c30f 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -1,15 +1,15 @@
from django.contrib import messages
from django.db import transaction
+from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
-from django_tables2 import RequestConfig
from netbox.views import generic
from utilities.forms import ConfirmationForm
-from utilities.paginator import EnhancedPaginator, get_paginate_count
+from utilities.tables import paginate_table
from utilities.utils import count_related
from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
#
@@ -33,16 +33,11 @@ class ProviderView(generic.ObjectView):
provider=instance
).prefetch_related(
'type', 'tenant', 'terminations__site'
- ).annotate_sites()
+ )
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider')
-
- paginate = {
- 'paginator_class': EnhancedPaginator,
- 'per_page': get_paginate_count(request)
- }
- RequestConfig(request, paginate).configure(circuits_table)
+ paginate_table(circuits_table, request)
return {
'circuits_table': circuits_table,
@@ -81,6 +76,66 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderTable
+#
+# Provider networks
+#
+
+class ProviderNetworkListView(generic.ObjectListView):
+ queryset = ProviderNetwork.objects.all()
+ filterset = filters.ProviderNetworkFilterSet
+ filterset_form = forms.ProviderNetworkFilterForm
+ table = tables.ProviderNetworkTable
+
+
+class ProviderNetworkView(generic.ObjectView):
+ queryset = ProviderNetwork.objects.all()
+
+ def get_extra_context(self, request, instance):
+ circuits = Circuit.objects.restrict(request.user, 'view').filter(
+ Q(termination_a__provider_network=instance.pk) |
+ Q(termination_z__provider_network=instance.pk)
+ ).prefetch_related(
+ 'type', 'tenant', 'terminations__site'
+ )
+
+ circuits_table = tables.CircuitTable(circuits)
+ circuits_table.columns.hide('termination_a')
+ circuits_table.columns.hide('termination_z')
+ paginate_table(circuits_table, request)
+
+ return {
+ 'circuits_table': circuits_table,
+ }
+
+
+class ProviderNetworkEditView(generic.ObjectEditView):
+ queryset = ProviderNetwork.objects.all()
+ model_form = forms.ProviderNetworkForm
+
+
+class ProviderNetworkDeleteView(generic.ObjectDeleteView):
+ queryset = ProviderNetwork.objects.all()
+
+
+class ProviderNetworkBulkImportView(generic.BulkImportView):
+ queryset = ProviderNetwork.objects.all()
+ model_form = forms.ProviderNetworkCSVForm
+ table = tables.ProviderNetworkTable
+
+
+class ProviderNetworkBulkEditView(generic.BulkEditView):
+ queryset = ProviderNetwork.objects.all()
+ filterset = filters.ProviderNetworkFilterSet
+ table = tables.ProviderNetworkTable
+ form = forms.ProviderNetworkBulkEditForm
+
+
+class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
+ queryset = ProviderNetwork.objects.all()
+ filterset = filters.ProviderNetworkFilterSet
+ table = tables.ProviderNetworkTable
+
+
#
# Circuit Types
#
@@ -92,6 +147,23 @@ class CircuitTypeListView(generic.ObjectListView):
table = tables.CircuitTypeTable
+class CircuitTypeView(generic.ObjectView):
+ queryset = CircuitType.objects.all()
+
+ def get_extra_context(self, request, instance):
+ circuits = Circuit.objects.restrict(request.user, 'view').filter(
+ type=instance
+ )
+
+ circuits_table = tables.CircuitTable(circuits)
+ circuits_table.columns.hide('type')
+ paginate_table(circuits_table, request)
+
+ return {
+ 'circuits_table': circuits_table,
+ }
+
+
class CircuitTypeEditView(generic.ObjectEditView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm
@@ -129,8 +201,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
- 'provider', 'type', 'tenant', 'terminations'
- ).annotate_sites()
+ 'provider', 'type', 'tenant', 'termination_a', 'termination_z'
+ )
filterset = filters.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
table = tables.CircuitTable
@@ -147,8 +219,6 @@ class CircuitView(generic.ObjectView):
).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
- if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
- termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
@@ -156,8 +226,6 @@ class CircuitView(generic.ObjectView):
).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
- if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
- termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return {
'termination_a': termination_a,
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 4941b7bbc..e9efe4136 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -3,13 +3,14 @@ from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
+from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
-from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
+from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import (
NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
WritableNestedSerializer,
@@ -106,7 +107,7 @@ class SiteSerializer(PrimaryModelSerializer):
region = NestedRegionSerializer(required=False, allow_null=True)
group = NestedSiteGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
- time_zone = TimeZoneField(required=False)
+ time_zone = TimeZoneSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
@@ -133,12 +134,13 @@ class LocationSerializer(NestedGroupModelSerializer):
site = NestedSiteSerializer()
parent = NestedLocationSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
+ device_count = serializers.IntegerField(read_only=True)
class Meta:
model = Location
fields = [
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created',
- 'last_updated', 'rack_count', '_depth',
+ 'last_updated', 'rack_count', 'device_count', '_depth',
]
@@ -840,7 +842,7 @@ class CablePathSerializer(serializers.ModelSerializer):
class InterfaceConnectionSerializer(ValidatedModelSerializer):
interface_a = serializers.SerializerMethodField()
- interface_b = NestedInterfaceSerializer(source='connected_endpoint')
+ interface_b = NestedInterfaceSerializer(source='_path.destination')
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class Meta:
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 6aa4fd484..cb46c1eca 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -2,6 +2,7 @@ import socket
from collections import OrderedDict
from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
from django.db.models import F
from django.http import HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404
@@ -141,12 +142,18 @@ class SiteViewSet(CustomFieldModelViewSet):
#
-# Rack groups
+# Locations
#
class LocationViewSet(CustomFieldModelViewSet):
queryset = Location.objects.add_related_count(
- Location.objects.all(),
+ Location.objects.add_related_count(
+ Location.objects.all(),
+ Device,
+ 'location',
+ 'device_count',
+ cumulative=True
+ ),
Rack,
'location',
'rack_count',
@@ -174,7 +181,7 @@ class RackRoleViewSet(CustomFieldModelViewSet):
class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.prefetch_related(
- 'site', 'location__site', 'role', 'tenant', 'tags'
+ 'site', 'location', 'role', 'tenant', 'tags'
).annotate(
device_count=count_related(Device, 'rack'),
powerfeed_count=count_related(PowerFeed, 'rack')
@@ -590,6 +597,8 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.prefetch_related('device', '_path').filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair
+ _path__destination_type__app_label='dcim',
+ _path__destination_type__model='interface',
_path__destination_id__isnull=False,
pk__lt=F('_path__destination_id')
)
diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py
index 8d19dc2f0..202a50cb4 100644
--- a/netbox/dcim/filters.py
+++ b/netbox/dcim/filters.py
@@ -191,15 +191,18 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
to_field_name='slug',
label='Site (slug)',
)
- parent_id = django_filters.ModelMultipleChoiceFilter(
+ parent_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
- label='Rack group (ID)',
+ field_name='parent',
+ lookup_expr='in',
+ label='Location (ID)',
)
- parent = django_filters.ModelMultipleChoiceFilter(
- field_name='parent__slug',
+ parent = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
+ field_name='parent',
+ lookup_expr='in',
to_field_name='slug',
- label='Rack group (slug)',
+ label='Location (slug)',
)
class Meta:
@@ -857,7 +860,7 @@ class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
class Meta:
model = ConsolePort
- fields = ['id', 'name', 'description']
+ fields = ['id', 'name', 'label', 'description']
class ConsoleServerPortFilterSet(
@@ -873,7 +876,7 @@ class ConsoleServerPortFilterSet(
class Meta:
model = ConsoleServerPort
- fields = ['id', 'name', 'description']
+ fields = ['id', 'name', 'label', 'description']
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
@@ -884,7 +887,7 @@ class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
class Meta:
model = PowerPort
- fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description']
+ fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
@@ -895,7 +898,7 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
class Meta:
model = PowerOutlet
- fields = ['id', 'name', 'feed_leg', 'description']
+ fields = ['id', 'name', 'label', 'feed_leg', 'description']
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
@@ -946,7 +949,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
class Meta:
model = Interface
- fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
+ fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
def filter_device(self, queryset, name, value):
try:
@@ -1000,21 +1003,21 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
class Meta:
model = FrontPort
- fields = ['id', 'name', 'type', 'description']
+ fields = ['id', 'name', 'label', 'type', 'description']
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
class Meta:
model = RearPort
- fields = ['id', 'name', 'type', 'positions', 'description']
+ fields = ['id', 'name', 'label', 'type', 'positions', 'description']
class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class Meta:
model = DeviceBay
- fields = ['id', 'name', 'description']
+ fields = ['id', 'name', 'label', 'description']
class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
@@ -1075,7 +1078,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class Meta:
model = InventoryItem
- fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered']
+ fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered']
def search(self, queryset, name, value):
if not value.strip():
@@ -1090,7 +1093,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
return queryset.filter(qs_filter)
-class VirtualChassisFilterSet(BaseFilterSet):
+class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1167,10 +1170,10 @@ class VirtualChassisFilterSet(BaseFilterSet):
Q(members__name__icontains=value) |
Q(domain__icontains=value)
)
- return queryset.filter(qs_filter)
+ return queryset.filter(qs_filter).distinct()
-class CableFilterSet(BaseFilterSet):
+class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1346,7 +1349,7 @@ class PowerPanelFilterSet(BaseFilterSet):
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
- label='Rack group (ID)',
+ label='Location (ID)',
)
tag = TagFilter()
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 76de832da..feb8c5e81 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -56,12 +56,18 @@ def get_device_by_name_or_pk(name):
class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm):
field_order = [
- 'q', 'region_id', 'site_group_id', 'site_id'
+ 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
]
q = forms.CharField(
required=False,
label=_('Search')
)
+ name = forms.CharField(
+ required=False
+ )
+ label = forms.CharField(
+ required=False
+ )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -91,6 +97,11 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm):
class InterfaceCommonForm(forms.Form):
+ mac_address = forms.CharField(
+ empty_value=None,
+ required=False,
+ label='MAC address'
+ )
def clean(self):
super().clean()
@@ -298,6 +309,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False
)
slug = SlugField()
+ time_zone = TimeZoneFormField(
+ choices=add_blank_choice(TimeZoneFormField().choices),
+ required=False,
+ widget=StaticSelect2()
+ )
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -505,9 +521,9 @@ class LocationCSVForm(CustomFieldModelCSVForm):
queryset=Location.objects.all(),
required=False,
to_field_name='name',
- help_text='Parent rack group',
+ help_text='Parent location',
error_messages={
- 'invalid_choice': 'Rack group not found.',
+ 'invalid_choice': 'Location not found.',
}
)
@@ -555,7 +571,7 @@ class LocationFilterForm(BootstrapMixin, forms.Form):
},
label=_('Site')
)
- parent = DynamicModelMultipleChoiceField(
+ parent_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
@@ -880,6 +896,9 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
null_option='None',
label=_('Role')
)
+ asset_tag = forms.CharField(
+ required=False
+ )
tag = TagFilterField(model)
@@ -940,7 +959,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Rack.objects.all(),
query_params={
'site_id': '$site',
- 'location_id': 'location',
+ 'location_id': '$location',
}
)
units = NumericArrayField(
@@ -1045,7 +1064,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
nullable_fields = []
-class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
+class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = RackReservation
field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
q = forms.CharField(
@@ -1068,13 +1087,13 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'),
required=False,
- label='Location',
+ label=_('Location'),
null_option='None'
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
- label='User',
+ label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
)
@@ -1149,10 +1168,10 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
widgets = {
'subdevice_role': StaticSelect2(),
# Exclude SVG images (unsupported by PIL)
- 'front_image': forms.FileInput(attrs={
+ 'front_image': forms.ClearableFileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
}),
- 'rear_image': forms.FileInput(attrs={
+ 'rear_image': forms.ClearableFileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
})
}
@@ -2017,7 +2036,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
@@ -2038,7 +2056,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False,
query_params={
'site_id': '$site',
- 'location_id': 'location',
+ 'location_id': '$location',
}
)
position = forms.IntegerField(
@@ -2104,8 +2122,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
model = Device
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
- 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster',
- 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
+ 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group',
+ 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
]
help_texts = {
'device_role': "The function this device serves",
@@ -2344,6 +2362,17 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=DeviceRole.objects.all(),
required=False
)
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False
+ )
+ location = DynamicModelChoiceField(
+ queryset=Location.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
+ )
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
@@ -2373,7 +2402,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
model = Device
field_order = [
'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
- 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip',
+ 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
]
q = forms.CharField(
required=False,
@@ -2381,14 +2410,16 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
- required=False
+ required=False,
+ label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id'
- }
+ },
+ label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
@@ -2437,6 +2468,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
required=False,
widget=StaticSelect2Multiple()
)
+ asset_tag = forms.CharField(
+ required=False
+ )
mac_address = forms.CharField(
required=False,
label='MAC address'
@@ -3038,10 +3072,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
- label='Parent interface',
- query_params={
- 'kind': 'physical',
- }
+ label='Parent interface'
)
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
@@ -3054,20 +3085,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
- label='Untagged VLAN',
- brief_mode=False,
- query_params={
- 'site_id': 'null',
- }
+ label='Untagged VLAN'
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
- label='Tagged VLANs',
- brief_mode=False,
- query_params={
- 'site_id': 'null',
- }
+ label='Tagged VLANs'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -3101,9 +3124,9 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
self.fields['parent'].widget.add_query_param('device_id', device.pk)
self.fields['lag'].widget.add_query_param('device_id', device.pk)
- # Add current site to VLANs query params
- self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
- self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
+ # Limit VLAN choices by device
+ self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
+ self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
@@ -3121,7 +3144,6 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
required=False,
query_params={
'device_id': '$device',
- 'kind': 'physical',
}
)
lag = DynamicModelChoiceField(
@@ -3154,19 +3176,11 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
- required=False,
- brief_mode=False,
- query_params={
- 'site_id': 'null',
- }
+ required=False
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
- required=False,
- brief_mode=False,
- query_params={
- 'site_id': 'null',
- }
+ required=False
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
@@ -3176,12 +3190,10 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- # Add current site to VLANs query params
- device = Device.objects.get(
- pk=self.initial.get('device') or self.data.get('device')
- )
- self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
- self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
+ # Limit VLAN choices by device
+ device_id = self.initial.get('device') or self.data.get('device')
+ self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id)
+ self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id)
class InterfaceBulkCreateForm(
@@ -3218,10 +3230,7 @@ class InterfaceBulkEditForm(
)
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
- required=False,
- query_params={
- 'kind': 'physical',
- }
+ required=False
)
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
@@ -3241,19 +3250,11 @@ class InterfaceBulkEditForm(
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
- required=False,
- brief_mode=False,
- query_params={
- 'site_id': 'null',
- }
+ required=False
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
- required=False,
- brief_mode=False,
- query_params={
- 'site_id': 'null',
- }
+ required=False
)
class Meta:
@@ -3270,9 +3271,9 @@ class InterfaceBulkEditForm(
self.fields['parent'].widget.add_query_param('device_id', device.pk)
self.fields['lag'].widget.add_query_param('device_id', device.pk)
- # Add current site to VLANs query params
- self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
- self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
+ # Limit VLAN choices by device
+ self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
+ self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
else:
# See #4523
@@ -4322,7 +4323,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo
})
-class CableFilterForm(BootstrapMixin, forms.Form):
+class CableFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Cable
q = forms.CharField(
required=False,
@@ -4349,7 +4350,7 @@ class CableFilterForm(BootstrapMixin, forms.Form):
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
- label='Rack',
+ label=_('Rack'),
null_option='None',
query_params={
'site_id': '$site_id'
@@ -4743,7 +4744,6 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
@@ -4767,7 +4767,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
'region', 'site_group', 'site', 'location', 'name', 'tags',
]
fieldsets = (
- ('Power Panel', ('region', 'site', 'location', 'name', 'tags')),
+ ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')),
)
diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py
index 06b5bdec0..fd5f9cfab 100644
--- a/netbox/dcim/management/commands/trace_paths.py
+++ b/netbox/dcim/management/commands/trace_paths.py
@@ -2,12 +2,10 @@ from django.core.management.base import BaseCommand
from django.core.management.color import no_style
from django.db import connection
-from circuits.models import CircuitTermination
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath
ENDPOINT_MODELS = (
- CircuitTermination,
ConsolePort,
ConsoleServerPort,
Interface,
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index c8166cb44..28d21ff68 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -242,6 +242,16 @@ class Cable(PrimaryModel):
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
+ # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
+ if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
+ raise ValidationError({
+ 'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
+ })
+ if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
+ raise ValidationError({
+ 'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
+ })
+
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
@@ -384,6 +394,8 @@ class CablePath(BigIDModel):
"""
Create a new CablePath instance as traced from the given path origin.
"""
+ from circuits.models import CircuitTermination
+
if origin is None or origin.cable is None:
return None
@@ -431,6 +443,23 @@ class CablePath(BigIDModel):
# No corresponding FrontPort found for the RearPort
break
+ # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
+ elif isinstance(peer_termination, CircuitTermination):
+ path.append(object_to_path_node(peer_termination))
+ # Get peer CircuitTermination
+ node = peer_termination.get_peer_termination()
+ if node:
+ path.append(object_to_path_node(node))
+ if node.provider_network:
+ destination = node.provider_network
+ break
+ elif node.site and not node.cable:
+ destination = node.site
+ break
+ else:
+ # No peer CircuitTermination exists; halt the trace
+ break
+
# Anything else marks the end of the path
else:
destination = peer_termination
@@ -476,19 +505,43 @@ class CablePath(BigIDModel):
return path
+ @property
+ def last_node(self):
+ """
+ Return either the destination or the last node within the path.
+ """
+ return self.destination or path_node_to_object(self.path[-1])
+
+ def get_cable_ids(self):
+ """
+ Return all Cable IDs within the path.
+ """
+ cable_ct = ContentType.objects.get_for_model(Cable).pk
+ cable_ids = []
+
+ for node in self.path:
+ ct, id = decompile_path_node(node)
+ if ct == cable_ct:
+ cable_ids.append(id)
+
+ return cable_ids
+
def get_total_length(self):
"""
- Return the sum of the length of each cable in the path.
+ Return a tuple containing the sum of the length of each cable in the path
+ and a flag indicating whether the length is definitive.
"""
- cable_ids = [
- # Starting from the first element, every third element in the path should be a Cable
- decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3)
- ]
- return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total']
+ cable_ids = self.get_cable_ids()
+ cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
+ total_length = cables.aggregate(total=Sum('_abs_length'))['total']
+ is_definitive = len(cables) == len(cable_ids)
+
+ return total_length, is_definitive
def get_split_nodes(self):
"""
Return all available next segments in a split cable path.
"""
rearport = path_node_to_object(self.path[-1])
+
return FrontPort.objects.filter(rear_port=rearport)
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 4b162b30f..eda262a59 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -141,7 +141,9 @@ class CableTermination(models.Model):
super().clean()
if self.mark_connected and self.cable_id:
- raise ValidationError("Cannot set mark_connected with a cable connected.")
+ raise ValidationError({
+ "mark_connected": "Cannot mark as connected with a cable attached."
+ })
def get_cable_peer(self):
return self._cable_peer
@@ -158,7 +160,7 @@ class CableTermination(models.Model):
class PathEndpoint(models.Model):
"""
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
- these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination.
+ these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
@@ -182,10 +184,11 @@ class PathEndpoint(models.Model):
# Construct the complete path
path = [self, *self._path.get_path()]
- while (len(path) + 1) % 3:
+ if self._path.destination:
+ path.append(self._path.destination)
+ while len(path) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort)
- path.append(None)
- path.append(self._path.destination)
+ path.insert(-1, None)
# Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3))
@@ -504,6 +507,10 @@ class BaseInterface(models.Model):
return super().save(*args, **kwargs)
+ @property
+ def count_ipaddresses(self):
+ return self.ip_addresses.count()
+
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
@@ -596,12 +603,15 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
super().clean()
# Virtual interfaces cannot be connected
- if self.type in NONCONNECTABLE_IFACE_TYPES and (
- self.cable or getattr(self, 'circuit_termination', False)
- ):
+ if not self.is_connectable and self.cable:
raise ValidationError({
- 'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
- "Disconnect the interface or choose a suitable type."
+ 'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
+ })
+
+ # Non-connectable interfaces cannot be marked as connected
+ if not self.is_connectable and self.mark_connected:
+ raise ValidationError({
+ 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected."
})
# An interface's parent must belong to the same device or virtual chassis
@@ -617,14 +627,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
f"is not part of virtual chassis {self.device.virtual_chassis}."
})
+ # An interface cannot be its own parent
+ if self.pk and self.parent_id == self.pk:
+ raise ValidationError({'parent': "An interface cannot be its own parent."})
+
# A physical interface cannot have a parent interface
if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
- # A virtual interface cannot be a parent interface
- if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL:
- raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."})
-
# An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device:
if self.device.virtual_chassis is None:
@@ -668,10 +678,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG
- @property
- def count_ipaddresses(self):
- return self.ip_addresses.count()
-
#
# Pass-through ports
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 7f22d9325..551fac2d4 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -65,7 +65,7 @@ class Manufacturer(OrganizationalModel):
return self.name
def get_absolute_url(self):
- return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
+ return reverse('dcim:manufacturer', args=[self.pk])
def to_csv(self):
return (
@@ -375,6 +375,9 @@ class DeviceRole(OrganizationalModel):
def __str__(self):
return self.name
+ def get_absolute_url(self):
+ return reverse('dcim:devicerole', args=[self.pk])
+
def to_csv(self):
return (
self.name,
@@ -436,7 +439,7 @@ class Platform(OrganizationalModel):
return self.name
def get_absolute_url(self):
- return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
+ return reverse('dcim:platform', args=[self.pk])
def to_csv(self):
return (
@@ -649,6 +652,10 @@ class Device(PrimaryModel, ConfigContextModel):
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
})
+ if self.location and self.site != self.location.site:
+ raise ValidationError({
+ 'location': f"Location {self.location} does not belong to site {self.site}.",
+ })
if self.rack and self.location and self.rack.location != self.location:
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 06d234149..a5e3149f8 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -66,9 +66,9 @@ class PowerPanel(PrimaryModel):
# Location must belong to assigned Site
if self.location and self.location.site != self.site:
- raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
- self.location, self.location.site, self.site
- ))
+ raise ValidationError(
+ f"Location {self.location} ({self.location.site}) is in a different site than {self.site}"
+ )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 1942e3cb0..2869c4265 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -67,7 +67,7 @@ class RackRole(OrganizationalModel):
return self.name
def get_absolute_url(self):
- return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
+ return reverse('dcim:rackrole', args=[self.pk])
def to_csv(self):
return (
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index 51cb63d08..225a8e749 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -7,6 +7,7 @@ from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
+from django.core.exceptions import ValidationError
from dcim.fields import ASNField
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
@@ -56,7 +57,7 @@ class Region(NestedGroupModel):
csv_headers = ['name', 'slug', 'parent', 'description']
def get_absolute_url(self):
- return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
+ return reverse('dcim:region', args=[self.pk])
def to_csv(self):
return (
@@ -108,7 +109,7 @@ class SiteGroup(NestedGroupModel):
csv_headers = ['name', 'slug', 'parent', 'description']
def get_absolute_url(self):
- return "{}?group={}".format(reverse('dcim:site_list'), self.slug)
+ return reverse('dcim:sitegroup', args=[self.pk])
def to_csv(self):
return (
@@ -313,8 +314,12 @@ class Location(NestedGroupModel):
max_length=200,
blank=True
)
+ images = GenericRelation(
+ to='extras.ImageAttachment'
+ )
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
+ clone_fields = ['site', 'parent', 'description']
class Meta:
ordering = ['site', 'name']
@@ -324,7 +329,7 @@ class Location(NestedGroupModel):
]
def get_absolute_url(self):
- return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk)
+ return reverse('dcim:location', args=[self.pk])
def to_csv(self):
return (
diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py
index cdb79f4e1..9509ec2bc 100644
--- a/netbox/dcim/tables/cables.py
+++ b/netbox/dcim/tables/cables.py
@@ -26,9 +26,10 @@ class CableTable(BaseTable):
orderable=False,
verbose_name='Side A'
)
- termination_a = tables.LinkColumn(
+ termination_a = tables.Column(
accessor=Accessor('termination_a'),
orderable=False,
+ linkify=True,
verbose_name='Termination A'
)
termination_b_parent = tables.TemplateColumn(
@@ -37,9 +38,10 @@ class CableTable(BaseTable):
orderable=False,
verbose_name='Side B'
)
- termination_b = tables.LinkColumn(
+ termination_b = tables.Column(
accessor=Accessor('termination_b'),
orderable=False,
+ linkify=True,
verbose_name='Termination B'
)
status = ChoiceFieldColumn()
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index c4204dd4a..cc1d78fa3 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -44,20 +44,31 @@ __all__ = (
)
+def get_cabletermination_row_class(record):
+ if record.mark_connected:
+ return 'success'
+ elif record.cable:
+ return record.cable.get_status_class()
+ return ''
+
+
#
# Device roles
#
class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
device_count = LinkedCountColumn(
viewname='dcim:device_list',
- url_params={'role': 'slug'},
+ url_params={'role_id': 'pk'},
verbose_name='Devices'
)
vm_count = LinkedCountColumn(
viewname='virtualization:virtualmachine_list',
- url_params={'role': 'slug'},
+ url_params={'role_id': 'pk'},
verbose_name='VMs'
)
color = ColorColumn()
@@ -76,14 +87,17 @@ class DeviceRoleTable(BaseTable):
class PlatformTable(BaseTable):
pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
device_count = LinkedCountColumn(
viewname='dcim:device_list',
- url_params={'platform': 'slug'},
+ url_params={'platform_id': 'pk'},
verbose_name='Devices'
)
vm_count = LinkedCountColumn(
viewname='virtualization:virtualmachine_list',
- url_params={'platform': 'slug'},
+ url_params={'platform_id': 'pk'},
verbose_name='VMs'
)
actions = ButtonsColumn(Platform)
@@ -123,11 +137,13 @@ class DeviceTable(BaseTable):
device_role = ColoredLabelColumn(
verbose_name='Role'
)
- device_type = tables.LinkColumn(
- viewname='dcim:devicetype',
- args=[Accessor('device_type__pk')],
- verbose_name='Type',
- text=lambda record: record.device_type.display_name
+ manufacturer = tables.Column(
+ accessor=Accessor('device_type__manufacturer'),
+ linkify=True
+ )
+ device_type = tables.Column(
+ linkify=True,
+ verbose_name='Type'
)
if settings.PREFER_IPV4:
primary_ip = tables.Column(
@@ -149,13 +165,11 @@ class DeviceTable(BaseTable):
linkify=True,
verbose_name='IPv6 Address'
)
- cluster = tables.LinkColumn(
- viewname='virtualization:cluster',
- args=[Accessor('cluster__pk')]
+ cluster = tables.Column(
+ linkify=True
)
- virtual_chassis = tables.LinkColumn(
- viewname='dcim:virtualchassis',
- args=[Accessor('virtual_chassis__pk')]
+ virtual_chassis = tables.Column(
+ linkify=True
)
vc_position = tables.Column(
verbose_name='VC Position'
@@ -170,12 +184,13 @@ class DeviceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Device
fields = (
- 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
- 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
- 'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
+ 'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
+ 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
+ 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
)
default_columns = (
- 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'device_type', 'primary_ip',
+ 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
+ 'primary_ip',
)
@@ -230,6 +245,11 @@ class CableTerminationTable(BaseTable):
cable = tables.Column(
linkify=True
)
+ cable_color = ColorColumn(
+ accessor='cable.color',
+ orderable=False,
+ verbose_name='Cable Color'
+ )
cable_peer = tables.TemplateColumn(
accessor='_cable_peer',
template_code=CABLETERMINATION,
@@ -241,7 +261,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable):
connection = tables.TemplateColumn(
- accessor='_path.destination',
+ accessor='_path.last_node',
template_code=CABLETERMINATION,
verbose_name='Connection',
orderable=False
@@ -249,6 +269,12 @@ class PathEndpointTable(CableTerminationTable):
class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_consoleports',
+ 'args': [Accessor('device_id')],
+ }
+ )
tags = TagColumn(
url_name='dcim:consoleport_list'
)
@@ -256,8 +282,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
- 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
- 'connection', 'tags',
+ 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
+ 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
@@ -276,16 +302,22 @@ class DeviceConsolePortTable(ConsolePortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
- 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
- 'connection', 'tags', 'actions'
+ 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
+ 'cable_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = {
- 'class': lambda record: record.cable.get_status_class() if record.cable else ''
+ 'class': get_cabletermination_row_class
}
class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_consoleserverports',
+ 'args': [Accessor('device_id')],
+ }
+ )
tags = TagColumn(
url_name='dcim:consoleserverport_list'
)
@@ -293,8 +325,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
- 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
- 'connection', 'tags',
+ 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
+ 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
@@ -314,16 +346,22 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
- 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
- 'connection', 'tags', 'actions',
+ 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
+ 'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = {
- 'class': lambda record: record.cable.get_status_class() if record.cable else ''
+ 'class': get_cabletermination_row_class
}
class PowerPortTable(DeviceComponentTable, PathEndpointTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_powerports',
+ 'args': [Accessor('device_id')],
+ }
+ )
tags = TagColumn(
url_name='dcim:powerport_list'
)
@@ -332,7 +370,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
model = PowerPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
- 'cable', 'cable_peer', 'connection', 'tags',
+ 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -353,18 +391,24 @@ class DevicePowerPortTable(PowerPortTable):
model = PowerPort
fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
- 'cable_peer', 'connection', 'tags', 'actions',
+ 'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
'actions',
)
row_attrs = {
- 'class': lambda record: record.cable.get_status_class() if record.cable else ''
+ 'class': get_cabletermination_row_class
}
class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_poweroutlets',
+ 'args': [Accessor('device_id')],
+ }
+ )
power_port = tables.Column(
linkify=True
)
@@ -376,7 +420,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
model = PowerOutlet
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
- 'cable_peer', 'connection', 'tags',
+ 'cable_color', 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -396,13 +440,13 @@ class DevicePowerOutletTable(PowerOutletTable):
model = PowerOutlet
fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
- 'cable_peer', 'connection', 'tags', 'actions',
+ 'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
)
row_attrs = {
- 'class': lambda record: record.cable.get_status_class() if record.cable else ''
+ 'class': get_cabletermination_row_class
}
@@ -422,6 +466,12 @@ class BaseInterfaceTable(BaseTable):
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_interfaces',
+ 'args': [Accessor('device_id')],
+ }
+ )
mgmt_only = BooleanColumn()
tags = TagColumn(
url_name='dcim:interface_list'
@@ -431,7 +481,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
model = Interface
fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
- 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses',
+ 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
@@ -462,7 +512,7 @@ class DeviceInterfaceTable(InterfaceTable):
model = Interface
fields = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
- 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses',
+ 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans', 'actions',
)
default_columns = (
@@ -470,12 +520,18 @@ class DeviceInterfaceTable(InterfaceTable):
'cable', 'connection', 'actions',
)
row_attrs = {
- 'class': lambda record: record.cable.get_status_class() if record.cable else '',
+ 'class': get_cabletermination_row_class,
'data-name': lambda record: record.name,
}
class FrontPortTable(DeviceComponentTable, CableTerminationTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_frontports',
+ 'args': [Accessor('device_id')],
+ }
+ )
rear_port_position = tables.Column(
verbose_name='Position'
)
@@ -490,7 +546,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
model = FrontPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected',
- 'cable', 'cable_peer', 'tags',
+ 'cable', 'cable_color', 'cable_peer', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
@@ -511,18 +567,24 @@ class DeviceFrontPortTable(FrontPortTable):
model = FrontPort
fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
- 'cable_peer', 'tags', 'actions',
+ 'cable_color', 'cable_peer', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
'actions',
)
row_attrs = {
- 'class': lambda record: record.cable.get_status_class() if record.cable else ''
+ 'class': get_cabletermination_row_class
}
class RearPortTable(DeviceComponentTable, CableTerminationTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_rearports',
+ 'args': [Accessor('device_id')],
+ }
+ )
tags = TagColumn(
url_name='dcim:rearport_list'
)
@@ -531,7 +593,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
model = RearPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable',
- 'cable_peer', 'tags',
+ 'cable_color', 'cable_peer', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
@@ -551,18 +613,24 @@ class DeviceRearPortTable(RearPortTable):
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
- 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_peer', 'tags',
- 'actions',
+ 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
+ 'cable_peer', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',
)
row_attrs = {
- 'class': lambda record: record.cable.get_status_class() if record.cable else ''
+ 'class': get_cabletermination_row_class
}
class DeviceBayTable(DeviceComponentTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_devicebays',
+ 'args': [Accessor('device_id')],
+ }
+ )
status = tables.TemplateColumn(
template_code=DEVICEBAY_STATUS
)
@@ -602,6 +670,12 @@ class DeviceDeviceBayTable(DeviceBayTable):
class InventoryItemTable(DeviceComponentTable):
+ device = tables.Column(
+ linkify={
+ 'viewname': 'dcim:device_inventory',
+ 'args': [Accessor('device_id')],
+ }
+ )
manufacturer = tables.Column(
linkify=True
)
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index c5b8bb70d..0a445171d 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -26,7 +26,9 @@ __all__ = (
class ManufacturerTable(BaseTable):
pk = ToggleColumn()
- name = tables.LinkColumn()
+ name = tables.Column(
+ linkify=True
+ )
devicetype_count = tables.Column(
verbose_name='Device Types'
)
diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py
index ce69b0ede..1c4d6e921 100644
--- a/netbox/dcim/tables/power.py
+++ b/netbox/dcim/tables/power.py
@@ -1,10 +1,8 @@
import django_tables2 as tables
-from django_tables2.utils import Accessor
from dcim.models import PowerFeed, PowerPanel
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
from .devices import CableTerminationTable
-from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION
__all__ = (
'PowerFeedTable',
@@ -18,7 +16,9 @@ __all__ = (
class PowerPanelTable(BaseTable):
pk = ToggleColumn()
- name = tables.LinkColumn()
+ name = tables.Column(
+ linkify=True
+ )
site = tables.Column(
linkify=True
)
@@ -45,7 +45,9 @@ class PowerPanelTable(BaseTable):
# cannot traverse pass-through ports.
class PowerFeedTable(CableTerminationTable):
pk = ToggleColumn()
- name = tables.LinkColumn()
+ name = tables.Column(
+ linkify=True
+ )
power_panel = tables.Column(
linkify=True
)
@@ -68,7 +70,8 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed
fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
- 'max_utilization', 'mark_connected', 'cable', 'cable_peer', 'connection', 'available_power', 'tags',
+ 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
+ 'tags',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index eb4b28710..3a63eef1e 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -1,47 +1,21 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
-from dcim.models import Rack, Location, RackReservation, RackRole
+from dcim.models import Rack, RackReservation, RackRole
from tenancy.tables import TenantColumn
from utilities.tables import (
- BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn,
- TagColumn, ToggleColumn, UtilizationColumn,
+ BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
+ ToggleColumn, UtilizationColumn,
)
-from .template_code import LOCATION_ELEVATIONS
__all__ = (
'RackTable',
'RackDetailTable',
- 'LocationTable',
'RackReservationTable',
'RackRoleTable',
)
-#
-# Rack groups
-#
-
-class LocationTable(BaseTable):
- pk = ToggleColumn()
- name = MPTTColumn()
- site = tables.Column(
- linkify=True
- )
- rack_count = tables.Column(
- verbose_name='Racks'
- )
- actions = ButtonsColumn(
- model=Location,
- prepend_template=LOCATION_ELEVATIONS
- )
-
- class Meta(BaseTable.Meta):
- model = Location
- fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
- default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
-
-
#
# Rack roles
#
@@ -69,7 +43,7 @@ class RackTable(BaseTable):
order_by=('_name',),
linkify=True
)
- group = tables.Column(
+ location = tables.Column(
linkify=True
)
site = tables.Column(
@@ -86,10 +60,10 @@ class RackTable(BaseTable):
class Meta(BaseTable.Meta):
model = Rack
fields = (
- 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
+ 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height',
)
- default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height')
+ default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height')
class RackDetailTable(RackTable):
@@ -111,11 +85,11 @@ class RackDetailTable(RackTable):
class Meta(RackTable.Meta):
fields = (
- 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
+ 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
)
default_columns = (
- 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
+ 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization', 'get_power_utilization',
)
diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py
index e22037255..b7d46eba5 100644
--- a/netbox/dcim/tables/sites.py
+++ b/netbox/dcim/tables/sites.py
@@ -1,10 +1,14 @@
import django_tables2 as tables
-from dcim.models import Region, Site, SiteGroup
+from dcim.models import Location, Region, Site, SiteGroup
from tenancy.tables import TenantColumn
-from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn
+from utilities.tables import (
+ BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn,
+)
+from .template_code import LOCATION_ELEVATIONS
__all__ = (
+ 'LocationTable',
'RegionTable',
'SiteTable',
'SiteGroupTable',
@@ -17,8 +21,12 @@ __all__ = (
class RegionTable(BaseTable):
pk = ToggleColumn()
- name = MPTTColumn()
- site_count = tables.Column(
+ name = MPTTColumn(
+ linkify=True
+ )
+ site_count = LinkedCountColumn(
+ viewname='dcim:site_list',
+ url_params={'region_id': 'pk'},
verbose_name='Sites'
)
actions = ButtonsColumn(Region)
@@ -35,8 +43,12 @@ class RegionTable(BaseTable):
class SiteGroupTable(BaseTable):
pk = ToggleColumn()
- name = MPTTColumn()
- site_count = tables.Column(
+ name = MPTTColumn(
+ linkify=True
+ )
+ site_count = LinkedCountColumn(
+ viewname='dcim:site_list',
+ url_params={'group_id': 'pk'},
verbose_name='Sites'
)
actions = ButtonsColumn(SiteGroup)
@@ -53,8 +65,8 @@ class SiteGroupTable(BaseTable):
class SiteTable(BaseTable):
pk = ToggleColumn()
- name = tables.LinkColumn(
- order_by=('_name',)
+ name = tables.Column(
+ linkify=True
)
status = ChoiceFieldColumn()
region = tables.Column(
@@ -76,3 +88,32 @@ class SiteTable(BaseTable):
'contact_email', 'tags',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')
+
+
+#
+# Locations
+#
+
+class LocationTable(BaseTable):
+ pk = ToggleColumn()
+ name = MPTTColumn(
+ linkify=True
+ )
+ site = tables.Column(
+ linkify=True
+ )
+ rack_count = tables.Column(
+ verbose_name='Racks'
+ )
+ device_count = tables.Column(
+ verbose_name='Devices'
+ )
+ actions = ButtonsColumn(
+ model=Location,
+ prepend_template=LOCATION_ELEVATIONS
+ )
+
+ class Meta(BaseTable.Meta):
+ model = Location
+ fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
+ default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions')
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index e12f6ef87..fa22dd5c0 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -1,10 +1,12 @@
CABLETERMINATION = """
{% if value %}
+ {% if value.parent_object %}
{{ value.parent_object }}
- {{ value }}
+ {% endif %}
+ {{ value }}
{% else %}
- —
+ —
{% endif %}
"""
@@ -101,6 +103,8 @@ CONSOLEPORT_BUTTONS = """
- {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
- {% include 'inc/paginator.html' with paginator=items_table.paginator page=items_table.page %}
-
+
+
+
+
+ Tag
+
+
+
+
+
Name
+
+ {{ object.name }}
+
+
+
+
Slug
+
+ {{ object.slug }}
+
+
+
+
Tagged Items
+
+ {{ items_count }}
+
+
+
+
Color
+
+
+
+
+
+
Description
+
+ {{ object.description|placeholder }}
+
+
+
+
+
+
+ {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
+ {% include 'inc/paginator.html' with paginator=items_table.paginator page=items_table.page %}
+
+
+
+
+ {% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %}
+
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
- {% if permissions.change or permissions.delete %}
-