mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-17 04:32:51 -06:00
Merge branch 'fix/generic_prefetch_4.2' of github.com:Tishka17/netbox into fix/generic_prefetch_4.2
This commit is contained in:
commit
2f51dfc07a
16
.github/ISSUE_TEMPLATE/01-feature_request.yaml
vendored
16
.github/ISSUE_TEMPLATE/01-feature_request.yaml
vendored
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
|
type: Feature
|
||||||
description: Propose a new NetBox feature or enhancement
|
description: Propose a new NetBox feature or enhancement
|
||||||
labels: ["type: feature", "status: needs triage"]
|
labels: ["type: feature", "status: needs triage"]
|
||||||
body:
|
body:
|
||||||
@ -14,7 +15,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.1.11
|
placeholder: v4.2.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
@ -27,19 +28,6 @@ body:
|
|||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: Triage priority
|
|
||||||
description: >
|
|
||||||
Issue triage may be prioritized in some cases. Select whichever of the following
|
|
||||||
conditions applies, if any.
|
|
||||||
options:
|
|
||||||
- I volunteer to perform this work (if approved)
|
|
||||||
- I'm a NetBox Labs customer
|
|
||||||
- N/A
|
|
||||||
default: 2
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Proposed functionality
|
label: Proposed functionality
|
||||||
|
16
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
16
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
|
type: Bug
|
||||||
description: Report a reproducible bug in the current release of NetBox
|
description: Report a reproducible bug in the current release of NetBox
|
||||||
labels: ["type: bug", "status: needs triage"]
|
labels: ["type: bug", "status: needs triage"]
|
||||||
body:
|
body:
|
||||||
@ -22,24 +23,11 @@ body:
|
|||||||
- Self-hosted
|
- Self-hosted
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: Triage priority
|
|
||||||
description: >
|
|
||||||
Issue triage may be prioritized in some cases. Select whichever of the following
|
|
||||||
conditions applies, if any.
|
|
||||||
options:
|
|
||||||
- I volunteer to perform this work (if approved)
|
|
||||||
- I'm a NetBox Labs customer
|
|
||||||
- N/A
|
|
||||||
default: 2
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.1.11
|
placeholder: v4.2.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 📖 Documentation Change
|
name: 📖 Documentation Change
|
||||||
|
type: Documentation
|
||||||
description: Suggest an addition or modification to the NetBox documentation
|
description: Suggest an addition or modification to the NetBox documentation
|
||||||
labels: ["type: documentation", "status: needs triage"]
|
labels: ["type: documentation", "status: needs triage"]
|
||||||
body:
|
body:
|
||||||
|
1
.github/ISSUE_TEMPLATE/04-translation.yaml
vendored
1
.github/ISSUE_TEMPLATE/04-translation.yaml
vendored
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 🌍 Translation
|
name: 🌍 Translation
|
||||||
|
type: Translation
|
||||||
description: Request support for a new language in the user interface
|
description: Request support for a new language in the user interface
|
||||||
labels: ["type: translation"]
|
labels: ["type: translation"]
|
||||||
body:
|
body:
|
||||||
|
1
.github/ISSUE_TEMPLATE/05-housekeeping.yaml
vendored
1
.github/ISSUE_TEMPLATE/05-housekeeping.yaml
vendored
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 🏡 Housekeeping
|
name: 🏡 Housekeeping
|
||||||
|
type: Housekeeping
|
||||||
description: A change pertaining to the codebase itself (developers only)
|
description: A change pertaining to the codebase itself (developers only)
|
||||||
labels: ["type: housekeeping"]
|
labels: ["type: housekeeping"]
|
||||||
body:
|
body:
|
||||||
|
1
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
1
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 🗑️ Deprecation
|
name: 🗑️ Deprecation
|
||||||
|
type: Deprecation
|
||||||
description: The removal of an existing feature or resource
|
description: The removal of an existing feature or resource
|
||||||
labels: ["type: deprecation"]
|
labels: ["type: deprecation"]
|
||||||
body:
|
body:
|
||||||
|
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -2,7 +2,7 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: 📖 Contributing Policy
|
- name: 📖 Contributing Policy
|
||||||
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
|
url: https://github.com/netbox-community/netbox/blob/main/CONTRIBUTING.md
|
||||||
about: "Please read through our contributing policy before opening an issue or pull request."
|
about: "Please read through our contributing policy before opening an issue or pull request."
|
||||||
- name: ❓ Discussion
|
- name: ❓ Discussion
|
||||||
url: https://github.com/netbox-community/netbox/discussions
|
url: https://github.com/netbox-community/netbox/discussions
|
||||||
|
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -3,11 +3,15 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
- 'contrib/**'
|
- 'contrib/**'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'netbox/translations/**'
|
- 'netbox/translations/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
- 'contrib/**'
|
- 'contrib/**'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'netbox/translations/**'
|
- 'netbox/translations/**'
|
||||||
|
2
.github/workflows/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@ -38,7 +38,7 @@ jobs:
|
|||||||
issues may receive direct feedback. **Do not** attempt to circumvent this
|
issues may receive direct feedback. **Do not** attempt to circumvent this
|
||||||
process by "bumping" the issue; doing so will result in its immediate closure
|
process by "bumping" the issue; doing so will result in its immediate closure
|
||||||
and you may be barred from participating in any future discussions. Please see
|
and you may be barred from participating in any future discussions. Please see
|
||||||
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
our [contributing guide](https://github.com/netbox-community/netbox/blob/main/CONTRIBUTING.md).
|
||||||
|
|
||||||
# Pull request parameters
|
# Pull request parameters
|
||||||
close-pr-message: >
|
close-pr-message: >
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
[main]
|
[main]
|
||||||
host = https://app.transifex.com
|
host = https://app.transifex.com
|
||||||
|
|
||||||
[o:netbox-community:p:netbox:r:9cbf4fcf95b3d92e4ebbf1a5e5d1caee]
|
[o:netbox-community:p:netbox:r:034999968a7366ba27a8bdf1ab63bf42]
|
||||||
file_filter = netbox/translations/<lang>/LC_MESSAGES/django.po
|
file_filter = netbox/translations/<lang>/LC_MESSAGES/django.po
|
||||||
source_file = netbox/translations/en/LC_MESSAGES/django.po
|
source_file = netbox/translations/en/LC_MESSAGES/django.po
|
||||||
type = PO
|
type = PO
|
||||||
|
@ -84,7 +84,7 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
|
|||||||
|
|
||||||
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
|
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
|
||||||
|
|
||||||
* New pull requests should generally be based off of the `develop` branch, rather than `master`. The `develop` branch is used for ongoing development, while `master` is used for tracking stable releases. (If you're developing for an upcoming minor release, use `feature` instead.)
|
* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
|
||||||
|
|
||||||
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
|
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
|
||||||
|
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo_light.svg" width="400" alt="NetBox logo" />
|
<img src="https://raw.githubusercontent.com/netbox-community/netbox/main/docs/netbox_logo_light.svg" width="400" alt="NetBox logo" />
|
||||||
<p><strong>The cornerstone of every automated network</strong></p>
|
<p><strong>The cornerstone of every automated network</strong></p>
|
||||||
<a href="https://github.com/netbox-community/netbox/releases"><img src="https://img.shields.io/github/v/release/netbox-community/netbox" alt="Latest release" /></a>
|
<a href="https://github.com/netbox-community/netbox/releases"><img src="https://img.shields.io/github/v/release/netbox-community/netbox" alt="Latest release" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
<a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
|
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
|
||||||
<p>
|
<p>
|
||||||
<strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> |
|
<strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> |
|
||||||
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
||||||
|
@ -8,8 +8,6 @@ django-cors-headers
|
|||||||
|
|
||||||
# Runtime UI tool for debugging Django
|
# Runtime UI tool for debugging Django
|
||||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||||
# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
|
|
||||||
# and https://github.com/jazzband/django-debug-toolbar/issues/1927
|
|
||||||
django-debug-toolbar
|
django-debug-toolbar
|
||||||
|
|
||||||
# Library for writing reusable URL query filters
|
# Library for writing reusable URL query filters
|
||||||
@ -90,7 +88,8 @@ mkdocs-material
|
|||||||
|
|
||||||
# Introspection for embedded code
|
# Introspection for embedded code
|
||||||
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
||||||
mkdocstrings[python-legacy]
|
# See #18568
|
||||||
|
mkdocstrings[python-legacy]==0.27.0
|
||||||
|
|
||||||
# Library for manipulating IP prefixes and addresses
|
# Library for manipulating IP prefixes and addresses
|
||||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||||
@ -134,7 +133,8 @@ strawberry-graphql
|
|||||||
|
|
||||||
# Strawberry GraphQL Django extension
|
# Strawberry GraphQL Django extension
|
||||||
# https://github.com/strawberry-graphql/strawberry-django/releases
|
# https://github.com/strawberry-graphql/strawberry-django/releases
|
||||||
strawberry-graphql-django
|
# Pinned to v0.52.0 for suspected upstream bug; see #18329
|
||||||
|
strawberry-graphql-django==0.52.0
|
||||||
|
|
||||||
# SVG image rendering (used for rack elevations)
|
# SVG image rendering (used for rack elevations)
|
||||||
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||||
|
@ -54,6 +54,7 @@ Icons](https://github.com/google/material-design-icons) icon's name; or be
|
|||||||
`None` for no icon.
|
`None` for no icon.
|
||||||
|
|
||||||
For instance, the OIDC backend may be customized with
|
For instance, the OIDC backend may be customized with
|
||||||
|
|
||||||
```python
|
```python
|
||||||
SOCIAL_AUTH_BACKEND_ATTRS = {
|
SOCIAL_AUTH_BACKEND_ATTRS = {
|
||||||
'oidc': ("My awesome SSO", "login"),
|
'oidc': ("My awesome SSO", "login"),
|
||||||
|
@ -233,3 +233,15 @@ This parameter controls how frequently a failed job is retried, up to the maximu
|
|||||||
Default: `0` (retries disabled)
|
Default: `0` (retries disabled)
|
||||||
|
|
||||||
The maximum number of times a background task will be retried before being marked as failed.
|
The maximum number of times a background task will be retried before being marked as failed.
|
||||||
|
|
||||||
|
## DISK_BASE_UNIT
|
||||||
|
|
||||||
|
Default: `1000`
|
||||||
|
|
||||||
|
The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
|
||||||
|
|
||||||
|
## RAM_BASE_UNIT
|
||||||
|
|
||||||
|
Default: `1000`
|
||||||
|
|
||||||
|
The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## ALLOWED_HOSTS
|
## ALLOWED_HOSTS
|
||||||
|
|
||||||
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
|
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attacks](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
This parameter must always be defined as a list or tuple, even if only a single value is provided.
|
This parameter must always be defined as a list or tuple, even if only a single value is provided.
|
||||||
@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
|
|||||||
|
|
||||||
## DATABASE
|
## DATABASE
|
||||||
|
|
||||||
NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
|
NetBox requires access to a PostgreSQL 13 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
|
||||||
|
|
||||||
* `NAME` - Database name
|
* `NAME` - Database name
|
||||||
* `USER` - PostgreSQL username
|
* `USER` - PostgreSQL username
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
|
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
|
||||||
|
|
||||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `object`, and custom fields through `object.cf`.
|
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja template code](https://jinja.palletsprojects.com/en/stable/) through the variable `object`, and custom fields through `object.cf`.
|
||||||
|
|
||||||
For example, you might define a link like this:
|
For example, you might define a link like this:
|
||||||
|
|
||||||
|
@ -308,6 +308,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
|
|||||||
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
|
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
|
||||||
* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
|
* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
|
||||||
* `null_option` - A label representing a "null" or empty choice (optional)
|
* `null_option` - A label representing a "null" or empty choice (optional)
|
||||||
|
* `selector` - A boolean that, when True, includes an advanced object selection widget to assist the user in identifying the desired object (optional; False by default)
|
||||||
|
|
||||||
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
|
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ 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`.
|
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:
|
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 %}
|
{% for server in queryset %}
|
||||||
{% set data = server.get_config_context() %}
|
{% set data = server.get_config_context() %}
|
||||||
|
@ -37,16 +37,12 @@ CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scri
|
|||||||
|
|
||||||
### 2. Create a New Branch
|
### 2. Create a New Branch
|
||||||
|
|
||||||
The NetBox project utilizes three persistent git branches to track work:
|
The NetBox project utilizes two persistent git branches to track work:
|
||||||
|
|
||||||
* `master` - Serves as a snapshot of the current stable release
|
* `main` - All development on the upcoming stable (patch) release occurs here. Releases are published from this branch.
|
||||||
* `develop` - All development on the upcoming stable (patch) release occurs here
|
* `feature` - All work planned for the upcoming minor release is done here.
|
||||||
* `feature` - Tracks work on an upcoming minor release
|
|
||||||
|
|
||||||
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. For example, assume that the current NetBox release is v3.3.5. Work applied to the `develop` branch will appear in v3.3.6, and work done under the `feature` branch will be included in the next minor release (v3.4.0).
|
Typically, you'll base pull requests off of the `main` branch, or off of `feature` if you're working on the upcoming minor or major release. For example, assume that the current NetBox release is v4.2.3. Work applied to the `main` branch will appear in v4.2.4, and work done under the `feature` branch will be included in the next minor release (v4.3.0).
|
||||||
|
|
||||||
!!! warning
|
|
||||||
**Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release.
|
|
||||||
|
|
||||||
To create a new branch, first ensure that you've checked out the desired base branch, then run:
|
To create a new branch, first ensure that you've checked out the desired base branch, then run:
|
||||||
|
|
||||||
|
@ -128,7 +128,7 @@ Fast-forward
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! warning "Avoid Merging Remote Branches"
|
!!! warning "Avoid Merging Remote Branches"
|
||||||
You generally want to avoid merging branches that exist on the remote (upstream) repository, such as `develop` and `feature`: Merges into these branches should be done via a pull request on GitHub. Only merge branches when it is necessary to consolidate work you've done locally.
|
You generally want to avoid merging branches that exist on the remote (upstream) repository, namely `main` and `feature`: Merges into these branches should be done via a pull request on GitHub. Only merge branches when it is necessary to consolidate work you've done locally.
|
||||||
|
|
||||||
### Show Pending Changes
|
### Show Pending Changes
|
||||||
|
|
||||||
@ -196,7 +196,7 @@ index 93e125079..4344fb514 100644
|
|||||||
+and here too
|
+and here too
|
||||||
+
|
+
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
<img src="https://raw.githubusercontent.com/netbox-community/netbox/main/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||||
</div>
|
</div>
|
||||||
diff --git a/foo.py b/foo.py
|
diff --git a/foo.py b/foo.py
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
|
@ -8,11 +8,10 @@ NetBox and many of its related projects are maintained on [GitHub](https://githu
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
There are three permanent branches in the repository:
|
There are two permanent branches in the repository:
|
||||||
|
|
||||||
* `master` - The current stable release. Individual changes should never be pushed directly to this branch, but rather merged from `develop`.
|
* `main` - Active development for the upcoming patch release. Pull requests will typically be based on this branch unless they introduce breaking changes that must be deferred until the next minor release.
|
||||||
* `develop` - Active development for the upcoming patch release. Pull requests will typically be based on this branch unless they introduce breaking changes that must be deferred until the next minor release.
|
* `feature` - New feature work to be introduced in the next minor release (e.g. from v4.2 to v4.3).
|
||||||
* `feature` - New feature work to be introduced in the next minor release (e.g. from v3.3 to v3.4).
|
|
||||||
|
|
||||||
NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function:
|
NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function:
|
||||||
|
|
||||||
@ -57,4 +56,4 @@ NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevo
|
|||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
The entire NetBox project is licensed as open source under the [Apache 2.0 license](https://github.com/netbox-community/netbox/blob/master/LICENSE.txt). This is a very permissive license which allows unlimited redistribution of all code within the project. Note that all submissions to the project are subject to the same license.
|
The entire NetBox project is licensed as open source under the [Apache 2.0 license](https://github.com/netbox-community/netbox/blob/main/LICENSE.txt). This is a very permissive license which allows unlimited redistribution of all code within the project. Note that all submissions to the project are subject to the same license.
|
||||||
|
@ -8,6 +8,8 @@ This documentation describes the process of packaging and publishing a new NetBo
|
|||||||
|
|
||||||
While major releases generally introduce some very substantial change to the application, they are typically treated the same as minor version increments for the purpose of release packaging.
|
While major releases generally introduce some very substantial change to the application, they are typically treated the same as minor version increments for the purpose of release packaging.
|
||||||
|
|
||||||
|
For patch releases (e.g. upgrading from v4.2.2 to v4.2.3), begin at the [patch releases](#patch-releases) heading below. For minor or major releases, complete the entire checklist.
|
||||||
|
|
||||||
## Minor Version Releases
|
## Minor Version Releases
|
||||||
|
|
||||||
### Address Constrained Dependencies
|
### Address Constrained Dependencies
|
||||||
@ -43,9 +45,9 @@ Follow these instructions to perform a new installation of NetBox in a temporary
|
|||||||
|
|
||||||
Upgrading from a previous version typically involves database migrations, which must work without errors. Supported upgrade paths include from one minor version to another within the same major version (i.e. 4.0 to 4.1), as well as from the latest patch version of the previous minor version (i.e. 3.7 to 4.0 or to 4.1). Prior to release, test all these supported paths by loading demo data from the source version and performing a `./manage.py migrate`.
|
Upgrading from a previous version typically involves database migrations, which must work without errors. Supported upgrade paths include from one minor version to another within the same major version (i.e. 4.0 to 4.1), as well as from the latest patch version of the previous minor version (i.e. 3.7 to 4.0 or to 4.1). Prior to release, test all these supported paths by loading demo data from the source version and performing a `./manage.py migrate`.
|
||||||
|
|
||||||
### Merge the Release Branch
|
### Merge the `feature` Branch
|
||||||
|
|
||||||
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
|
Submit a pull request to merge the `feature` branch into the `main` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
|
||||||
|
|
||||||
### Rebuild Demo Data (After Release)
|
### Rebuild Demo Data (After Release)
|
||||||
|
|
||||||
@ -55,6 +57,15 @@ After the release of a new minor version, generate a new demo data snapshot comp
|
|||||||
|
|
||||||
## Patch Releases
|
## Patch Releases
|
||||||
|
|
||||||
|
### Create a Release Branch
|
||||||
|
|
||||||
|
Begin by creating a new branch (based off of `main`) to effect the release. This will comprise the changes listed below.
|
||||||
|
|
||||||
|
```
|
||||||
|
git checkout main
|
||||||
|
git checkout -B release-vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
### Notify netbox-docker Project of Any Relevant Changes
|
### Notify netbox-docker Project of Any Relevant Changes
|
||||||
|
|
||||||
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
|
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
|
||||||
@ -76,7 +87,20 @@ In cases where upgrading a dependency to its most recent release is breaking, it
|
|||||||
|
|
||||||
### Update UI Dependencies
|
### Update UI Dependencies
|
||||||
|
|
||||||
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
|
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn bundle
|
||||||
|
yarn run v1.22.19
|
||||||
|
$ node bundle.js
|
||||||
|
✅ Bundled source file 'styles/external.scss' to 'netbox-external.css'
|
||||||
|
✅ Bundled source file 'styles/netbox.scss' to 'netbox.css'
|
||||||
|
✅ Bundled source file 'styles/svg/rack_elevation.scss' to 'rack_elevation.css'
|
||||||
|
✅ Bundled source file 'styles/svg/cable_trace.scss' to 'cable_trace.css'
|
||||||
|
✅ Bundled source file 'index.ts' to 'netbox.js'
|
||||||
|
✅ Copied graphiql files
|
||||||
|
Done in 1.00s.
|
||||||
|
```
|
||||||
|
|
||||||
### Rebuild the Device Type Definition Schema
|
### Rebuild the Device Type Definition Schema
|
||||||
|
|
||||||
@ -107,29 +131,29 @@ Then, compile these portable (`.po`) files for use in the application:
|
|||||||
|
|
||||||
### Update Version and Changelog
|
### Update Version and Changelog
|
||||||
|
|
||||||
* Update the version and published date in `release.yaml` with the current version & date. Add a designation (e.g.g `beta1`) if applicable.
|
* Update the version number and date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
|
||||||
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
|
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
|
||||||
* Replace the "FUTURE" placeholder in the release notes with the current date.
|
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
|
||||||
|
|
||||||
Commit these changes to the `develop` branch and push upstream.
|
!!! tip
|
||||||
|
Put yourself in the shoes of the user when recording change notes. Focus on the effect that each change has for the end user, rather than the specific bits of code that were modified in a PR. Ensure that each message conveys meaning absent context of the initial feature request or bug report. Remember to include key words or phrases (such as exception names) that can be easily searched.
|
||||||
### Verify CI Build Status
|
|
||||||
|
|
||||||
Ensure that continuous integration testing on the `develop` branch is completing successfully. If it fails, take action to correct the failure before proceeding with the release.
|
|
||||||
|
|
||||||
### Submit a Pull Request
|
### Submit a Pull Request
|
||||||
|
|
||||||
Submit a pull request titled **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body.
|
Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** to merge the current release branch (e.g. `release-vX.Y.Z`) into `main`. Copy the documented release notes into the pull request's body.
|
||||||
|
|
||||||
Once CI has completed on the PR, merge it. This effects a new release in the `master` branch.
|
Once CI has completed and a colleague has reviewed the PR, merge it. This effects a new release in the `main` branch.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
To ensure a streamlined review process, the pull request for a release **must** be limited to the changes outlined in this document. A release PR must never include functional changes to the application: Any unrelated "cleanup" needs to be captured in a separate PR prior to the release being shipped.
|
||||||
|
|
||||||
### Create a New Release
|
### Create a New Release
|
||||||
|
|
||||||
Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.
|
Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.
|
||||||
|
|
||||||
* **Tag:** Current version (e.g. `v3.3.1`)
|
* **Tag:** Current version (e.g. `v4.2.1`)
|
||||||
* **Target:** `master`
|
* **Target:** `main`
|
||||||
* **Title:** Version and date (e.g. `v3.3.1 - 2022-08-25`)
|
* **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
|
||||||
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
|
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
|
||||||
|
|
||||||
Once created, the release will become available for users to install.
|
Once created, the release will become available for users to install.
|
||||||
|
@ -22,7 +22,7 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
|
|||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `ruff` manually, run:
|
The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style, and is run automatically by [pre-commit](./getting-started.md#5-install-pre-commit). To invoke `ruff` manually, run:
|
||||||
|
|
||||||
```
|
```
|
||||||
ruff check netbox/
|
ruff check netbox/
|
||||||
|
@ -14,10 +14,10 @@ To update the English `.po` file from which all translations are derived, use th
|
|||||||
./manage.py makemessages -l en -i "project-static/*"
|
./manage.py makemessages -l en -i "project-static/*"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
|
Then, commit the change and push to the `main` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/develop/.github/workflows/update-translation-strings.yml).
|
It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/main/.github/workflows/update-translation-strings.yml).
|
||||||
|
|
||||||
## Updating Translated Strings
|
## Updating Translated Strings
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ To download translated strings automatically, you'll need to:
|
|||||||
1. Install the [Transifex CLI client](https://github.com/transifex/cli)
|
1. Install the [Transifex CLI client](https://github.com/transifex/cli)
|
||||||
2. Generate a [Transifex API token](https://app.transifex.com/user/settings/api/)
|
2. Generate a [Transifex API token](https://app.transifex.com/user/settings/api/)
|
||||||
|
|
||||||
Once you have the client set up, run the following command:
|
Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
TX_TOKEN=$TOKEN tx pull
|
TX_TOKEN=$TOKEN tx pull
|
||||||
@ -46,6 +46,9 @@ Once retrieved, the updated strings need to be compiled into new `.mo` files so
|
|||||||
|
|
||||||
Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
|
Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
Run `git status` to check that both `*.mo` & `*.po` files have been updated as expected.
|
||||||
|
|
||||||
## Proposing New Languages
|
## Proposing New Languages
|
||||||
|
|
||||||
If you'd like to add support for a new language to NetBox, the first step is to [submit a GitHub issue](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+translation&projects=&template=translation.yaml) to capture the proposal. While we'd like to add as many languages as possible, we do need to limit the rate at which new languages are added. New languages will be selected according to community interest and the number of volunteers who sign up as translators.
|
If you'd like to add support for a new language to NetBox, the first step is to [submit a GitHub issue](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+translation&projects=&template=translation.yaml) to capture the proposal. While we'd like to add as many languages as possible, we do need to limit the rate at which new languages are added. New languages will be selected according to community interest and the number of volunteers who sign up as translators.
|
||||||
|
@ -46,7 +46,7 @@ Regions will always be listed alphabetically by name within each parent, and the
|
|||||||
|
|
||||||
Like regions, site groups can be arranged in a recursive hierarchy for grouping sites. However, whereas regions are intended for geographic organization, site groups may be used for functional grouping. For example, you might classify sites as corporate, branch, or customer sites in addition to where they are physically located.
|
Like regions, site groups can be arranged in a recursive hierarchy for grouping sites. However, whereas regions are intended for geographic organization, site groups may be used for functional grouping. For example, you might classify sites as corporate, branch, or customer sites in addition to where they are physically located.
|
||||||
|
|
||||||
The use of both regions and site groups affords to independent but complementary dimensions across which sites can be organized.
|
The use of both regions and site groups affords two independent but complementary dimensions across which sites can be organized.
|
||||||
|
|
||||||
## Sites
|
## Sites
|
||||||
|
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
|
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
|
||||||
|
|
||||||
!!! warning "PostgreSQL 12 or later required"
|
!!! warning "PostgreSQL 13 or later required"
|
||||||
NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported.
|
NetBox requires PostgreSQL 13 or later. Please note that MySQL and other relational databases are **not** supported.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ This section entails the installation and configuration of a local PostgreSQL da
|
|||||||
sudo systemctl enable --now postgresql
|
sudo systemctl enable --now postgresql
|
||||||
```
|
```
|
||||||
|
|
||||||
Before continuing, verify that you have installed PostgreSQL 12 or later:
|
Before continuing, verify that you have installed PostgreSQL 13 or later:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
psql -V
|
psql -V
|
||||||
@ -62,6 +62,9 @@ GRANT CREATE ON SCHEMA public TO netbox;
|
|||||||
!!! danger "Use a strong password"
|
!!! danger "Use a strong password"
|
||||||
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
|
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
|
||||||
|
|
||||||
|
!!! danger "Use UTF8 encoding"
|
||||||
|
Make sure that your database uses `UTF8` encoding (the default for new installations). Especially do not use `SQL_ASCII` encoding, as it can lead to unpredictable and unrecoverable errors. Enter `\l` to check your encoding.
|
||||||
|
|
||||||
Once complete, enter `\q` to exit the PostgreSQL shell.
|
Once complete, enter `\q` to exit the PostgreSQL shell.
|
||||||
|
|
||||||
## Verify Service Status
|
## Verify Service Status
|
||||||
|
@ -29,7 +29,7 @@ python3 -V
|
|||||||
|
|
||||||
## Download NetBox
|
## Download NetBox
|
||||||
|
|
||||||
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
|
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by checking out the latest release tag.
|
||||||
|
|
||||||
### Option A: Download a Release Archive
|
### Option A: Download a Release Archive
|
||||||
|
|
||||||
@ -67,16 +67,13 @@ If `git` is not already installed, install it:
|
|||||||
sudo yum install -y git
|
sudo yum install -y git
|
||||||
```
|
```
|
||||||
|
|
||||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
|
Next, clone the git repository:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
sudo git clone -b master --depth 1 https://github.com/netbox-community/netbox.git .
|
sudo git clone https://github.com/netbox-community/netbox.git .
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
This command should generate output similar to the following:
|
||||||
The `git clone` command above utilizes a "shallow clone" to retrieve only the most recent commit. If you need to download the entire history, omit the `--depth 1` argument.
|
|
||||||
|
|
||||||
The `git clone` command should generate output similar to the following:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Cloning into '.'...
|
Cloning into '.'...
|
||||||
@ -88,8 +85,13 @@ Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
|
|||||||
Resolving deltas: 100% (148/148), done.
|
Resolving deltas: 100% (148/148), done.
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
|
||||||
Installation via git also allows you to easily try out different versions of NetBox. To check out a [specific NetBox release](https://github.com/netbox-community/netbox/releases), use the `git checkout` command with the desired release tag. For example, `git checkout v3.0.8`.
|
|
||||||
|
```
|
||||||
|
sudo git checkout vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Using this installation method enables easy upgrades in the future by simply checking out the latest release tag.
|
||||||
|
|
||||||
## Create the NetBox System User
|
## Create the NetBox System User
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ The following sections detail how to set up a new instance of NetBox:
|
|||||||
| Dependency | Supported Versions |
|
| Dependency | Supported Versions |
|
||||||
|------------|--------------------|
|
|------------|--------------------|
|
||||||
| Python | 3.10, 3.11, 3.12 |
|
| Python | 3.10, 3.11, 3.12 |
|
||||||
| PostgreSQL | 12+ |
|
| PostgreSQL | 13+ |
|
||||||
| Redis | 4.0+ |
|
| Redis | 4.0+ |
|
||||||
|
|
||||||
Below is a simplified overview of the NetBox application stack for reference:
|
Below is a simplified overview of the NetBox application stack for reference:
|
||||||
|
@ -20,15 +20,15 @@ NetBox requires the following dependencies:
|
|||||||
| Dependency | Supported Versions |
|
| Dependency | Supported Versions |
|
||||||
|------------|--------------------|
|
|------------|--------------------|
|
||||||
| Python | 3.10, 3.11, 3.12 |
|
| Python | 3.10, 3.11, 3.12 |
|
||||||
| PostgreSQL | 12+ |
|
| PostgreSQL | 13+ |
|
||||||
| Redis | 4.0+ |
|
| Redis | 4.0+ |
|
||||||
|
|
||||||
## 3. Install the Latest Release
|
## 3. Install the Latest Release
|
||||||
|
|
||||||
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
|
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
Use the same method as you used to install NetBox originally
|
Use the same method as you used to install NetBox originally.
|
||||||
|
|
||||||
If you are not sure how NetBox was installed originally, check with this command:
|
If you are not sure how NetBox was installed originally, check with this command:
|
||||||
|
|
||||||
@ -36,10 +36,7 @@ If you are not sure how NetBox was installed originally, check with this command
|
|||||||
ls -ld /opt/netbox /opt/netbox/.git
|
ls -ld /opt/netbox /opt/netbox/.git
|
||||||
```
|
```
|
||||||
|
|
||||||
If NetBox was installed from a release package, then `/opt/netbox` will be a
|
If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories.
|
||||||
symlink pointing to the current version, and `/opt/netbox/.git` will not
|
|
||||||
exist. If it was installed from git, then `/opt/netbox` and
|
|
||||||
`/opt/netbox/.git` will both exist as normal directories.
|
|
||||||
|
|
||||||
### Option A: Download a Release
|
### Option A: Download a Release
|
||||||
|
|
||||||
@ -84,20 +81,20 @@ If you followed the original installation guide to set up gunicorn, be sure to c
|
|||||||
sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
|
sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option B: Clone the Git Repository
|
### Option B: Check Out a Git Release
|
||||||
|
|
||||||
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
|
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following `git` commands:
|
||||||
|
|
||||||
```no-highlight
|
```
|
||||||
cd /opt/netbox
|
sudo git fetch --tags
|
||||||
sudo git checkout master
|
git describe --tags $(git rev-list --tags --max-count=1)
|
||||||
sudo git pull origin master
|
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Checking out an older release"
|
Check out the desired release by specifying its tag:
|
||||||
If you need to upgrade to an older version rather than the current stable release, you can check out any valid [git tag](https://github.com/netbox-community/netbox/tags), each of which represents a release. For example, to checkout the code for NetBox v2.11.11, do:
|
|
||||||
|
|
||||||
sudo git checkout v2.11.11
|
```
|
||||||
|
sudo git checkout v4.2.0
|
||||||
|
```
|
||||||
|
|
||||||
## 4. Run the Upgrade Script
|
## 4. Run the Upgrade Script
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# GraphQL API Overview
|
# GraphQL API Overview
|
||||||
|
|
||||||
NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by [Strawberry Django](https://strawberry-graphql.github.io/strawberry-django/).
|
NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by [Strawberry Django](https://strawberry.rocks/).
|
||||||
|
|
||||||
## Queries
|
## Queries
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ NetBox provides both a singular and plural query field for each object type:
|
|||||||
|
|
||||||
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
|
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
|
||||||
|
|
||||||
For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry-graphql.github.io/strawberry-django/guide/filters/).
|
For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry.rocks/docs/django/guide/filters).
|
||||||
|
|
||||||
## Filtering
|
## Filtering
|
||||||
|
|
||||||
@ -60,6 +60,7 @@ query {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
In addition, filtering can be done on list of related objects as shown in the following query:
|
In addition, filtering can be done on list of related objects as shown in the following query:
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -98,8 +99,8 @@ Certain queries can return multiple types of objects, for example cable terminat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
|
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## What is a REST API?
|
## What is a REST API?
|
||||||
|
|
||||||
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/REST). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
||||||
|
|
||||||
* `GET`: Retrieve an object or list of objects
|
* `GET`: Retrieve an object or list of objects
|
||||||
* `POST`: Create an object
|
* `POST`: Create an object
|
||||||
|
@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
|||||||
| HTTP service | nginx or Apache |
|
| HTTP service | nginx or Apache |
|
||||||
| WSGI service | gunicorn or uWSGI |
|
| WSGI service | gunicorn or uWSGI |
|
||||||
| Application | Django/Python |
|
| Application | Django/Python |
|
||||||
| Database | PostgreSQL 12+ |
|
| Database | PostgreSQL 13+ |
|
||||||
| Task queuing | Redis/django-rq |
|
| Task queuing | Redis/django-rq |
|
||||||
|
@ -204,6 +204,7 @@ To ease development, it is recommended to go ahead and install the plugin at thi
|
|||||||
```no-highlight
|
```no-highlight
|
||||||
$ pip install -e .
|
$ pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
|
More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
|
||||||
|
|
||||||
## Configure NetBox
|
## Configure NetBox
|
||||||
|
@ -150,5 +150,5 @@ The [NAPALM automation](https://github.com/napalm-automation/napalm) library pro
|
|||||||
* Modified the interface serializer to include three discrete fields relating to connections: `is_connected` (boolean), `interface_connection`, and `circuit_termination`
|
* Modified the interface serializer to include three discrete fields relating to connections: `is_connected` (boolean), `interface_connection`, and `circuit_termination`
|
||||||
* Added two new fields to the inventory item serializer: `asset_tag` and `description`
|
* Added two new fields to the inventory item serializer: `asset_tag` and `description`
|
||||||
* Added "wireless" to interface type filter (in addition to physical, virtual, and LAG)
|
* Added "wireless" to interface type filter (in addition to physical, virtual, and LAG)
|
||||||
* Added a new endpoint at /api/ipam/prefixes/<pk>/available-ips/ to retrieve or create available IPs within a prefix
|
* Added a new endpoint at /api/ipam/prefixes/<pk\>/available-ips/ to retrieve or create available IPs within a prefix
|
||||||
* Extended `parent_device` on DeviceSerializer to include the `url` and `display_name` of the parent Device, and the `url` of the DeviceBay
|
* Extended `parent_device` on DeviceSerializer to include the `url` and `display_name` of the parent Device, and the `url` of the DeviceBay
|
||||||
|
@ -1,13 +1,122 @@
|
|||||||
# NetBox v4.2
|
# NetBox v4.2
|
||||||
|
|
||||||
## v4.2-beta1 (2024-12-02)
|
## v4.2.5 (2025-03-06)
|
||||||
|
|
||||||
!!! danger "Not for Production Use"
|
### Enhancements
|
||||||
This is a beta release of NetBox intended for testing and evaluation. **Do not use this software in production.** Also be aware that no upgrade path is provided to future releases.
|
|
||||||
|
* [#17357](https://github.com/netbox-community/netbox/issues/17357) - Use VirtualChassis name as fallback for unnamed devices
|
||||||
|
* [#17542](https://github.com/netbox-community/netbox/issues/17542) - Add contact assignments to VPN tunnels
|
||||||
|
* [#17944](https://github.com/netbox-community/netbox/issues/17944) - Allow script inputs to be filtered on ObjectVar and MultiObjectVar selections
|
||||||
|
* [#18024](https://github.com/netbox-community/netbox/issues/18024) - Add permalink URL pattern to match a custom script by module and class name
|
||||||
|
* [#18095](https://github.com/netbox-community/netbox/issues/18095) - Ensure contacts are shown on children of objects with contacts
|
||||||
|
* [#18141](https://github.com/netbox-community/netbox/issues/18141) - Support "Quick Add" for plugins
|
||||||
|
* [#18403](https://github.com/netbox-community/netbox/issues/18403) - Improve performance of job list views
|
||||||
|
* [#18693](https://github.com/netbox-community/netbox/issues/18693) - Support setting VLAN translation on bulk edit of interfaces
|
||||||
|
* [#18772](https://github.com/netbox-community/netbox/issues/18772) - Add "type" filter for virtual circuits
|
||||||
|
* [#18774](https://github.com/netbox-community/netbox/issues/18774) - Add tooltip preview of tag descriptions when hovering over tags
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#15016](https://github.com/netbox-community/netbox/issues/15016) - Prevent AssertionError when adding multiple devices "mid-span" in a cable trace
|
||||||
|
* [#15924](https://github.com/netbox-community/netbox/issues/15924) - Prevent setting tagged VLANs on interfaces with mode: tagged-all
|
||||||
|
* [#17488](https://github.com/netbox-community/netbox/issues/17488) - Ensure VLANGroup.vid_ranges shows up in API results
|
||||||
|
* [#17709](https://github.com/netbox-community/netbox/issues/17709) - Allow primary key for nested models in OpenAPI request schemas
|
||||||
|
* [#17796](https://github.com/netbox-community/netbox/issues/17796) - Fix IndexError on "Create & Add Another" operation on custom field choices
|
||||||
|
* [#18605](https://github.com/netbox-community/netbox/issues/18605) - Limit VLAN selection dropdown to choices appropriate to site
|
||||||
|
* [#18722](https://github.com/netbox-community/netbox/issues/18722) - Improve UI feedback on failed script execution
|
||||||
|
* [#18729](https://github.com/netbox-community/netbox/issues/18729) - Fix unpredictable ordering on querysets with annotations/groupings
|
||||||
|
* [#18753](https://github.com/netbox-community/netbox/issues/18753) - Prevent webhooks from being triggered on a script dry-run
|
||||||
|
* [#18758](https://github.com/netbox-community/netbox/issues/18758) - Fix FieldError when sorting by account count field in providers list
|
||||||
|
* [#18768](https://github.com/netbox-community/netbox/issues/18768) - Fix removing a secondary MAC address from an interface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.2.4 (2025-02-21)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#17309](https://github.com/netbox-community/netbox/issues/17309) - Omit empty counts in related object tables
|
||||||
|
* [#18277](https://github.com/netbox-community/netbox/issues/18277) - Improve multi-table inheritance in serialization of change-logged models
|
||||||
|
* [#18286](https://github.com/netbox-community/netbox/issues/18286) - Add more job duration choices
|
||||||
|
* [#18357](https://github.com/netbox-community/netbox/issues/18357) - Display author name in plugin list for locally installed plugins
|
||||||
|
* [#18408](https://github.com/netbox-community/netbox/issues/18408) - Add Paused status for virtual machines
|
||||||
|
* [#18584](https://github.com/netbox-community/netbox/issues/18584) - Add rack type column to manufacturer list
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#17436](https://github.com/netbox-community/netbox/issues/17436) - Fix {module} replacement in module bays
|
||||||
|
* [#18013](https://github.com/netbox-community/netbox/issues/18013) - Limit object type to selected object in change log filter
|
||||||
|
* [#18241](https://github.com/netbox-community/netbox/issues/18241) - Default logging level of custom scripts changed to INFO
|
||||||
|
* [#18247](https://github.com/netbox-community/netbox/issues/18247) - Fix visibility of disabled cable paths in dark mode
|
||||||
|
* [#18480](https://github.com/netbox-community/netbox/issues/18480) - Clean data passed to script in runscript command
|
||||||
|
* [#18555](https://github.com/netbox-community/netbox/issues/18555) - Add default get_absolute_url method to plugin models
|
||||||
|
* [#18585](https://github.com/netbox-community/netbox/issues/18585) - Fix filtering circuits by location
|
||||||
|
* [#18593](https://github.com/netbox-community/netbox/issues/18593) - Fix "Create & Add Another" IP Address workflow
|
||||||
|
* [#18594](https://github.com/netbox-community/netbox/issues/18594) - Enable sorting by ASN count on site and provider lists
|
||||||
|
* [#18619](https://github.com/netbox-community/netbox/issues/18619) - Ensure shift-click selection selects only visible list items
|
||||||
|
* [#18674](https://github.com/netbox-community/netbox/issues/18674) - Preserve form values when selecting speed on circuit termination
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.2.3 (2025-02-04)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#18518](https://github.com/netbox-community/netbox/issues/18518) - Add a "hostname" `<meta>` tag to the page header
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#18497](https://github.com/netbox-community/netbox/issues/18497) - Fix unhandled `FieldDoesNotExist` exception when search results include virtual circuit
|
||||||
|
* [#18433](https://github.com/netbox-community/netbox/issues/18433) - Fix MAC address not shown as "primary for interface" in MAC address detail view
|
||||||
|
* [#18154](https://github.com/netbox-community/netbox/issues/18154) - Allow anonymous users to change default table preferences
|
||||||
|
* [#18515](https://github.com/netbox-community/netbox/issues/18515) - Fix Django `collectstatic` management command in debug mode with Redis not running
|
||||||
|
* [#18456](https://github.com/netbox-community/netbox/issues/18456) - Avoid duplicate MAC Address column in interface tables
|
||||||
|
* [#18447](https://github.com/netbox-community/netbox/issues/18447) - Fix `FieldError` exception when sorting interface tables on MAC Address columns
|
||||||
|
* [#18438](https://github.com/netbox-community/netbox/issues/18438) - Improve performance in IPAM migration `0072_prefix_cached_relations` when upgrading from v4.1 or earlier
|
||||||
|
* [#18436](https://github.com/netbox-community/netbox/issues/18436) - Reset primary MAC address when unassigning MAC address from interface
|
||||||
|
* [#18181](https://github.com/netbox-community/netbox/issues/18181) - Fix "Create & Add Another" workflow when adding IP addresses to interfaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.2.2 (2025-01-17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#18336](https://github.com/netbox-community/netbox/issues/18336) - Validate new rack height against installed devices when changing a rack's type
|
||||||
|
* [#18350](https://github.com/netbox-community/netbox/issues/18350) - Fix `FieldDoesNotExist` exception when global search results include a circuit termination
|
||||||
|
* [#18353](https://github.com/netbox-community/netbox/issues/18353) - Disable fetching of plugin catalog data when `ISOLATED_DEPLOYMENT` is enabled
|
||||||
|
* [#18362](https://github.com/netbox-community/netbox/issues/18362) - Avoid transmitting census data on every worker restart
|
||||||
|
* [#18363](https://github.com/netbox-community/netbox/issues/18363) - Fix support for assigning a MAC address to an interface via the REST API
|
||||||
|
* [#18368](https://github.com/netbox-community/netbox/issues/18368) - Restore missing attributes from REST API serializer for MAC addresses (`tags`, `created`, `last_updated`, and custom fields)
|
||||||
|
* [#18369](https://github.com/netbox-community/netbox/issues/18369) - Fix `TypeError` exception when rendering the system configuration view with one or more custom classes defined under `PROTECTION_RULES`
|
||||||
|
* [#18373](https://github.com/netbox-community/netbox/issues/18373) - Fix `AttributeError` exception when attempting to assign host devices to a cluster
|
||||||
|
* [#18376](https://github.com/netbox-community/netbox/issues/18376) - Fix the display of tagged VLANs in interfaces list for Q-in-Q interfaces
|
||||||
|
* [#18379](https://github.com/netbox-community/netbox/issues/18379) - Ensure RSS feed dashboard widget content is sanitized
|
||||||
|
* [#18392](https://github.com/netbox-community/netbox/issues/18392) - Virtual machines should not inherit config contexts assigned to locations
|
||||||
|
* [#18400](https://github.com/netbox-community/netbox/issues/18400) - Fix support for `STORAGE_BACKEND` configuration parameter
|
||||||
|
* [#18406](https://github.com/netbox-community/netbox/issues/18406) - Scope column headers in object lists should not be orderable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.2.1 (2025-01-08)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#18282](https://github.com/netbox-community/netbox/issues/18282) - Fix ordering of prefixes list by assigned VLAN
|
||||||
|
* [#18314](https://github.com/netbox-community/netbox/issues/18314) - Fix KeyError exception when rendering pre-saved dashboard (`requires_internet` missing)
|
||||||
|
* [#18316](https://github.com/netbox-community/netbox/issues/18316) - Fix AttributeError exception when global search results include prefixes and/or clusters
|
||||||
|
* [#18318](https://github.com/netbox-community/netbox/issues/18318) - Correct navigation breadcrumbs for module type UI view
|
||||||
|
* [#18324](https://github.com/netbox-community/netbox/issues/18324) - Correct filtering for certain related object listings
|
||||||
|
* [#18329](https://github.com/netbox-community/netbox/issues/18329) - Address upstream bug in GraphQL API where only one primary IP address is returned within a device/VM list
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.2.0 (2025-01-06)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
* Support for the Django admin UI has been completely removed. (The Django admin UI was disabled by default in NetBox v4.0.)
|
* Support for the Django admin UI has been completely removed. (The Django admin UI was disabled by default in NetBox v4.0.)
|
||||||
|
* This release drops support for PostgreSQL 12. PostgreSQL 13 or later is required to run this release.
|
||||||
* NetBox has adopted collation-based natural ordering for many models. This may alter the order in which some objects are listed by default.
|
* NetBox has adopted collation-based natural ordering for many models. This may alter the order in which some objects are listed by default.
|
||||||
* Automatic redirects from pre-v4.1 UI views for virtual disks have been removed.
|
* Automatic redirects from pre-v4.1 UI views for virtual disks have been removed.
|
||||||
* The `site` and `provider_network` foreign key fields on `circuits.CircuitTermination` have been replaced by the `termination` generic foreign key.
|
* The `site` and `provider_network` foreign key fields on `circuits.CircuitTermination` have been replaced by the `termination` generic foreign key.
|
||||||
|
10
mkdocs.yml
10
mkdocs.yml
@ -28,12 +28,7 @@ plugins:
|
|||||||
- mkdocstrings:
|
- mkdocstrings:
|
||||||
handlers:
|
handlers:
|
||||||
python:
|
python:
|
||||||
setup_commands:
|
paths: ["netbox"]
|
||||||
- import os
|
|
||||||
- import django
|
|
||||||
- os.chdir('netbox/')
|
|
||||||
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
|
||||||
- django.setup()
|
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
members_order: source
|
members_order: source
|
||||||
@ -64,6 +59,8 @@ markdown_extensions:
|
|||||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
- pymdownx.tabbed:
|
- pymdownx.tabbed:
|
||||||
alternate_style: true
|
alternate_style: true
|
||||||
|
not_in_nav: |
|
||||||
|
/index.md
|
||||||
nav:
|
nav:
|
||||||
- Introduction: 'introduction.md'
|
- Introduction: 'introduction.md'
|
||||||
- Features:
|
- Features:
|
||||||
@ -176,6 +173,7 @@ nav:
|
|||||||
- Provider Network: 'models/circuits/providernetwork.md'
|
- Provider Network: 'models/circuits/providernetwork.md'
|
||||||
- Virtual Circuit: 'models/circuits/virtualcircuit.md'
|
- Virtual Circuit: 'models/circuits/virtualcircuit.md'
|
||||||
- Virtual Circuit Termination: 'models/circuits/virtualcircuittermination.md'
|
- Virtual Circuit Termination: 'models/circuits/virtualcircuittermination.md'
|
||||||
|
- Virtual Circuit Type: 'models/circuits/virtualcircuittype.md'
|
||||||
- Core:
|
- Core:
|
||||||
- DataFile: 'models/core/datafile.md'
|
- DataFile: 'models/core/datafile.md'
|
||||||
- DataSource: 'models/core/datasource.md'
|
- DataSource: 'models/core/datasource.md'
|
||||||
|
@ -95,7 +95,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProviderAccountFilterSet(NetBoxModelFilterSet):
|
class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Provider.objects.all(),
|
queryset=Provider.objects.all(),
|
||||||
label=_('Provider (ID)'),
|
label=_('Provider (ID)'),
|
||||||
@ -234,6 +234,11 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label=_('Site (slug)'),
|
label=_('Site (slug)'),
|
||||||
)
|
)
|
||||||
|
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='terminations___location',
|
||||||
|
label=_('Location (ID)'),
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
)
|
||||||
termination_a_id = django_filters.ModelMultipleChoiceFilter(
|
termination_a_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=CircuitTermination.objects.all(),
|
queryset=CircuitTermination.objects.all(),
|
||||||
label=_('Termination A (ID)'),
|
label=_('Termination A (ID)'),
|
||||||
|
@ -66,11 +66,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
|
class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||||
model = ProviderAccount
|
model = ProviderAccount
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('provider_id', 'account', name=_('Attributes')),
|
FieldSet('provider_id', 'account', name=_('Attributes')),
|
||||||
|
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||||
)
|
)
|
||||||
provider_id = DynamicModelMultipleChoiceField(
|
provider_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Provider.objects.all(),
|
queryset=Provider.objects.all(),
|
||||||
@ -126,7 +127,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
|
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
|
||||||
name=_('Attributes')
|
name=_('Attributes')
|
||||||
),
|
),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||||
)
|
)
|
||||||
@ -181,6 +182,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
},
|
},
|
||||||
label=_('Site')
|
label=_('Site')
|
||||||
)
|
)
|
||||||
|
location_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Location')
|
||||||
|
)
|
||||||
install_date = forms.DateField(
|
install_date = forms.DateField(
|
||||||
label=_('Install date'),
|
label=_('Install date'),
|
||||||
required=False,
|
required=False,
|
||||||
@ -322,7 +328,7 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
||||||
FieldSet('type', 'status', name=_('Attributes')),
|
FieldSet('type_id', 'status', name=_('Attributes')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
)
|
)
|
||||||
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
|
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
# Generated by Django 5.0.9 on 2024-10-21 17:34
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ def populate_denormalized_fields(apps, schema_editor):
|
|||||||
termination._site_id = termination.site_id
|
termination._site_id = termination.site_id
|
||||||
# Note: Location cannot be set prior to migration
|
# Note: Location cannot be set prior to migration
|
||||||
|
|
||||||
CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'])
|
CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'], batch_size=100)
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
@ -349,9 +349,8 @@ class CircuitTermination(
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Must define either site *or* provider network
|
|
||||||
if self.termination is None:
|
if self.termination is None:
|
||||||
raise ValidationError(_("A circuit termination must attach to termination."))
|
raise ValidationError(_("A circuit termination must attach to a terminating object."))
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Cache objects associated with the terminating object (for filtering)
|
# Cache objects associated with the terminating object (for filtering)
|
||||||
|
@ -34,7 +34,7 @@ class CircuitTerminationIndex(SearchIndex):
|
|||||||
('port_speed', 2000),
|
('port_speed', 2000),
|
||||||
('upstream_speed', 2000),
|
('upstream_speed', 2000),
|
||||||
)
|
)
|
||||||
display_attrs = ('circuit', 'site', 'provider_network', 'description')
|
display_attrs = ('circuit', 'termination', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -90,7 +90,7 @@ class VirtualCircuitIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
display_attrs = ('provider', 'provider_network', 'provider_account', 'status', 'tenant', 'description')
|
display_attrs = ('provider_network', 'provider_account', 'status', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
|
@ -111,7 +111,7 @@ class CircuitTerminationTable(NetBoxTable):
|
|||||||
provider = tables.Column(
|
provider = tables.Column(
|
||||||
verbose_name=_('Provider'),
|
verbose_name=_('Provider'),
|
||||||
linkify=True,
|
linkify=True,
|
||||||
accessor='circuit.provider'
|
accessor='circuit__provider'
|
||||||
)
|
)
|
||||||
term_side = tables.Column(
|
term_side = tables.Column(
|
||||||
verbose_name=_('Side')
|
verbose_name=_('Side')
|
||||||
|
@ -23,7 +23,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Accounts')
|
verbose_name=_('Accounts')
|
||||||
)
|
)
|
||||||
account_count = columns.LinkedCountColumn(
|
account_count = columns.LinkedCountColumn(
|
||||||
accessor=tables.A('accounts__count'),
|
|
||||||
viewname='circuits:provideraccount_list',
|
viewname='circuits:provideraccount_list',
|
||||||
url_params={'provider_id': 'pk'},
|
url_params={'provider_id': 'pk'},
|
||||||
verbose_name=_('Account Count')
|
verbose_name=_('Account Count')
|
||||||
@ -33,7 +32,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('ASNs')
|
verbose_name=_('ASNs')
|
||||||
)
|
)
|
||||||
asn_count = columns.LinkedCountColumn(
|
asn_count = columns.LinkedCountColumn(
|
||||||
accessor=tables.A('asns__count'),
|
|
||||||
viewname='ipam:asn_list',
|
viewname='ipam:asn_list',
|
||||||
url_params={'provider_id': 'pk'},
|
url_params={'provider_id': 'pk'},
|
||||||
verbose_name=_('ASN Count')
|
verbose_name=_('ASN Count')
|
||||||
|
@ -3,8 +3,10 @@ from django.test import TestCase
|
|||||||
from circuits.choices import *
|
from circuits.choices import *
|
||||||
from circuits.filtersets import *
|
from circuits.filtersets import *
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
from dcim.choices import InterfaceTypeChoices
|
from dcim.choices import InterfaceTypeChoices, LocationStatusChoices
|
||||||
from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
|
from dcim.models import (
|
||||||
|
Cable, Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
|
||||||
|
)
|
||||||
from ipam.models import ASN, RIR
|
from ipam.models import ASN, RIR
|
||||||
from netbox.choices import DistanceUnitChoices
|
from netbox.choices import DistanceUnitChoices
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
@ -225,6 +227,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
)
|
)
|
||||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||||
|
|
||||||
|
locations = (
|
||||||
|
Location.objects.create(
|
||||||
|
site=sites[0], name='Test Location 1', slug='test-location-1',
|
||||||
|
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||||
|
),
|
||||||
|
Location.objects.create(
|
||||||
|
site=sites[1], name='Test Location 2', slug='test-location-2',
|
||||||
|
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
circuits = (
|
circuits = (
|
||||||
Circuit(
|
Circuit(
|
||||||
provider=providers[0],
|
provider=providers[0],
|
||||||
@ -305,7 +318,9 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
|
|
||||||
circuit_terminations = ((
|
circuit_terminations = ((
|
||||||
CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
|
CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
|
||||||
|
CircuitTermination(circuit=circuits[0], termination=locations[0], term_side='Z'),
|
||||||
CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
|
CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
|
||||||
|
CircuitTermination(circuit=circuits[1], termination=locations[1], term_side='Z'),
|
||||||
CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
|
CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
|
||||||
CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
|
CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
|
||||||
CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
|
CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
|
||||||
@ -395,6 +410,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_location(self):
|
||||||
|
location_ids = Location.objects.values_list('id', flat=True)[:2]
|
||||||
|
params = {'location_id': location_ids}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_tenant(self):
|
def test_tenant(self):
|
||||||
tenants = Tenant.objects.all()[:2]
|
tenants = Tenant.objects.all()[:2]
|
||||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||||
|
@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from dcim.views import PathTraceView
|
from dcim.views import PathTraceView
|
||||||
|
from ipam.models import ASN
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from tenancy.views import ObjectContactsView
|
from tenancy.views import ObjectContactsView
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
@ -20,7 +21,9 @@ from .models import *
|
|||||||
@register_model_view(Provider, 'list', path='', detail=False)
|
@register_model_view(Provider, 'list', path='', detail=False)
|
||||||
class ProviderListView(generic.ObjectListView):
|
class ProviderListView(generic.ObjectListView):
|
||||||
queryset = Provider.objects.annotate(
|
queryset = Provider.objects.annotate(
|
||||||
count_circuits=count_related(Circuit, 'provider')
|
count_circuits=count_related(Circuit, 'provider'),
|
||||||
|
asn_count=count_related(ASN, 'providers'),
|
||||||
|
account_count=count_related(ProviderAccount, 'provider'),
|
||||||
)
|
)
|
||||||
filterset = filtersets.ProviderFilterSet
|
filterset = filtersets.ProviderFilterSet
|
||||||
filterset_form = forms.ProviderFilterForm
|
filterset_form = forms.ProviderFilterForm
|
||||||
|
@ -2,12 +2,13 @@ import re
|
|||||||
import typing
|
import typing
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
from drf_spectacular.extensions import OpenApiSerializerFieldExtension, OpenApiSerializerExtension, _SchemaType
|
||||||
from drf_spectacular.openapi import AutoSchema
|
from drf_spectacular.openapi import AutoSchema
|
||||||
from drf_spectacular.plumbing import (
|
from drf_spectacular.plumbing import (
|
||||||
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
||||||
)
|
)
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import Direction
|
||||||
|
|
||||||
from netbox.api.fields import ChoiceField
|
from netbox.api.fields import ChoiceField
|
||||||
from netbox.api.serializers import WritableNestedSerializer
|
from netbox.api.serializers import WritableNestedSerializer
|
||||||
@ -277,3 +278,40 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
|
|||||||
return component.ref if component else None
|
return component.ref if component else None
|
||||||
else:
|
else:
|
||||||
return build_basic_type(OpenApiTypes.INT)
|
return build_basic_type(OpenApiTypes.INT)
|
||||||
|
|
||||||
|
|
||||||
|
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
|
||||||
|
target_class = 'netbox.api.fields.IntegerRangeSerializer'
|
||||||
|
|
||||||
|
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
|
||||||
|
return {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {
|
||||||
|
'type': 'integer',
|
||||||
|
},
|
||||||
|
'minItems': 2,
|
||||||
|
'maxItems': 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Nested models can be passed by ID in requests
|
||||||
|
# The logic for this is handled in `BaseModelSerializer.to_internal_value`
|
||||||
|
class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension):
|
||||||
|
target_class = 'netbox.api.serializers.BaseModelSerializer'
|
||||||
|
match_subclasses = True
|
||||||
|
|
||||||
|
def map_serializer_field(self, auto_schema, direction):
|
||||||
|
schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True)
|
||||||
|
if schema is None:
|
||||||
|
return schema
|
||||||
|
if direction == 'request' and self.target.nested:
|
||||||
|
return {
|
||||||
|
'oneOf': [
|
||||||
|
build_basic_type(OpenApiTypes.INT),
|
||||||
|
schema,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
@ -28,4 +28,7 @@ class CoreConfig(AppConfig):
|
|||||||
|
|
||||||
# Clear Redis cache on startup in development mode
|
# Clear Redis cache on startup in development mode
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
cache.clear()
|
try:
|
||||||
|
cache.clear()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
@ -81,8 +81,10 @@ class JobIntervalChoices(ChoiceSet):
|
|||||||
CHOICES = (
|
CHOICES = (
|
||||||
(INTERVAL_MINUTELY, _('Minutely')),
|
(INTERVAL_MINUTELY, _('Minutely')),
|
||||||
(INTERVAL_HOURLY, _('Hourly')),
|
(INTERVAL_HOURLY, _('Hourly')),
|
||||||
|
(INTERVAL_HOURLY * 12, _('12 hours')),
|
||||||
(INTERVAL_DAILY, _('Daily')),
|
(INTERVAL_DAILY, _('Daily')),
|
||||||
(INTERVAL_WEEKLY, _('Weekly')),
|
(INTERVAL_WEEKLY, _('Weekly')),
|
||||||
|
(INTERVAL_DAILY * 30, _('30 days')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -62,6 +62,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
|
|||||||
|
|
||||||
|
|
||||||
class JobFilterForm(SavedFiltersMixin, FilterForm):
|
class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = Job
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('object_type', 'status', name=_('Attributes')),
|
FieldSet('object_type', 'status', name=_('Attributes')),
|
||||||
@ -162,6 +163,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = ConfigRevision
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
)
|
)
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
|
||||||
from netbox.jobs import JobRunner
|
from django.conf import settings
|
||||||
|
from netbox.jobs import JobRunner, system_job
|
||||||
from netbox.search.backends import search_backend
|
from netbox.search.backends import search_backend
|
||||||
from .choices import DataSourceStatusChoices
|
from .choices import DataSourceStatusChoices, JobIntervalChoices
|
||||||
from .exceptions import SyncError
|
from .exceptions import SyncError
|
||||||
from .models import DataSource
|
from .models import DataSource
|
||||||
|
|
||||||
@ -31,3 +34,44 @@ class SyncDataSourceJob(JobRunner):
|
|||||||
if type(e) is SyncError:
|
if type(e) is SyncError:
|
||||||
logging.error(e)
|
logging.error(e)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
|
||||||
|
class SystemHousekeepingJob(JobRunner):
|
||||||
|
"""
|
||||||
|
Perform daily system housekeeping functions.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
name = "System Housekeeping"
|
||||||
|
|
||||||
|
def run(self, *args, **kwargs):
|
||||||
|
# Skip if running in development or test mode
|
||||||
|
if settings.DEBUG or 'test' in sys.argv:
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: Migrate other housekeeping functions from the `housekeeping` management command.
|
||||||
|
self.send_census_report()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_census_report():
|
||||||
|
"""
|
||||||
|
Send a census report (if enabled).
|
||||||
|
"""
|
||||||
|
# Skip if census reporting is disabled
|
||||||
|
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
census_data = {
|
||||||
|
'version': settings.RELEASE.full_version,
|
||||||
|
'python_version': sys.version.split()[0],
|
||||||
|
'deployment_id': settings.DEPLOYMENT_ID,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
requests.get(
|
||||||
|
url=settings.CENSUS_URL,
|
||||||
|
params=census_data,
|
||||||
|
timeout=3,
|
||||||
|
proxies=settings.HTTP_PROXIES
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
pass
|
||||||
|
@ -80,6 +80,13 @@ def get_local_plugins(plugins=None):
|
|||||||
plugin = importlib.import_module(plugin_name)
|
plugin = importlib.import_module(plugin_name)
|
||||||
plugin_config: PluginConfig = plugin.config
|
plugin_config: PluginConfig = plugin.config
|
||||||
|
|
||||||
|
if plugin_config.author:
|
||||||
|
author = PluginAuthor(
|
||||||
|
name=plugin_config.author,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
author = None
|
||||||
|
|
||||||
local_plugins[plugin_config.name] = Plugin(
|
local_plugins[plugin_config.name] = Plugin(
|
||||||
config_name=plugin_config.name,
|
config_name=plugin_config.name,
|
||||||
title_short=plugin_config.verbose_name,
|
title_short=plugin_config.verbose_name,
|
||||||
@ -88,6 +95,7 @@ def get_local_plugins(plugins=None):
|
|||||||
description_short=plugin_config.description,
|
description_short=plugin_config.description,
|
||||||
is_local=True,
|
is_local=True,
|
||||||
is_installed=True,
|
is_installed=True,
|
||||||
|
author=author,
|
||||||
installed_version=plugin_config.version,
|
installed_version=plugin_config.version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -165,7 +165,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
@register_model_view(Job, 'list', path='', detail=False)
|
@register_model_view(Job, 'list', path='', detail=False)
|
||||||
class JobListView(generic.ObjectListView):
|
class JobListView(generic.ObjectListView):
|
||||||
queryset = Job.objects.all()
|
queryset = Job.objects.defer('data')
|
||||||
filterset = filtersets.JobFilterSet
|
filterset = filtersets.JobFilterSet
|
||||||
filterset_form = forms.JobFilterForm
|
filterset_form = forms.JobFilterForm
|
||||||
table = tables.JobTable
|
table = tables.JobTable
|
||||||
@ -182,12 +182,12 @@ class JobView(generic.ObjectView):
|
|||||||
|
|
||||||
@register_model_view(Job, 'delete')
|
@register_model_view(Job, 'delete')
|
||||||
class JobDeleteView(generic.ObjectDeleteView):
|
class JobDeleteView(generic.ObjectDeleteView):
|
||||||
queryset = Job.objects.all()
|
queryset = Job.objects.defer('data')
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Job, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(Job, 'bulk_delete', path='delete', detail=False)
|
||||||
class JobBulkDeleteView(generic.BulkDeleteView):
|
class JobBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = Job.objects.all()
|
queryset = Job.objects.defer('data')
|
||||||
filterset = filtersets.JobFilterSet
|
filterset = filtersets.JobFilterSet
|
||||||
table = tables.JobTable
|
table = tables.JobTable
|
||||||
|
|
||||||
@ -570,8 +570,9 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
# Serialize any CustomValidator classes
|
# Serialize any CustomValidator classes
|
||||||
if hasattr(config, 'CUSTOM_VALIDATORS') and config.CUSTOM_VALIDATORS:
|
for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']:
|
||||||
config.CUSTOM_VALIDATORS = json.dumps(config.CUSTOM_VALIDATORS, cls=ConfigJSONEncoder, indent=4)
|
if hasattr(config, attr) and getattr(config, attr, None):
|
||||||
|
setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))
|
||||||
|
|
||||||
return render(request, 'core/system.html', {
|
return render(request, 'core/system.html', {
|
||||||
'stats': stats,
|
'stats': stats,
|
||||||
@ -594,7 +595,7 @@ class BasePluginView(UserPassesTestMixin, View):
|
|||||||
catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False)
|
catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False)
|
||||||
if not catalog_plugins_error:
|
if not catalog_plugins_error:
|
||||||
catalog_plugins = get_catalog_plugins()
|
catalog_plugins = get_catalog_plugins()
|
||||||
if not catalog_plugins:
|
if not catalog_plugins and not settings.ISOLATED_DEPLOYMENT:
|
||||||
# Cache for 5 minutes to avoid spamming connection
|
# Cache for 5 minutes to avoid spamming connection
|
||||||
cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300)
|
cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300)
|
||||||
messages.warning(request, _("Plugins catalog could not be loaded"))
|
messages.warning(request, _("Plugins catalog could not be loaded"))
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from django.utils.translation import gettext as _
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -232,8 +233,56 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
# Validate many-to-many VLAN assignments
|
|
||||||
if not self.nested:
|
if not self.nested:
|
||||||
|
|
||||||
|
# Validate 802.1q mode and vlan(s)
|
||||||
|
mode = None
|
||||||
|
tagged_vlans = []
|
||||||
|
|
||||||
|
# Gather Information
|
||||||
|
if self.instance:
|
||||||
|
mode = data.get('mode') if 'mode' in data.keys() else self.instance.mode
|
||||||
|
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else \
|
||||||
|
self.instance.untagged_vlan
|
||||||
|
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else \
|
||||||
|
self.instance.qinq_svlan
|
||||||
|
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else \
|
||||||
|
self.instance.tagged_vlans.all()
|
||||||
|
else:
|
||||||
|
mode = data.get('mode', None)
|
||||||
|
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else None
|
||||||
|
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else None
|
||||||
|
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else None
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
# Non Q-in-Q mode with service vlan set
|
||||||
|
if mode != InterfaceModeChoices.MODE_Q_IN_Q and qinq_svlan:
|
||||||
|
errors.update({
|
||||||
|
'qinq_svlan': _("Interface mode does not support q-in-q service vlan")
|
||||||
|
})
|
||||||
|
# Routed mode
|
||||||
|
if not mode:
|
||||||
|
# Untagged vlan
|
||||||
|
if untagged_vlan:
|
||||||
|
errors.update({
|
||||||
|
'untagged_vlan': _("Interface mode does not support untagged vlan")
|
||||||
|
})
|
||||||
|
# Tagged vlan
|
||||||
|
if tagged_vlans:
|
||||||
|
errors.update({
|
||||||
|
'tagged_vlans': _("Interface mode does not support tagged vlans")
|
||||||
|
})
|
||||||
|
# Non-tagged mode
|
||||||
|
elif mode in (InterfaceModeChoices.MODE_TAGGED_ALL, InterfaceModeChoices.MODE_ACCESS) and tagged_vlans:
|
||||||
|
errors.update({
|
||||||
|
'tagged_vlans': _("Interface mode does not support tagged vlans")
|
||||||
|
})
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise serializers.ValidationError(errors)
|
||||||
|
|
||||||
|
# Validate many-to-many VLAN assignments
|
||||||
device = self.instance.device if self.instance else data.get('device')
|
device = self.instance.device if self.instance else data.get('device')
|
||||||
for vlan in data.get('tagged_vlans', []):
|
for vlan in data.get('tagged_vlans', []):
|
||||||
if vlan.site not in [device.site, None]:
|
if vlan.site not in [device.site, None]:
|
||||||
|
@ -170,8 +170,8 @@ class MACAddressSerializer(NetBoxModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = MACAddress
|
model = MACAddress
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object',
|
'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object_id',
|
||||||
'description', 'comments',
|
'assigned_object', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
|
brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
|
||||||
|
|
||||||
|
@ -53,10 +53,10 @@ class ScopedFilterSet(BaseFilterSet):
|
|||||||
label=_('Site (slug)'),
|
label=_('Site (slug)'),
|
||||||
)
|
)
|
||||||
location_id = TreeNodeMultipleChoiceFilter(
|
location_id = TreeNodeMultipleChoiceFilter(
|
||||||
queryset=Location.objects.all(),
|
queryset=Location.objects.all(),
|
||||||
field_name='_location',
|
field_name='_location',
|
||||||
lookup_expr='in',
|
lookup_expr='in',
|
||||||
label=_('Location (ID)'),
|
label=_('Location (ID)'),
|
||||||
)
|
)
|
||||||
location = TreeNodeMultipleChoiceFilter(
|
location = TreeNodeMultipleChoiceFilter(
|
||||||
queryset=Location.objects.all(),
|
queryset=Location.objects.all(),
|
||||||
|
2
netbox/dcim/exceptions.py
Normal file
2
netbox/dcim/exceptions.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
class UnsupportedCablePath(Exception):
|
||||||
|
pass
|
@ -1193,6 +1193,7 @@ class DeviceFilterSet(
|
|||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(name__icontains=value) |
|
Q(name__icontains=value) |
|
||||||
|
Q(virtual_chassis__name__icontains=value) |
|
||||||
Q(serial__icontains=value.strip()) |
|
Q(serial__icontains=value.strip()) |
|
||||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||||
Q(asset_tag__icontains=value.strip()) |
|
Q(asset_tag__icontains=value.strip()) |
|
||||||
@ -1652,8 +1653,8 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
|||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
qs_filter = (
|
qs_filter = (
|
||||||
Q(mac_address__icontains=value) |
|
Q(mac_address__icontains=value) |
|
||||||
Q(description__icontains=value)
|
Q(description__icontains=value)
|
||||||
)
|
)
|
||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
@ -1411,7 +1411,7 @@ class InterfaceBulkEditForm(
|
|||||||
form_from_model(Interface, [
|
form_from_model(Interface, [
|
||||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
|
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
|
||||||
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||||
'wireless_lans'
|
'wireless_lans', 'vlan_translation_policy'
|
||||||
])
|
])
|
||||||
):
|
):
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
@ -1564,7 +1564,9 @@ class InterfaceBulkEditForm(
|
|||||||
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
|
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
|
||||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||||
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
|
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
|
||||||
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', name=_('802.1Q Switching')),
|
FieldSet(
|
||||||
|
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
|
||||||
|
),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
TabbedGroups(
|
TabbedGroups(
|
||||||
FieldSet('tagged_vlans', name=_('Assignment')),
|
FieldSet('tagged_vlans', name=_('Assignment')),
|
||||||
@ -1579,7 +1581,7 @@ class InterfaceBulkEditForm(
|
|||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
|
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
|
||||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans'
|
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -43,20 +43,14 @@ class InterfaceCommonForm(forms.Form):
|
|||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
||||||
tagged_vlans = self.cleaned_data.get('tagged_vlans')
|
if 'tagged_vlans' in self.fields.keys():
|
||||||
|
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
|
||||||
# Untagged interfaces cannot be assigned tagged VLANs
|
self.get_initial_for_field(self.fields['tagged_vlans'], 'tagged_vlans')
|
||||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
|
else:
|
||||||
raise forms.ValidationError({
|
tagged_vlans = []
|
||||||
'mode': _("An access interface cannot have tagged VLANs assigned.")
|
|
||||||
})
|
|
||||||
|
|
||||||
# Remove all tagged VLAN assignments from "tagged all" interfaces
|
|
||||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
|
|
||||||
self.cleaned_data['tagged_vlans'] = []
|
|
||||||
|
|
||||||
# Validate tagged VLANs; must be a global VLAN or in the same site
|
# Validate tagged VLANs; must be a global VLAN or in the same site
|
||||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
|
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
|
||||||
valid_sites = [None, self.cleaned_data[parent_field].site]
|
valid_sites = [None, self.cleaned_data[parent_field].site]
|
||||||
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
|
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
|
||||||
|
|
||||||
|
@ -303,7 +303,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
|||||||
model = RackType
|
model = RackType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
|
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
|
||||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||||
)
|
)
|
||||||
|
@ -1810,6 +1810,11 @@ class MACAddressForm(NetBoxModelForm):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if instance and instance.assigned_object and instance.assigned_object.primary_mac_address:
|
||||||
|
if instance.assigned_object.primary_mac_address.pk == instance.pk:
|
||||||
|
self.fields['interface'].disabled = True
|
||||||
|
self.fields['vminterface'].disabled = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ from dcim.fields import PathField
|
|||||||
from dcim.utils import decompile_path_node, object_to_path_node
|
from dcim.utils import decompile_path_node, object_to_path_node
|
||||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||||
from utilities.conversion import to_meters
|
from utilities.conversion import to_meters
|
||||||
|
from utilities.exceptions import AbortRequest
|
||||||
from utilities.fields import ColorField, GenericArrayForeignKey
|
from utilities.fields import ColorField, GenericArrayForeignKey
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from wireless.models import WirelessLink
|
from wireless.models import WirelessLink
|
||||||
@ -25,6 +26,7 @@ __all__ = (
|
|||||||
'CableTermination',
|
'CableTermination',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..exceptions import UnsupportedCablePath
|
||||||
|
|
||||||
trace_paths = Signal()
|
trace_paths = Signal()
|
||||||
|
|
||||||
@ -235,8 +237,10 @@ class Cable(PrimaryModel):
|
|||||||
for termination in self.b_terminations:
|
for termination in self.b_terminations:
|
||||||
if not termination.pk or termination not in b_terminations:
|
if not termination.pk or termination not in b_terminations:
|
||||||
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
||||||
|
try:
|
||||||
trace_paths.send(Cable, instance=self, created=_created)
|
trace_paths.send(Cable, instance=self, created=_created)
|
||||||
|
except UnsupportedCablePath as e:
|
||||||
|
raise AbortRequest(e)
|
||||||
|
|
||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return LinkStatusChoices.colors.get(self.status)
|
return LinkStatusChoices.colors.get(self.status)
|
||||||
@ -533,8 +537,8 @@ class CablePath(models.Model):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Ensure all originating terminations are attached to the same link
|
# Ensure all originating terminations are attached to the same link
|
||||||
if len(terminations) > 1:
|
if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
|
||||||
assert all(t.link == terminations[0].link for t in terminations[1:])
|
raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
|
||||||
|
|
||||||
path = []
|
path = []
|
||||||
position_stack = []
|
position_stack = []
|
||||||
@ -545,12 +549,13 @@ class CablePath(models.Model):
|
|||||||
while terminations:
|
while terminations:
|
||||||
|
|
||||||
# Terminations must all be of the same type
|
# Terminations must all be of the same type
|
||||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
|
||||||
|
raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
|
||||||
|
|
||||||
# All mid-span terminations must all be attached to the same device
|
# All mid-span terminations must all be attached to the same device
|
||||||
if not isinstance(terminations[0], PathEndpoint):
|
if (not isinstance(terminations[0], PathEndpoint) and not
|
||||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
all(t.parent_object == terminations[0].parent_object for t in terminations[1:])):
|
||||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
|
||||||
|
|
||||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||||
# different cables attached)
|
# different cables attached)
|
||||||
@ -573,8 +578,10 @@ class CablePath(models.Model):
|
|||||||
return None
|
return None
|
||||||
# Otherwise, halt the trace if no link exists
|
# Otherwise, halt the trace if no link exists
|
||||||
break
|
break
|
||||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
if not all(type(link) in (Cable, WirelessLink) for link in links):
|
||||||
assert all(isinstance(link, type(links[0])) for link in links)
|
raise UnsupportedCablePath(_("All links must be cable or wireless"))
|
||||||
|
if not all(isinstance(link, type(links[0])) for link in links):
|
||||||
|
raise UnsupportedCablePath(_("All links must match first link type"))
|
||||||
|
|
||||||
# Step 3: Record asymmetric paths as split
|
# Step 3: Record asymmetric paths as split
|
||||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||||
@ -655,14 +662,18 @@ class CablePath(models.Model):
|
|||||||
positions = position_stack.pop()
|
positions = position_stack.pop()
|
||||||
|
|
||||||
# Ensure we have a number of positions equal to the amount of remote terminations
|
# Ensure we have a number of positions equal to the amount of remote terminations
|
||||||
assert len(remote_terminations) == len(positions)
|
if len(remote_terminations) != len(positions):
|
||||||
|
raise UnsupportedCablePath(
|
||||||
|
_("All positions counts within the path on opposite ends of links must match")
|
||||||
|
)
|
||||||
|
|
||||||
# Get our front ports
|
# Get our front ports
|
||||||
q_filter = Q()
|
q_filter = Q()
|
||||||
for rt in remote_terminations:
|
for rt in remote_terminations:
|
||||||
position = positions.pop()
|
position = positions.pop()
|
||||||
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
||||||
assert q_filter is not Q()
|
if q_filter is Q():
|
||||||
|
raise UnsupportedCablePath(_("Remote termination position filter is missing"))
|
||||||
front_ports = FrontPort.objects.filter(q_filter)
|
front_ports = FrontPort.objects.filter(q_filter)
|
||||||
# Obtain the individual front ports based on the termination and position
|
# Obtain the individual front ports based on the termination and position
|
||||||
elif position_stack:
|
elif position_stack:
|
||||||
|
@ -937,6 +937,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
|||||||
raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
|
raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
|
||||||
|
|
||||||
# VLAN validation
|
# VLAN validation
|
||||||
|
if not self.mode and self.untagged_vlan:
|
||||||
|
raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")})
|
||||||
|
|
||||||
# Validate untagged VLAN
|
# Validate untagged VLAN
|
||||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
||||||
|
@ -15,6 +15,7 @@ from django.urls import reverse
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import ObjectType
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.fields import MACAddressField
|
from dcim.fields import MACAddressField
|
||||||
@ -801,14 +802,10 @@ class Device(
|
|||||||
verbose_name_plural = _('devices')
|
verbose_name_plural = _('devices')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.name and self.asset_tag:
|
if self.label and self.asset_tag:
|
||||||
return f'{self.name} ({self.asset_tag})'
|
return f'{self.label} ({self.asset_tag})'
|
||||||
elif self.name:
|
elif self.label:
|
||||||
return self.name
|
return self.label
|
||||||
elif self.virtual_chassis and self.asset_tag:
|
|
||||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
|
|
||||||
elif self.virtual_chassis:
|
|
||||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
|
|
||||||
elif self.device_type and self.asset_tag:
|
elif self.device_type and self.asset_tag:
|
||||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
||||||
elif self.device_type:
|
elif self.device_type:
|
||||||
@ -1072,14 +1069,22 @@ class Device(
|
|||||||
device.location = self.location
|
device.location = self.location
|
||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
"""
|
||||||
|
Return the device name if set; otherwise return a generated name if available.
|
||||||
|
"""
|
||||||
|
if self.name:
|
||||||
|
return self.name
|
||||||
|
if self.virtual_chassis:
|
||||||
|
return f'{self.virtual_chassis.name}:{self.vc_position}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self):
|
def identifier(self):
|
||||||
"""
|
"""
|
||||||
Return the device name if set; otherwise return the Device's primary key as {pk}
|
Return the device name if set; otherwise return the Device's primary key as {pk}
|
||||||
"""
|
"""
|
||||||
if self.name is not None:
|
return self.label or '{{{}}}'.format(self.pk)
|
||||||
return self.name
|
|
||||||
return '{{{}}}'.format(self.pk)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_ip(self):
|
def primary_ip(self):
|
||||||
@ -1297,6 +1302,7 @@ class Module(PrimaryModel, ConfigContextModel):
|
|||||||
else:
|
else:
|
||||||
# ModuleBays must be saved individually for MPTT
|
# ModuleBays must be saved individually for MPTT
|
||||||
for instance in create_instances:
|
for instance in create_instances:
|
||||||
|
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
update_fields = ['module']
|
update_fields = ['module']
|
||||||
@ -1522,3 +1528,37 @@ class MACAddress(PrimaryModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.mac_address)
|
return str(self.mac_address)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Denote the original assigned object (if any) for validation in clean()
|
||||||
|
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
||||||
|
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def is_primary(self):
|
||||||
|
if self.assigned_object and hasattr(self.assigned_object, 'primary_mac_address'):
|
||||||
|
if self.assigned_object.primary_mac_address and self.assigned_object.primary_mac_address.pk == self.pk:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clean(self, *args, **kwargs):
|
||||||
|
super().clean()
|
||||||
|
if self._original_assigned_object_id and self._original_assigned_object_type_id:
|
||||||
|
assigned_object = self.assigned_object
|
||||||
|
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
|
||||||
|
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
original_assigned_object.primary_mac_address
|
||||||
|
and original_assigned_object.primary_mac_address.pk == self.pk
|
||||||
|
):
|
||||||
|
if not assigned_object:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Cannot unassign MAC Address while it is designated as the primary MAC for an object")
|
||||||
|
)
|
||||||
|
elif original_assigned_object != assigned_object:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Cannot reassign MAC Address while it is designated as the primary MAC for an object")
|
||||||
|
)
|
||||||
|
@ -374,22 +374,27 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
|
|||||||
if not self._state.adding:
|
if not self._state.adding:
|
||||||
mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
|
mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
|
||||||
|
|
||||||
|
effective_u_height = self.rack_type.u_height if self.rack_type else self.u_height
|
||||||
|
effective_starting_unit = self.rack_type.starting_unit if self.rack_type else self.starting_unit
|
||||||
|
|
||||||
# Validate that Rack is tall enough to house the highest mounted Device
|
# Validate that Rack is tall enough to house the highest mounted Device
|
||||||
if top_device := mounted_devices.last():
|
if top_device := mounted_devices.last():
|
||||||
min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
|
min_height = top_device.position + top_device.device_type.u_height - effective_starting_unit
|
||||||
if self.u_height < min_height:
|
if effective_u_height < min_height:
|
||||||
|
field = 'rack_type' if self.rack_type else 'u_height'
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'u_height': _(
|
field: _(
|
||||||
"Rack must be at least {min_height}U tall to house currently installed devices."
|
"Rack must be at least {min_height}U tall to house currently installed devices."
|
||||||
).format(min_height=min_height)
|
).format(min_height=min_height)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
|
# Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
|
||||||
if last_device := mounted_devices.first():
|
if last_device := mounted_devices.first():
|
||||||
if self.starting_unit > last_device.position:
|
if effective_starting_unit > last_device.position:
|
||||||
|
field = 'rack_type' if self.rack_type else 'starting_unit'
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'starting_unit': _("Rack unit numbering must begin at {position} or less to house "
|
field: _("Rack unit numbering must begin at {position} or less to house "
|
||||||
"currently installed devices.").format(position=last_device.position)
|
"currently installed devices.").format(position=last_device.position)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Validate that Rack was assigned a Location of its same site, if applicable
|
# Validate that Rack was assigned a Location of its same site, if applicable
|
||||||
|
@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex):
|
|||||||
('asset_tag', 50),
|
('asset_tag', 50),
|
||||||
('serial', 60),
|
('serial', 60),
|
||||||
('name', 100),
|
('name', 100),
|
||||||
|
('virtual_chassis', 200),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff'
|
|||||||
|
|
||||||
|
|
||||||
def get_device_name(device):
|
def get_device_name(device):
|
||||||
if device.virtual_chassis:
|
if device.label:
|
||||||
name = f'{device.virtual_chassis.name}:{device.vc_position}'
|
name = device.label
|
||||||
elif device.name:
|
|
||||||
name = device.name
|
|
||||||
else:
|
else:
|
||||||
name = str(device.device_type)
|
name = str(device.device_type)
|
||||||
if device.devicebay_count:
|
if device.devicebay_count:
|
||||||
|
@ -143,6 +143,7 @@ class PlatformTable(NetBoxTable):
|
|||||||
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.TemplateColumn(
|
name = tables.TemplateColumn(
|
||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
|
accessor=Accessor('label'),
|
||||||
template_code=DEVICE_LINK,
|
template_code=DEVICE_LINK,
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -671,7 +672,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
|
|||||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||||
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||||
'qinq_svlan', 'inventory_items', 'created', 'last_updated',
|
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||||
|
|
||||||
@ -705,7 +706,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
|||||||
model = models.Interface
|
model = models.Interface
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
|
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
|
||||||
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
'mgmt_only', 'mtu', 'mode', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||||
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
||||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
|
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
|
||||||
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
|
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
|
||||||
|
@ -31,6 +31,11 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
racktype_count = columns.LinkedCountColumn(
|
||||||
|
viewname='dcim:racktype_list',
|
||||||
|
url_params={'manufacturer_id': 'pk'},
|
||||||
|
verbose_name=_('Rack Types')
|
||||||
|
)
|
||||||
devicetype_count = columns.LinkedCountColumn(
|
devicetype_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:devicetype_list',
|
viewname='dcim:devicetype_list',
|
||||||
url_params={'manufacturer_id': 'pk'},
|
url_params={'manufacturer_id': 'pk'},
|
||||||
@ -58,12 +63,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.Manufacturer
|
model = models.Manufacturer
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
'pk', 'id', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||||
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
'pk', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||||
'description', 'slug',
|
'platform_count', 'description', 'slug',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -94,7 +94,6 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('ASNs')
|
verbose_name=_('ASNs')
|
||||||
)
|
)
|
||||||
asn_count = columns.LinkedCountColumn(
|
asn_count = columns.LinkedCountColumn(
|
||||||
accessor=tables.A('asns__count'),
|
|
||||||
viewname='ipam:asn_list',
|
viewname='ipam:asn_list',
|
||||||
url_params={'site_id': 'pk'},
|
url_params={'site_id': 'pk'},
|
||||||
verbose_name=_('ASN Count')
|
verbose_name=_('ASN Count')
|
||||||
@ -147,6 +146,11 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
url_params={'location_id': 'pk'},
|
url_params={'location_id': 'pk'},
|
||||||
verbose_name=_('Devices')
|
verbose_name=_('Devices')
|
||||||
)
|
)
|
||||||
|
vlangroup_count = columns.LinkedCountColumn(
|
||||||
|
viewname='ipam:vlangroup_list',
|
||||||
|
url_params={'location': 'pk'},
|
||||||
|
verbose_name=_('VLAN Groups')
|
||||||
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:location_list'
|
url_name='dcim:location_list'
|
||||||
)
|
)
|
||||||
@ -158,8 +162,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
model = Location
|
model = Location
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
||||||
'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', 'vlangroup_count',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description'
|
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'vlangroup_count',
|
||||||
|
'description'
|
||||||
)
|
)
|
||||||
|
@ -69,16 +69,18 @@ INTERFACE_FHRPGROUPS = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
INTERFACE_TAGGED_VLANS = """
|
INTERFACE_TAGGED_VLANS = """
|
||||||
{% if record.mode == 'tagged' %}
|
{% load i18n %}
|
||||||
|
{% if record.mode == 'access' %}
|
||||||
|
{% elif record.mode == 'tagged-all' %}
|
||||||
|
{% trans "All" %}
|
||||||
|
{% else %}
|
||||||
{% if value.count > 3 %}
|
{% if value.count > 3 %}
|
||||||
<a href="{% url 'ipam:vlan_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }} VLANs</a>
|
<a href="{% url 'ipam:vlan_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }} VLANs</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for vlan in value.all %}
|
{% for vlan in value.all %}
|
||||||
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
|
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif record.mode == 'tagged-all' %}
|
|
||||||
All
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -157,8 +159,8 @@ CONSOLEPORT_BUTTONS = """
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% elif perms.dcim.add_cable %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||||
@ -170,7 +172,7 @@ CONSOLEPORT_BUTTONS = """
|
|||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -207,8 +209,8 @@ CONSOLESERVERPORT_BUTTONS = """
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% elif perms.dcim.add_cable %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||||
@ -220,7 +222,7 @@ CONSOLESERVERPORT_BUTTONS = """
|
|||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -257,8 +259,8 @@ POWERPORT_BUTTONS = """
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% elif perms.dcim.add_cable %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||||
@ -269,7 +271,7 @@ POWERPORT_BUTTONS = """
|
|||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -306,14 +308,14 @@ POWEROUTLET_BUTTONS = """
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% elif perms.dcim.add_cable %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||||
{% if not record.mark_connected %}
|
{% if not record.mark_connected %}
|
||||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
|
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
|
||||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
|
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
@ -400,8 +402,8 @@ INTERFACE_BUTTONS = """
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif record.is_wired and perms.dcim.add_cable %}
|
{% elif record.is_wired and perms.dcim.add_cable %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||||
{% if not record.mark_connected %}
|
{% if not record.mark_connected %}
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
|
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
|
||||||
@ -415,7 +417,7 @@ INTERFACE_BUTTONS = """
|
|||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
|
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
|
||||||
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device_id }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
|
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device_id }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
|
||||||
@ -457,8 +459,8 @@ FRONTPORT_BUTTONS = """
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% elif perms.dcim.add_cable %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||||
{% if not record.mark_connected %}
|
{% if not record.mark_connected %}
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
@ -474,7 +476,7 @@ FRONTPORT_BUTTONS = """
|
|||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
@ -512,8 +514,8 @@ REARPORT_BUTTONS = """
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% elif perms.dcim.add_cable %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||||
{% if not record.mark_connected %}
|
{% if not record.mark_connected %}
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
@ -529,7 +531,7 @@ REARPORT_BUTTONS = """
|
|||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -1748,6 +1750,23 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):
|
||||||
|
device = Device.objects.first()
|
||||||
|
data = {
|
||||||
|
'device': device.pk,
|
||||||
|
'name': 'Interface 1',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||||
|
}
|
||||||
|
data.update({'mode': mode})
|
||||||
|
data.update(invalid_data)
|
||||||
|
|
||||||
|
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||||
|
content = json.loads(response.content)
|
||||||
|
for key in invalid_data.keys():
|
||||||
|
self.assertIn(key, content)
|
||||||
|
self.assertIsNone(content.get('data'))
|
||||||
|
|
||||||
def test_bulk_delete_child_interfaces(self):
|
def test_bulk_delete_child_interfaces(self):
|
||||||
interface1 = Interface.objects.get(name='Interface 1')
|
interface1 = Interface.objects.get(name='Interface 1')
|
||||||
device = interface1.device
|
device = interface1.device
|
||||||
@ -1775,6 +1794,57 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
|||||||
self.client.delete(self._get_list_url(), data, format='json', **self.header)
|
self.client.delete(self._get_list_url(), data, format='json', **self.header)
|
||||||
self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted
|
self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted
|
||||||
|
|
||||||
|
def test_create_child_interfaces_mode_invalid_data(self):
|
||||||
|
"""
|
||||||
|
POST data to test interface mode check and invalid tagged/untagged VLANS.
|
||||||
|
"""
|
||||||
|
self.add_permissions('dcim.add_interface')
|
||||||
|
|
||||||
|
vlans = VLAN.objects.all()[0:3]
|
||||||
|
|
||||||
|
# Routed mode, untagged, tagged and qinq service vlan
|
||||||
|
invalid_data = {
|
||||||
|
'untagged_vlan': vlans[0].pk,
|
||||||
|
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
|
||||||
|
'qinq_svlan': vlans[2].pk
|
||||||
|
}
|
||||||
|
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||||
|
|
||||||
|
# Routed mode, untagged and tagged vlan
|
||||||
|
invalid_data = {
|
||||||
|
'untagged_vlan': vlans[0].pk,
|
||||||
|
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
|
||||||
|
}
|
||||||
|
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||||
|
|
||||||
|
# Routed mode, untagged vlan
|
||||||
|
invalid_data = {
|
||||||
|
'untagged_vlan': vlans[0].pk,
|
||||||
|
}
|
||||||
|
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||||
|
|
||||||
|
invalid_data = {
|
||||||
|
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
|
||||||
|
}
|
||||||
|
# Routed mode, qinq service vlan
|
||||||
|
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||||
|
# Access mode, tagged vlans
|
||||||
|
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
|
||||||
|
# All tagged mode, tagged vlans
|
||||||
|
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
|
||||||
|
|
||||||
|
invalid_data = {
|
||||||
|
'qinq_svlan': vlans[0].pk,
|
||||||
|
}
|
||||||
|
# Routed mode, qinq service vlan
|
||||||
|
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||||
|
# Access mode, qinq service vlan
|
||||||
|
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
|
||||||
|
# Tagged mode, qinq service vlan
|
||||||
|
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED, invalid_data)
|
||||||
|
# Tagged-all mode, qinq service vlan
|
||||||
|
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = FrontPort
|
model = FrontPort
|
||||||
@ -2447,3 +2517,46 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
|
|||||||
# Omit identifier to test uniqueness constraint
|
# Omit identifier to test uniqueness constraint
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MACAddressTest(APIViewTestCases.APIViewTestCase):
|
||||||
|
model = MACAddress
|
||||||
|
brief_fields = ['description', 'display', 'id', 'mac_address', 'url']
|
||||||
|
bulk_update_data = {
|
||||||
|
'description': 'New description',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
device = create_test_device(name='Device 1')
|
||||||
|
interfaces = (
|
||||||
|
Interface(device=device, name='Interface 1', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 2', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 3', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 4', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 5', type='1000base-t'),
|
||||||
|
)
|
||||||
|
Interface.objects.bulk_create(interfaces)
|
||||||
|
|
||||||
|
mac_addresses = (
|
||||||
|
MACAddress(mac_address='00:00:00:00:00:01', assigned_object=interfaces[0]),
|
||||||
|
MACAddress(mac_address='00:00:00:00:00:02', assigned_object=interfaces[1]),
|
||||||
|
MACAddress(mac_address='00:00:00:00:00:03', assigned_object=interfaces[2]),
|
||||||
|
)
|
||||||
|
MACAddress.objects.bulk_create(mac_addresses)
|
||||||
|
|
||||||
|
cls.create_data = [
|
||||||
|
{
|
||||||
|
'mac_address': '00:00:00:00:00:04',
|
||||||
|
'assigned_object_type': 'dcim.interface',
|
||||||
|
'assigned_object_id': interfaces[3].pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'mac_address': '00:00:00:00:00:05',
|
||||||
|
'assigned_object_type': 'dcim.interface',
|
||||||
|
'assigned_object_id': interfaces[4].pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'mac_address': '00:00:00:00:00:06',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
@ -5,6 +5,7 @@ from dcim.choices import LinkStatusChoices
|
|||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from dcim.svg import CableTraceSVG
|
from dcim.svg import CableTraceSVG
|
||||||
from dcim.utils import object_to_path_node
|
from dcim.utils import object_to_path_node
|
||||||
|
from utilities.exceptions import AbortRequest
|
||||||
|
|
||||||
|
|
||||||
class CablePathTestCase(TestCase):
|
class CablePathTestCase(TestCase):
|
||||||
@ -2470,7 +2471,7 @@ class CablePathTestCase(TestCase):
|
|||||||
b_terminations=[frontport1, frontport3],
|
b_terminations=[frontport1, frontport3],
|
||||||
label='C1'
|
label='C1'
|
||||||
)
|
)
|
||||||
with self.assertRaises(AssertionError):
|
with self.assertRaises(AbortRequest):
|
||||||
cable1.save()
|
cable1.save()
|
||||||
|
|
||||||
self.assertPathDoesNotExist(
|
self.assertPathDoesNotExist(
|
||||||
@ -2489,7 +2490,7 @@ class CablePathTestCase(TestCase):
|
|||||||
label='C3'
|
label='C3'
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(AssertionError):
|
with self.assertRaises(AbortRequest):
|
||||||
cable3.save()
|
cable3.save()
|
||||||
|
|
||||||
self.assertPathDoesNotExist(
|
self.assertPathDoesNotExist(
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
|
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices
|
||||||
from dcim.forms import *
|
from dcim.forms import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
|
from ipam.models import VLAN
|
||||||
from utilities.testing import create_test_device
|
from utilities.testing import create_test_device
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
@ -117,11 +118,23 @@ class DeviceTestCase(TestCase):
|
|||||||
self.assertIn('position', form.errors)
|
self.assertIn('position', form.errors)
|
||||||
|
|
||||||
|
|
||||||
class LabelTestCase(TestCase):
|
class InterfaceTestCase(TestCase):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.device = create_test_device('Device 1')
|
cls.device = create_test_device('Device 1')
|
||||||
|
cls.vlans = (
|
||||||
|
VLAN(name='VLAN 1', vid=1),
|
||||||
|
VLAN(name='VLAN 2', vid=2),
|
||||||
|
VLAN(name='VLAN 3', vid=3),
|
||||||
|
)
|
||||||
|
VLAN.objects.bulk_create(cls.vlans)
|
||||||
|
cls.interface = Interface.objects.create(
|
||||||
|
device=cls.device,
|
||||||
|
name='Interface 1',
|
||||||
|
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
mode=InterfaceModeChoices.MODE_TAGGED,
|
||||||
|
)
|
||||||
|
|
||||||
def test_interface_label_count_valid(self):
|
def test_interface_label_count_valid(self):
|
||||||
"""
|
"""
|
||||||
@ -151,3 +164,152 @@ class LabelTestCase(TestCase):
|
|||||||
|
|
||||||
self.assertFalse(form.is_valid())
|
self.assertFalse(form.is_valid())
|
||||||
self.assertIn('label', form.errors)
|
self.assertIn('label', form.errors)
|
||||||
|
|
||||||
|
def test_create_interface_mode_valid_data(self):
|
||||||
|
"""
|
||||||
|
Test that saving valid interface mode and tagged/untagged vlans works properly
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Validate access mode
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'ethernet1/1',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': InterfaceModeChoices.MODE_ACCESS,
|
||||||
|
'untagged_vlan': self.vlans[0].pk
|
||||||
|
}
|
||||||
|
form = InterfaceCreateForm(data)
|
||||||
|
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
# Validate tagged vlans
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'ethernet1/2',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||||
|
'untagged_vlan': self.vlans[0].pk,
|
||||||
|
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
|
||||||
|
}
|
||||||
|
form = InterfaceCreateForm(data)
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
# Validate tagged vlans
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'ethernet1/3',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||||
|
'untagged_vlan': self.vlans[0].pk,
|
||||||
|
}
|
||||||
|
form = InterfaceCreateForm(data)
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_create_interface_mode_access_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'ethernet1/4',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': InterfaceModeChoices.MODE_ACCESS,
|
||||||
|
'untagged_vlan': self.vlans[0].pk,
|
||||||
|
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
|
||||||
|
}
|
||||||
|
form = InterfaceCreateForm(data)
|
||||||
|
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||||
|
|
||||||
|
def test_edit_interface_mode_access_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'Ethernet 1/5',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': InterfaceModeChoices.MODE_ACCESS,
|
||||||
|
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||||
|
}
|
||||||
|
form = InterfaceForm(data, instance=self.interface)
|
||||||
|
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||||
|
|
||||||
|
def test_create_interface_mode_tagged_all_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'ethernet1/6',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||||
|
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||||
|
}
|
||||||
|
form = InterfaceCreateForm(data)
|
||||||
|
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||||
|
|
||||||
|
def test_edit_interface_mode_tagged_all_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'Ethernet 1/7',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||||
|
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||||
|
}
|
||||||
|
form = InterfaceForm(data)
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||||
|
|
||||||
|
def test_create_interface_mode_routed_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'ethernet1/6',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': None,
|
||||||
|
'untagged_vlan': self.vlans[0].pk,
|
||||||
|
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||||
|
}
|
||||||
|
form = InterfaceCreateForm(data)
|
||||||
|
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||||
|
|
||||||
|
def test_edit_interface_mode_routed_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'device': self.device.pk,
|
||||||
|
'name': 'Ethernet 1/7',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'mode': None,
|
||||||
|
'untagged_vlan': self.vlans[0].pk,
|
||||||
|
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||||
|
}
|
||||||
|
form = InterfaceForm(data)
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||||
|
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import tag, TestCase
|
||||||
|
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
@ -12,6 +12,43 @@ from utilities.data import drange
|
|||||||
from virtualization.models import Cluster, ClusterType
|
from virtualization.models import Cluster, ClusterType
|
||||||
|
|
||||||
|
|
||||||
|
class MACAddressTestCase(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||||
|
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||||
|
device_type = DeviceType.objects.create(
|
||||||
|
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||||
|
)
|
||||||
|
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
|
||||||
|
device = Device.objects.create(
|
||||||
|
name='Device 1', device_type=device_type, role=device_role, site=site,
|
||||||
|
)
|
||||||
|
cls.interface = Interface.objects.create(
|
||||||
|
device=device,
|
||||||
|
name='Interface 1',
|
||||||
|
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||||
|
mgmt_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.mac_a = MACAddress.objects.create(mac_address='1234567890ab', assigned_object=cls.interface)
|
||||||
|
cls.mac_b = MACAddress.objects.create(mac_address='1234567890ba', assigned_object=cls.interface)
|
||||||
|
|
||||||
|
cls.interface.primary_mac_address = cls.mac_a
|
||||||
|
cls.interface.save()
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
|
||||||
|
self.mac_a.assigned_object = None
|
||||||
|
with self.assertRaisesMessage(ValidationError, 'Cannot unassign MAC Address while'):
|
||||||
|
self.mac_a.clean()
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
def test_clean_will_allow_removal_of_assigned_object_if_not_primary(self):
|
||||||
|
self.mac_b.assigned_object = None
|
||||||
|
self.mac_b.clean()
|
||||||
|
|
||||||
|
|
||||||
class LocationTestCase(TestCase):
|
class LocationTestCase(TestCase):
|
||||||
|
|
||||||
def test_change_location_site(self):
|
def test_change_location_site(self):
|
||||||
@ -590,6 +627,32 @@ class DeviceTestCase(TestCase):
|
|||||||
device2.full_clean()
|
device2.full_clean()
|
||||||
device2.save()
|
device2.save()
|
||||||
|
|
||||||
|
def test_device_label(self):
|
||||||
|
device1 = Device(
|
||||||
|
site=Site.objects.first(),
|
||||||
|
device_type=DeviceType.objects.first(),
|
||||||
|
role=DeviceRole.objects.first(),
|
||||||
|
name=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(device1.label, None)
|
||||||
|
|
||||||
|
device1.name = 'Test Device 1'
|
||||||
|
self.assertEqual(device1.label, 'Test Device 1')
|
||||||
|
|
||||||
|
virtual_chassis = VirtualChassis.objects.create(name='VC 1')
|
||||||
|
device2 = Device(
|
||||||
|
site=Site.objects.first(),
|
||||||
|
device_type=DeviceType.objects.first(),
|
||||||
|
role=DeviceRole.objects.first(),
|
||||||
|
name=None,
|
||||||
|
virtual_chassis=virtual_chassis,
|
||||||
|
vc_position=2,
|
||||||
|
)
|
||||||
|
self.assertEqual(device2.label, 'VC 1:2')
|
||||||
|
|
||||||
|
device2.name = 'Test Device 2'
|
||||||
|
self.assertEqual(device2.label, 'Test Device 2')
|
||||||
|
|
||||||
def test_device_mismatched_site_cluster(self):
|
def test_device_mismatched_site_cluster(self):
|
||||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||||
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||||
|
@ -3470,3 +3470,54 @@ class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE,
|
'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
|
model = MACAddress
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
device = create_test_device(name='Device 1')
|
||||||
|
interfaces = (
|
||||||
|
Interface(device=device, name='Interface 1', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 2', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 3', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 4', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 5', type='1000base-t'),
|
||||||
|
Interface(device=device, name='Interface 6', type='1000base-t'),
|
||||||
|
)
|
||||||
|
Interface.objects.bulk_create(interfaces)
|
||||||
|
|
||||||
|
mac_addresses = (
|
||||||
|
MACAddress(mac_address='00:00:00:00:00:01', assigned_object=interfaces[0]),
|
||||||
|
MACAddress(mac_address='00:00:00:00:00:02', assigned_object=interfaces[1]),
|
||||||
|
MACAddress(mac_address='00:00:00:00:00:03', assigned_object=interfaces[2]),
|
||||||
|
)
|
||||||
|
MACAddress.objects.bulk_create(mac_addresses)
|
||||||
|
|
||||||
|
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||||
|
|
||||||
|
cls.form_data = {
|
||||||
|
'mac_address': EUI('00:00:00:00:00:04'),
|
||||||
|
'description': 'New MAC address',
|
||||||
|
'interface_id': interfaces[3].pk,
|
||||||
|
'tags': [t.pk for t in tags],
|
||||||
|
}
|
||||||
|
|
||||||
|
cls.csv_data = (
|
||||||
|
"mac_address,device,interface",
|
||||||
|
"00:00:00:00:00:04,Device 1,Interface 4",
|
||||||
|
"00:00:00:00:00:05,Device 1,Interface 5",
|
||||||
|
"00:00:00:00:00:06,Device 1,Interface 6",
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.csv_update_data = (
|
||||||
|
"id,mac_address",
|
||||||
|
f"{mac_addresses[0].pk},00:00:00:00:00:0a",
|
||||||
|
f"{mac_addresses[1].pk},00:00:00:00:00:0b",
|
||||||
|
f"{mac_addresses[2].pk},00:00:00:00:00:0c",
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.bulk_edit_data = {
|
||||||
|
'description': 'New description',
|
||||||
|
}
|
||||||
|
@ -4,18 +4,16 @@ from django.core.paginator import EmptyPage, PageNotAnInteger
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
||||||
from django.http import HttpResponse
|
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from jinja2.exceptions import TemplateError
|
|
||||||
|
|
||||||
from circuits.models import Circuit, CircuitTermination
|
from circuits.models import Circuit, CircuitTermination
|
||||||
from extras.views import ObjectConfigContextView
|
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||||
from ipam.models import ASN, IPAddress, VLANGroup
|
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
||||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@ -30,8 +28,9 @@ from utilities.views import (
|
|||||||
)
|
)
|
||||||
from virtualization.filtersets import VirtualMachineFilterSet
|
from virtualization.filtersets import VirtualMachineFilterSet
|
||||||
from virtualization.forms import VirtualMachineFilterForm
|
from virtualization.forms import VirtualMachineFilterForm
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import Cluster, VirtualMachine
|
||||||
from virtualization.tables import VirtualMachineTable
|
from virtualization.tables import VirtualMachineTable
|
||||||
|
from wireless.models import WirelessLAN
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .choices import DeviceFaceChoices, InterfaceModeChoices
|
from .choices import DeviceFaceChoices, InterfaceModeChoices
|
||||||
from .models import *
|
from .models import *
|
||||||
@ -238,6 +237,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
regions,
|
regions,
|
||||||
|
omit=(Cluster, Prefix, WirelessLAN),
|
||||||
extra=(
|
extra=(
|
||||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||||
@ -247,6 +247,11 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
).distinct(),
|
).distinct(),
|
||||||
'region_id'
|
'region_id'
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Handle these relations manually to avoid erroneous filter name resolution
|
||||||
|
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||||
|
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||||
|
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -331,6 +336,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
groups,
|
groups,
|
||||||
|
omit=(Cluster, Prefix, WirelessLAN),
|
||||||
extra=(
|
extra=(
|
||||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
@ -340,6 +346,20 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
).distinct(),
|
).distinct(),
|
||||||
'site_group_id'
|
'site_group_id'
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Handle these relations manually to avoid erroneous filter name resolution
|
||||||
|
(
|
||||||
|
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||||
|
'site_group_id'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Prefix.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||||
|
'site_group_id'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WirelessLAN.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||||
|
'site_group_id'
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -402,7 +422,8 @@ class SiteGroupContactsView(ObjectContactsView):
|
|||||||
@register_model_view(Site, 'list', path='', detail=False)
|
@register_model_view(Site, 'list', path='', detail=False)
|
||||||
class SiteListView(generic.ObjectListView):
|
class SiteListView(generic.ObjectListView):
|
||||||
queryset = Site.objects.annotate(
|
queryset = Site.objects.annotate(
|
||||||
device_count=count_related(Device, 'site')
|
device_count=count_related(Device, 'site'),
|
||||||
|
asn_count=count_related(ASN, 'sites')
|
||||||
)
|
)
|
||||||
filterset = filtersets.SiteFilterSet
|
filterset = filtersets.SiteFilterSet
|
||||||
filterset_form = forms.SiteFilterForm
|
filterset_form = forms.SiteFilterForm
|
||||||
@ -418,8 +439,8 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
instance,
|
instance,
|
||||||
[CableTermination, CircuitTermination],
|
omit=(CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN),
|
||||||
(
|
extra=(
|
||||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
scope_type=ContentType.objects.get_for_model(Site),
|
scope_type=ContentType.objects.get_for_model(Site),
|
||||||
scope_id=instance.pk
|
scope_id=instance.pk
|
||||||
@ -429,6 +450,11 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
Circuit.objects.restrict(request.user, 'view').filter(terminations___site=instance).distinct(),
|
Circuit.objects.restrict(request.user, 'view').filter(terminations___site=instance).distinct(),
|
||||||
'site_id'
|
'site_id'
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Handle these relations manually to avoid erroneous filter name resolution
|
||||||
|
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||||
|
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||||
|
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -479,18 +505,24 @@ class SiteContactsView(ObjectContactsView):
|
|||||||
@register_model_view(Location, 'list', path='', detail=False)
|
@register_model_view(Location, 'list', path='', detail=False)
|
||||||
class LocationListView(generic.ObjectListView):
|
class LocationListView(generic.ObjectListView):
|
||||||
queryset = Location.objects.add_related_count(
|
queryset = Location.objects.add_related_count(
|
||||||
Location.objects.add_related_count(
|
Location.objects.add_related_count(
|
||||||
Location.objects.all(),
|
Location.objects.add_related_count(
|
||||||
Device,
|
Location.objects.all(),
|
||||||
'location',
|
Device,
|
||||||
'device_count',
|
'location',
|
||||||
cumulative=True
|
'device_count',
|
||||||
),
|
cumulative=True
|
||||||
Rack,
|
),
|
||||||
'location',
|
Rack,
|
||||||
'rack_count',
|
'location',
|
||||||
cumulative=True
|
'rack_count',
|
||||||
)
|
cumulative=True
|
||||||
|
),
|
||||||
|
VLANGroup,
|
||||||
|
'location',
|
||||||
|
'vlangroup_count',
|
||||||
|
cumulative=True
|
||||||
|
)
|
||||||
filterset = filtersets.LocationFilterSet
|
filterset = filtersets.LocationFilterSet
|
||||||
filterset_form = forms.LocationFilterForm
|
filterset_form = forms.LocationFilterForm
|
||||||
table = tables.LocationTable
|
table = tables.LocationTable
|
||||||
@ -502,18 +534,26 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
locations = instance.get_descendants(include_self=True)
|
locations = instance.get_descendants(include_self=True)
|
||||||
|
location_content_type = ContentType.objects.get_for_model(instance)
|
||||||
return {
|
return {
|
||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
locations,
|
locations,
|
||||||
[CableTermination],
|
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
|
||||||
(
|
extra=(
|
||||||
(
|
(
|
||||||
Circuit.objects.restrict(request.user, 'view').filter(
|
Circuit.objects.restrict(request.user, 'view').filter(
|
||||||
terminations___location=instance
|
terminations___location=instance
|
||||||
).distinct(),
|
).distinct(),
|
||||||
'location_id'
|
'location_id'
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Handle these relations manually to avoid erroneous filter name resolution
|
||||||
|
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||||
|
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||||
|
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||||
|
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
|
scope_type_id=location_content_type.id, scope_id=instance.id), 'location'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -934,6 +974,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
|||||||
@register_model_view(Manufacturer, 'list', path='', detail=False)
|
@register_model_view(Manufacturer, 'list', path='', detail=False)
|
||||||
class ManufacturerListView(generic.ObjectListView):
|
class ManufacturerListView(generic.ObjectListView):
|
||||||
queryset = Manufacturer.objects.annotate(
|
queryset = Manufacturer.objects.annotate(
|
||||||
|
racktype_count=count_related(RackType, 'manufacturer'),
|
||||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||||
moduletype_count=count_related(ModuleType, 'manufacturer'),
|
moduletype_count=count_related(ModuleType, 'manufacturer'),
|
||||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||||
@ -2221,54 +2262,14 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Device, 'render-config')
|
@register_model_view(Device, 'render-config')
|
||||||
class DeviceRenderConfigView(generic.ObjectView):
|
class DeviceRenderConfigView(ObjectRenderConfigView):
|
||||||
queryset = Device.objects.all()
|
queryset = Device.objects.all()
|
||||||
template_name = 'dcim/device/render_config.html'
|
base_template = 'dcim/device/base.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Render Config'),
|
label=_('Render Config'),
|
||||||
weight=2100
|
weight=2100,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, request, **kwargs):
|
|
||||||
instance = self.get_object(**kwargs)
|
|
||||||
context = self.get_extra_context(request, instance)
|
|
||||||
|
|
||||||
# If a direct export has been requested, return the rendered template content as a
|
|
||||||
# downloadable file.
|
|
||||||
if request.GET.get('export'):
|
|
||||||
content = context['rendered_config'] or context['error_message']
|
|
||||||
response = HttpResponse(content, content_type='text')
|
|
||||||
filename = f"{instance.name or 'config'}.txt"
|
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
|
||||||
return response
|
|
||||||
|
|
||||||
return render(request, self.get_template_name(), {
|
|
||||||
'object': instance,
|
|
||||||
'tab': self.tab,
|
|
||||||
**context,
|
|
||||||
})
|
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
|
||||||
# Compile context data
|
|
||||||
context_data = instance.get_config_context()
|
|
||||||
context_data.update({'device': instance})
|
|
||||||
|
|
||||||
# Render the config template
|
|
||||||
rendered_config = None
|
|
||||||
error_message = None
|
|
||||||
if config_template := instance.get_config_template():
|
|
||||||
try:
|
|
||||||
rendered_config = config_template.render(context=context_data)
|
|
||||||
except TemplateError as e:
|
|
||||||
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'config_template': config_template,
|
|
||||||
'context_data': context_data,
|
|
||||||
'rendered_config': rendered_config,
|
|
||||||
'error_message': error_message,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Device, 'virtual-machines')
|
@register_model_view(Device, 'virtual-machines')
|
||||||
class DeviceVirtualMachinesView(generic.ObjectChildrenView):
|
class DeviceVirtualMachinesView(generic.ObjectChildrenView):
|
||||||
|
@ -155,7 +155,6 @@ class JournalEntryKindChoices(ChoiceSet):
|
|||||||
class LogLevelChoices(ChoiceSet):
|
class LogLevelChoices(ChoiceSet):
|
||||||
|
|
||||||
LOG_DEBUG = 'debug'
|
LOG_DEBUG = 'debug'
|
||||||
LOG_DEFAULT = 'default'
|
|
||||||
LOG_INFO = 'info'
|
LOG_INFO = 'info'
|
||||||
LOG_SUCCESS = 'success'
|
LOG_SUCCESS = 'success'
|
||||||
LOG_WARNING = 'warning'
|
LOG_WARNING = 'warning'
|
||||||
@ -163,16 +162,15 @@ class LogLevelChoices(ChoiceSet):
|
|||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(LOG_DEBUG, _('Debug'), 'teal'),
|
(LOG_DEBUG, _('Debug'), 'teal'),
|
||||||
(LOG_DEFAULT, _('Default'), 'gray'),
|
|
||||||
(LOG_INFO, _('Info'), 'cyan'),
|
(LOG_INFO, _('Info'), 'cyan'),
|
||||||
(LOG_SUCCESS, _('Success'), 'green'),
|
(LOG_SUCCESS, _('Success'), 'green'),
|
||||||
(LOG_WARNING, _('Warning'), 'yellow'),
|
(LOG_WARNING, _('Warning'), 'yellow'),
|
||||||
(LOG_FAILURE, _('Failure'), 'red'),
|
(LOG_FAILURE, _('Failure'), 'red'),
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SYSTEM_LEVELS = {
|
SYSTEM_LEVELS = {
|
||||||
LOG_DEBUG: logging.DEBUG,
|
LOG_DEBUG: logging.DEBUG,
|
||||||
LOG_DEFAULT: logging.INFO,
|
|
||||||
LOG_INFO: logging.INFO,
|
LOG_INFO: logging.INFO,
|
||||||
LOG_SUCCESS: logging.INFO,
|
LOG_SUCCESS: logging.INFO,
|
||||||
LOG_WARNING: logging.WARNING,
|
LOG_WARNING: logging.WARNING,
|
||||||
@ -180,17 +178,6 @@ class LogLevelChoices(ChoiceSet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DurationChoices(ChoiceSet):
|
|
||||||
|
|
||||||
CHOICES = (
|
|
||||||
(60, _('Hourly')),
|
|
||||||
(720, _('12 hours')),
|
|
||||||
(1440, _('Daily')),
|
|
||||||
(10080, _('Weekly')),
|
|
||||||
(43200, _('30 days')),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Webhooks
|
# Webhooks
|
||||||
#
|
#
|
||||||
|
@ -76,7 +76,7 @@ DEFAULT_DASHBOARD = [
|
|||||||
'height': 4,
|
'height': 4,
|
||||||
'title': 'NetBox News',
|
'title': 'NetBox News',
|
||||||
'config': {
|
'config': {
|
||||||
'feed_url': 'http://netbox.dev/rss/',
|
'feed_url': 'https://api.netbox.oss.netboxlabs.com/v1/newsfeed/',
|
||||||
'max_entries': 10,
|
'max_entries': 10,
|
||||||
'cache_timeout': 14400,
|
'cache_timeout': 14400,
|
||||||
'requires_internet': True,
|
'requires_internet': True,
|
||||||
@ -138,9 +138,8 @@ DEFAULT_DASHBOARD = [
|
|||||||
|
|
||||||
LOG_LEVEL_RANK = {
|
LOG_LEVEL_RANK = {
|
||||||
LogLevelChoices.LOG_DEBUG: 0,
|
LogLevelChoices.LOG_DEBUG: 0,
|
||||||
LogLevelChoices.LOG_DEFAULT: 1,
|
LogLevelChoices.LOG_INFO: 1,
|
||||||
LogLevelChoices.LOG_INFO: 2,
|
LogLevelChoices.LOG_SUCCESS: 2,
|
||||||
LogLevelChoices.LOG_SUCCESS: 3,
|
LogLevelChoices.LOG_WARNING: 3,
|
||||||
LogLevelChoices.LOG_WARNING: 4,
|
LogLevelChoices.LOG_FAILURE: 4,
|
||||||
LogLevelChoices.LOG_FAILURE: 5,
|
|
||||||
}
|
}
|
||||||
|
@ -284,7 +284,8 @@ class RSSFeedWidget(DashboardWidget):
|
|||||||
|
|
||||||
class ConfigForm(WidgetConfigForm):
|
class ConfigForm(WidgetConfigForm):
|
||||||
feed_url = forms.URLField(
|
feed_url = forms.URLField(
|
||||||
label=_('Feed URL')
|
label=_('Feed URL'),
|
||||||
|
assume_scheme='https'
|
||||||
)
|
)
|
||||||
requires_internet = forms.BooleanField(
|
requires_internet = forms.BooleanField(
|
||||||
label=_('Requires external connection'),
|
label=_('Requires external connection'),
|
||||||
@ -314,7 +315,7 @@ class RSSFeedWidget(DashboardWidget):
|
|||||||
return f'dashboard_rss_{url_checksum}'
|
return f'dashboard_rss_{url_checksum}'
|
||||||
|
|
||||||
def get_feed(self):
|
def get_feed(self):
|
||||||
if self.config['requires_internet'] and settings.ISOLATED_DEPLOYMENT:
|
if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
|
||||||
return {
|
return {
|
||||||
'isolated_deployment': True,
|
'isolated_deployment': True,
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,7 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = CustomField
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
@ -115,6 +116,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('base_choices', 'choice', name=_('Choices')),
|
FieldSet('base_choices', 'choice', name=_('Choices')),
|
||||||
@ -129,6 +131,7 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = CustomLink
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
|
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
|
||||||
@ -159,6 +162,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = ExportTemplate
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||||
@ -200,6 +204,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = ImageAttachment
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('object_type_id', 'name', name=_('Attributes')),
|
FieldSet('object_type_id', 'name', name=_('Attributes')),
|
||||||
@ -216,6 +221,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = SavedFilter
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
||||||
@ -314,6 +320,7 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = ConfigContext
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag_id'),
|
FieldSet('q', 'filter_id', 'tag_id'),
|
||||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||||
@ -403,6 +410,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = ConfigTemplate
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||||
@ -469,6 +477,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
|
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
model = NotificationGroup
|
||||||
user_id = DynamicModelMultipleChoiceField(
|
user_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -162,6 +162,7 @@ class CustomFieldForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class CustomFieldChoiceSetForm(forms.ModelForm):
|
class CustomFieldChoiceSetForm(forms.ModelForm):
|
||||||
|
# TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
|
||||||
extra_choices = forms.CharField(
|
extra_choices = forms.CharField(
|
||||||
widget=ChoicesWidget(),
|
widget=ChoicesWidget(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -178,12 +179,25 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, initial=None, **kwargs):
|
def __init__(self, *args, initial=None, **kwargs):
|
||||||
super().__init__(*args, initial=initial, **kwargs)
|
super().__init__(*args, initial=initial, **kwargs)
|
||||||
|
|
||||||
# Escape colons in extra_choices
|
# TODO: The check for str / list below is to handle difference in extra_choices field definition
|
||||||
|
# In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField
|
||||||
|
# if standardize these, we can simplify this code
|
||||||
|
|
||||||
|
# Convert extra_choices Array Field from model to CharField for form
|
||||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||||
choices = []
|
extra_choices = self.initial['extra_choices']
|
||||||
for choice in self.initial['extra_choices']:
|
if isinstance(extra_choices, str):
|
||||||
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
|
extra_choices = [extra_choices]
|
||||||
choices.append(choice)
|
choices = ""
|
||||||
|
for choice in extra_choices:
|
||||||
|
# Setup choices in Add Another use case
|
||||||
|
if isinstance(choice, str):
|
||||||
|
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
|
||||||
|
choices += choice_str + "\n"
|
||||||
|
# Setup choices in Edit use case
|
||||||
|
elif isinstance(choice, list):
|
||||||
|
choice_str = ":".join(choice)
|
||||||
|
choices += choice_str + "\n"
|
||||||
|
|
||||||
self.initial['extra_choices'] = choices
|
self.initial['extra_choices'] = choices
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from extras.choices import DurationChoices
|
from core.choices import JobIntervalChoices
|
||||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||||
from utilities.datetime import local_now
|
from utilities.datetime import local_now
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ class ReportForm(forms.Form):
|
|||||||
min_value=1,
|
min_value=1,
|
||||||
label=_("Recurs every"),
|
label=_("Recurs every"),
|
||||||
widget=NumberWithOptions(
|
widget=NumberWithOptions(
|
||||||
options=DurationChoices
|
options=JobIntervalChoices
|
||||||
),
|
),
|
||||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from extras.choices import DurationChoices
|
from core.choices import JobIntervalChoices
|
||||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||||
from utilities.datetime import local_now
|
from utilities.datetime import local_now
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ class ScriptForm(forms.Form):
|
|||||||
min_value=1,
|
min_value=1,
|
||||||
label=_("Recurs every"),
|
label=_("Recurs every"),
|
||||||
widget=NumberWithOptions(
|
widget=NumberWithOptions(
|
||||||
options=DurationChoices
|
options=JobIntervalChoices
|
||||||
),
|
),
|
||||||
help_text=_("Interval at which this script is re-run (in minutes)")
|
help_text=_("Interval at which this script is re-run (in minutes)")
|
||||||
)
|
)
|
||||||
|
@ -100,7 +100,10 @@ class ScriptJob(JobRunner):
|
|||||||
|
|
||||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||||
# change logging, event rules, etc.
|
# change logging, event rules, etc.
|
||||||
with ExitStack() as stack:
|
if commit:
|
||||||
for request_processor in registry['request_processors']:
|
with ExitStack() as stack:
|
||||||
stack.enter_context(request_processor(request))
|
for request_processor in registry['request_processors']:
|
||||||
|
stack.enter_context(request_processor(request))
|
||||||
|
self.run_script(script, request, data, commit)
|
||||||
|
else:
|
||||||
self.run_script(script, request, data, commit)
|
self.run_script(script, request, data, commit)
|
||||||
|
@ -81,12 +81,17 @@ class Command(BaseCommand):
|
|||||||
logger.error(f'\t{field}: {error.get("message")}')
|
logger.error(f'\t{field}: {error.get("message")}')
|
||||||
raise CommandError()
|
raise CommandError()
|
||||||
|
|
||||||
|
# Remove extra fields from ScriptForm before passng data to script
|
||||||
|
form.cleaned_data.pop('_schedule_at')
|
||||||
|
form.cleaned_data.pop('_interval')
|
||||||
|
form.cleaned_data.pop('_commit')
|
||||||
|
|
||||||
# Execute the script.
|
# Execute the script.
|
||||||
job = ScriptJob.enqueue(
|
job = ScriptJob.enqueue(
|
||||||
instance=script_obj,
|
instance=script_obj,
|
||||||
user=user,
|
user=user,
|
||||||
immediate=True,
|
immediate=True,
|
||||||
data=data,
|
data=form.cleaned_data,
|
||||||
request=NetBoxFakeRequest({
|
request=NetBoxFakeRequest({
|
||||||
'META': {},
|
'META': {},
|
||||||
'POST': data,
|
'POST': data,
|
||||||
|
@ -200,7 +200,7 @@ class ConfigContextModel(models.Model):
|
|||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Verify that JSON data is provided as an object
|
# Verify that JSON data is provided as an object
|
||||||
if self.local_context_data and type(self.local_context_data) is not dict:
|
if self.local_context_data is not None and type(self.local_context_data) is not dict:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||||
)
|
)
|
||||||
|
@ -515,7 +515,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
# URL
|
# URL
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||||
field = LaxURLField(required=required, initial=initial)
|
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
|
||||||
|
|
||||||
# JSON
|
# JSON
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||||
|
@ -120,11 +120,12 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply Location & DeviceType filters only for VirtualMachines
|
||||||
if self.model._meta.model_name == 'device':
|
if self.model._meta.model_name == 'device':
|
||||||
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
|
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
|
||||||
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
|
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
|
||||||
|
|
||||||
elif self.model._meta.model_name == 'virtualmachine':
|
elif self.model._meta.model_name == 'virtualmachine':
|
||||||
|
base_query.add(Q(locations=None), Q.AND)
|
||||||
base_query.add(Q(device_types=None), Q.AND)
|
base_query.add(Q(device_types=None), Q.AND)
|
||||||
|
|
||||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||||
|
@ -15,7 +15,7 @@ class Report(BaseScript):
|
|||||||
|
|
||||||
# There is no generic log() equivalent on BaseScript
|
# There is no generic log() equivalent on BaseScript
|
||||||
def log(self, message):
|
def log(self, message):
|
||||||
self._log(message, None, level=LogLevelChoices.LOG_DEFAULT)
|
self._log(message, None, level=LogLevelChoices.LOG_INFO)
|
||||||
|
|
||||||
def log_success(self, obj=None, message=None):
|
def log_success(self, obj=None, message=None):
|
||||||
super().log_success(message, obj)
|
super().log_success(message, obj)
|
||||||
|
@ -211,10 +211,12 @@ class ObjectVar(ScriptVariable):
|
|||||||
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
|
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
|
||||||
elements within the dropdown menu (optional)
|
elements within the dropdown menu (optional)
|
||||||
:param null_option: The label to use as a "null" selection option (optional)
|
:param null_option: The label to use as a "null" selection option (optional)
|
||||||
|
:param selector: Include an advanced object selection widget to assist the user in identifying the desired
|
||||||
|
object (optional)
|
||||||
"""
|
"""
|
||||||
form_field = DynamicModelChoiceField
|
form_field = DynamicModelChoiceField
|
||||||
|
|
||||||
def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs):
|
def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self.field_attrs.update({
|
self.field_attrs.update({
|
||||||
@ -222,6 +224,7 @@ class ObjectVar(ScriptVariable):
|
|||||||
'query_params': query_params,
|
'query_params': query_params,
|
||||||
'context': context,
|
'context': context,
|
||||||
'null_option': null_option,
|
'null_option': null_option,
|
||||||
|
'selector': selector,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -460,7 +463,7 @@ class BaseScript:
|
|||||||
# Logging
|
# Logging
|
||||||
#
|
#
|
||||||
|
|
||||||
def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
|
def _log(self, message, obj=None, level=LogLevelChoices.LOG_INFO):
|
||||||
"""
|
"""
|
||||||
Log a message. Do not call this method directly; use one of the log_* wrappers below.
|
Log a message. Do not call this method directly; use one of the log_* wrappers below.
|
||||||
"""
|
"""
|
||||||
|
@ -660,8 +660,8 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
CustomField(
|
CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_BOOLEAN,
|
type=CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||||
name='boolean_field',
|
name='boolean_field',
|
||||||
default=False)
|
default=False
|
||||||
,
|
),
|
||||||
CustomField(
|
CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_DATE,
|
type=CustomFieldTypeChoices.TYPE_DATE,
|
||||||
name='date_field',
|
name='date_field',
|
||||||
|
@ -1118,6 +1118,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
'devicerole',
|
'devicerole',
|
||||||
'devicetype',
|
'devicetype',
|
||||||
'dummymodel', # From dummy_plugin
|
'dummymodel', # From dummy_plugin
|
||||||
|
'dummynetboxmodel', # From dummy_plugin
|
||||||
'eventrule',
|
'eventrule',
|
||||||
'fhrpgroup',
|
'fhrpgroup',
|
||||||
'frontport',
|
'frontport',
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from django.forms import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
@ -478,3 +479,30 @@ class ConfigContextTest(TestCase):
|
|||||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
|
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
|
||||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||||
|
|
||||||
|
def test_valid_local_context_data(self):
|
||||||
|
device = Device.objects.first()
|
||||||
|
device.local_context_data = None
|
||||||
|
device.clean()
|
||||||
|
|
||||||
|
device.local_context_data = {"foo": "bar"}
|
||||||
|
device.clean()
|
||||||
|
|
||||||
|
def test_invalid_local_context_data(self):
|
||||||
|
device = Device.objects.first()
|
||||||
|
|
||||||
|
device.local_context_data = ""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
device.clean()
|
||||||
|
|
||||||
|
device.local_context_data = 0
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
device.clean()
|
||||||
|
|
||||||
|
device.local_context_data = False
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
device.clean()
|
||||||
|
|
||||||
|
device.local_context_data = 'foo'
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
device.clean()
|
||||||
|
@ -75,8 +75,11 @@ urlpatterns = [
|
|||||||
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
|
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
|
||||||
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||||
path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
|
path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
|
||||||
|
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||||
path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||||
|
path('scripts/<str:module>.<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||||
path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||||
|
path('scripts/<str:module>.<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||||
path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
|
path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
|
||||||
|
|
||||||
# Markdown
|
# Markdown
|
||||||
|
@ -10,6 +10,7 @@ from django.utils import timezone
|
|||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
from jinja2.exceptions import TemplateError
|
||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
from core.forms import ManagedFileForm
|
from core.forms import ManagedFileForm
|
||||||
@ -885,6 +886,61 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
|||||||
queryset = ConfigTemplate.objects.all()
|
queryset = ConfigTemplate.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectRenderConfigView(generic.ObjectView):
|
||||||
|
base_template = None
|
||||||
|
template_name = 'extras/object_render_config.html'
|
||||||
|
|
||||||
|
def get(self, request, **kwargs):
|
||||||
|
instance = self.get_object(**kwargs)
|
||||||
|
context = self.get_extra_context(request, instance)
|
||||||
|
|
||||||
|
# If a direct export has been requested, return the rendered template content as a
|
||||||
|
# downloadable file.
|
||||||
|
if request.GET.get('export'):
|
||||||
|
content = context['rendered_config'] or context['error_message']
|
||||||
|
response = HttpResponse(content, content_type='text')
|
||||||
|
filename = f"{instance.name or 'config'}.txt"
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.get_template_name(),
|
||||||
|
{
|
||||||
|
'object': instance,
|
||||||
|
'tab': self.tab,
|
||||||
|
**context,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_extra_context_data(self, request, instance):
|
||||||
|
return {
|
||||||
|
f'{instance._meta.model_name}': instance,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
# Compile context data
|
||||||
|
context_data = instance.get_config_context()
|
||||||
|
context_data.update(self.get_extra_context_data(request, instance))
|
||||||
|
|
||||||
|
# Render the config template
|
||||||
|
rendered_config = None
|
||||||
|
error_message = None
|
||||||
|
if config_template := instance.get_config_template():
|
||||||
|
try:
|
||||||
|
rendered_config = config_template.render(context=context_data)
|
||||||
|
except TemplateError as e:
|
||||||
|
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'base_template': self.base_template,
|
||||||
|
'config_template': config_template,
|
||||||
|
'context_data': context_data,
|
||||||
|
'rendered_config': rendered_config,
|
||||||
|
'error_message': error_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Image attachments
|
# Image attachments
|
||||||
#
|
#
|
||||||
@ -1195,6 +1251,14 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
|||||||
class BaseScriptView(generic.ObjectView):
|
class BaseScriptView(generic.ObjectView):
|
||||||
queryset = Script.objects.all()
|
queryset = Script.objects.all()
|
||||||
|
|
||||||
|
def get_object(self, **kwargs):
|
||||||
|
if pk := kwargs.get('pk', False):
|
||||||
|
return get_object_or_404(self.queryset, pk=pk)
|
||||||
|
elif (module := kwargs.get('module')) and (name := kwargs.get('name', False)):
|
||||||
|
return get_object_or_404(self.queryset, module__file_path=f'{module}.py', name=name)
|
||||||
|
else:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
def _get_script_class(self, script):
|
def _get_script_class(self, script):
|
||||||
"""
|
"""
|
||||||
Return an instance of the Script's Python class
|
Return an instance of the Script's Python class
|
||||||
@ -1315,9 +1379,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
|||||||
index = 0
|
index = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)]
|
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_DEBUG]
|
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_INFO]
|
||||||
if job.data:
|
if job.data:
|
||||||
|
|
||||||
if 'log' in job.data:
|
if 'log' in job.data:
|
||||||
@ -1325,7 +1389,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
|||||||
tests = job.data['tests']
|
tests = job.data['tests']
|
||||||
|
|
||||||
for log in job.data['log']:
|
for log in job.data['log']:
|
||||||
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_DEFAULT)
|
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_INFO)
|
||||||
if log_level >= log_threshold:
|
if log_level >= log_threshold:
|
||||||
index += 1
|
index += 1
|
||||||
result = {
|
result = {
|
||||||
@ -1348,7 +1412,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
|||||||
for method, test_data in tests.items():
|
for method, test_data in tests.items():
|
||||||
if 'log' in test_data:
|
if 'log' in test_data:
|
||||||
for time, status, obj, url, message in test_data['log']:
|
for time, status, obj, url, message in test_data['log']:
|
||||||
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_DEFAULT)
|
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_INFO)
|
||||||
if log_level >= log_threshold:
|
if log_level >= log_threshold:
|
||||||
index += 1
|
index += 1
|
||||||
result = {
|
result = {
|
||||||
@ -1374,9 +1438,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
|||||||
if job.completed:
|
if job.completed:
|
||||||
table = self.get_table(job, request, bulk_actions=False)
|
table = self.get_table(job, request, bulk_actions=False)
|
||||||
|
|
||||||
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)
|
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
|
||||||
if log_threshold not in LOG_LEVEL_RANK:
|
if log_threshold not in LOG_LEVEL_RANK:
|
||||||
log_threshold = LogLevelChoices.LOG_DEBUG
|
log_threshold = LogLevelChoices.LOG_INFO
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'script': job.object,
|
'script': job.object,
|
||||||
|
@ -12,7 +12,8 @@ from netaddr.core import AddrFormatError
|
|||||||
from circuits.models import Provider
|
from circuits.models import Provider
|
||||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||||
from tenancy.filtersets import TenancyFilterSet
|
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||||
|
|
||||||
from utilities.filters import (
|
from utilities.filters import (
|
||||||
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||||
)
|
)
|
||||||
@ -148,7 +149,7 @@ class RIRFilterSet(OrganizationalModelFilterSet):
|
|||||||
fields = ('id', 'name', 'slug', 'is_private', 'description')
|
fields = ('id', 'name', 'slug', 'is_private', 'description')
|
||||||
|
|
||||||
|
|
||||||
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||||
family = django_filters.NumberFilter(
|
family = django_filters.NumberFilter(
|
||||||
field_name='prefix',
|
field_name='prefix',
|
||||||
lookup_expr='family'
|
lookup_expr='family'
|
||||||
@ -276,7 +277,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
|||||||
fields = ('id', 'name', 'slug', 'description', 'weight')
|
fields = ('id', 'name', 'slug', 'description', 'weight')
|
||||||
|
|
||||||
|
|
||||||
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||||
family = django_filters.NumberFilter(
|
family = django_filters.NumberFilter(
|
||||||
field_name='prefix',
|
field_name='prefix',
|
||||||
lookup_expr='family'
|
lookup_expr='family'
|
||||||
@ -430,7 +431,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
|||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet):
|
||||||
family = django_filters.NumberFilter(
|
family = django_filters.NumberFilter(
|
||||||
field_name='start_address',
|
field_name='start_address',
|
||||||
lookup_expr='family'
|
lookup_expr='family'
|
||||||
@ -522,7 +523,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
return queryset.filter(q)
|
return queryset.filter(q)
|
||||||
|
|
||||||
|
|
||||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||||
family = django_filters.NumberFilter(
|
family = django_filters.NumberFilter(
|
||||||
field_name='address',
|
field_name='address',
|
||||||
lookup_expr='family'
|
lookup_expr='family'
|
||||||
@ -1136,7 +1137,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
|
|||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class ServiceFilterSet(NetBoxModelFilterSet):
|
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
|
||||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
label=_('Device (ID)'),
|
label=_('Device (ID)'),
|
||||||
|
@ -177,6 +177,13 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=_("VLAN's group (if any)")
|
help_text=_("VLAN's group (if any)")
|
||||||
)
|
)
|
||||||
|
vlan_site = CSVModelChoiceField(
|
||||||
|
label=_('VLAN Site'),
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text=_("VLAN's site (if any)")
|
||||||
|
)
|
||||||
vlan = CSVModelChoiceField(
|
vlan = CSVModelChoiceField(
|
||||||
label=_('VLAN'),
|
label=_('VLAN'),
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
@ -200,8 +207,8 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Prefix
|
model = Prefix
|
||||||
fields = (
|
fields = (
|
||||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool',
|
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
|
||||||
'mark_utilized', 'description', 'comments', 'tags',
|
'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
|
||||||
)
|
)
|
||||||
labels = {
|
labels = {
|
||||||
'scope_id': _('Scope ID'),
|
'scope_id': _('Scope ID'),
|
||||||
@ -213,19 +220,19 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
|||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
site = data.get('site')
|
vlan_site = data.get('vlan_site')
|
||||||
vlan_group = data.get('vlan_group')
|
vlan_group = data.get('vlan_group')
|
||||||
|
|
||||||
# Limit VLAN queryset by assigned site and/or group (if specified)
|
# Limit VLAN queryset by assigned site and/or group (if specified)
|
||||||
query = Q()
|
query = Q()
|
||||||
|
|
||||||
if site:
|
if vlan_site:
|
||||||
query |= Q(**{
|
query |= Q(**{
|
||||||
f"site__{self.fields['site'].to_field_name}": site
|
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
||||||
})
|
})
|
||||||
# Don't Forget to include VLANs without a site in the filter
|
# Don't Forget to include VLANs without a site in the filter
|
||||||
query |= Q(**{
|
query |= Q(**{
|
||||||
f"site__{self.fields['site'].to_field_name}__isnull": True
|
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
|
||||||
})
|
})
|
||||||
|
|
||||||
if vlan_group:
|
if vlan_group:
|
||||||
|
@ -6,7 +6,7 @@ from ipam.choices import *
|
|||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import TenancyFilterForm
|
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
|
||||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
@ -94,12 +94,13 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('family', 'rir_id', name=_('Attributes')),
|
FieldSet('family', 'rir_id', name=_('Attributes')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
|
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||||
)
|
)
|
||||||
family = forms.ChoiceField(
|
family = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -162,7 +163,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm, ):
|
||||||
model = Prefix
|
model = Prefix
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
@ -174,6 +175,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
|
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||||
)
|
)
|
||||||
mask_length__lte = forms.IntegerField(
|
mask_length__lte = forms.IntegerField(
|
||||||
widget=forms.HiddenInput()
|
widget=forms.HiddenInput()
|
||||||
@ -262,12 +264,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||||
model = IPRange
|
model = IPRange
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
|
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
|
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||||
)
|
)
|
||||||
family = forms.ChoiceField(
|
family = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -301,7 +304,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
@ -312,6 +315,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
|
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
|
||||||
|
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||||
)
|
)
|
||||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||||
parent = forms.CharField(
|
parent = forms.CharField(
|
||||||
@ -590,12 +594,13 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
|
||||||
model = Service
|
model = Service
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('protocol', 'port', name=_('Attributes')),
|
FieldSet('protocol', 'port', name=_('Attributes')),
|
||||||
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
|
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
|
||||||
|
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||||
)
|
)
|
||||||
device_id = DynamicModelMultipleChoiceField(
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user