mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-26 18:38:38 -06:00
Merge branch 'feature' into 18783-tag_id-filter
This commit is contained in:
commit
fcdf41b1fa
@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.3
|
||||
placeholder: v4.2.7
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.3
|
||||
placeholder: v4.2.7
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -12,6 +12,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
|
1
.github/workflows/close-stale-issues.yml
vendored
1
.github/workflows/close-stale-issues.yml
vendored
@ -13,6 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
|
1
.github/workflows/lock-threads.yml
vendored
1
.github/workflows/lock-threads.yml
vendored
@ -13,6 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
|
@ -13,6 +13,7 @@ env:
|
||||
|
||||
jobs:
|
||||
makemessages:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NETBOX_CONFIGURATION: netbox.configuration_testing
|
||||
|
@ -1,7 +1,7 @@
|
||||
[main]
|
||||
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
|
||||
source_file = netbox/translations/en/LC_MESSAGES/django.po
|
||||
type = PO
|
||||
|
@ -8,7 +8,10 @@ django-cors-headers
|
||||
|
||||
# Runtime UI tool for debugging Django
|
||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||
django-debug-toolbar
|
||||
# See: https://django-debug-toolbar.readthedocs.io/en/latest/changes.html#id1
|
||||
# "Wrap SHOW_TOOLBAR_CALLBACK function with sync_to_async or async_to_sync to allow sync/async
|
||||
# compatibility." breaks stawberry-graphql-django at version 0.52.0 (current)
|
||||
django-debug-toolbar==5.0.1
|
||||
|
||||
# Library for writing reusable URL query filters
|
||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||
@ -96,8 +99,7 @@ mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
||||
# See #18568
|
||||
mkdocstrings[python-legacy]==0.27.0
|
||||
mkdocstrings[python]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
|
@ -427,6 +427,7 @@
|
||||
"e3",
|
||||
"xdsl",
|
||||
"docsis",
|
||||
"moca",
|
||||
"bpon",
|
||||
"epon",
|
||||
"10g-epon",
|
||||
@ -500,6 +501,9 @@
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"fc-pc",
|
||||
"fc-upc",
|
||||
"fc-apc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
@ -565,6 +569,9 @@
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"fc-pc",
|
||||
"fc-upc",
|
||||
"fc-apc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
|
@ -54,6 +54,7 @@ Icons](https://github.com/google/material-design-icons) icon's name; or be
|
||||
`None` for no icon.
|
||||
|
||||
For instance, the OIDC backend may be customized with
|
||||
|
||||
```python
|
||||
SOCIAL_AUTH_BACKEND_ATTRS = {
|
||||
'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)
|
||||
|
||||
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.).
|
||||
|
@ -310,6 +310,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)
|
||||
* `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)
|
||||
* `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:
|
||||
|
||||
|
@ -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`.
|
||||
|
||||
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
|
||||
|
||||
```
|
||||
{% for server in queryset %}
|
||||
{% set data = server.get_config_context() %}
|
||||
|
@ -1,12 +1,14 @@
|
||||
# Release Checklist
|
||||
|
||||
This documentation describes the process of packaging and publishing a new NetBox release. There are three types of release:
|
||||
This documentation describes the process of packaging and publishing a new NetBox release. There are three types of releases:
|
||||
|
||||
* Major release (e.g. v3.7.8 to v4.0.0)
|
||||
* Minor release (e.g. v4.0.10 to v4.1.0)
|
||||
* Patch release (e.g. v4.1.0 to v4.1.1)
|
||||
|
||||
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 changes 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
|
||||
|
||||
@ -29,6 +31,29 @@ Close the [release milestone](https://github.com/netbox-community/netbox/milesto
|
||||
|
||||
Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`.
|
||||
|
||||
### Update the Dependency Requirements Matrix
|
||||
|
||||
For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis:
|
||||
|
||||
1. Add a new row with the supported dependency versions.
|
||||
2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md`
|
||||
3. Bold any version changes for clarity.
|
||||
|
||||
**Example Update:**
|
||||
|
||||
```markdown
|
||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
||||
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||
```
|
||||
|
||||
### Update System Requirements
|
||||
|
||||
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
|
||||
|
||||
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
||||
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
|
||||
|
||||
### Manually Perform a New Install
|
||||
|
||||
Start the documentation server and navigate to the current version of the installation docs:
|
||||
@ -37,15 +62,25 @@ Start the documentation server and navigate to the current version of the instal
|
||||
mkdocs serve
|
||||
```
|
||||
|
||||
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release.
|
||||
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release.
|
||||
|
||||
### Test Upgrade Paths
|
||||
|
||||
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.
|
||||
Test the following supported upgrade paths:
|
||||
|
||||
- From one minor version to another within the same major version (e.g. 4.0 to 4.1).
|
||||
- From the latest patch version of the previous minor version (e.g. 3.7 to 4.0 or 4.1).
|
||||
|
||||
Prior to release, test all these supported paths by loading demo data from the source version and performing:
|
||||
|
||||
```no-highlight
|
||||
./manage.py migrate
|
||||
```
|
||||
|
||||
### Merge the `feature` Branch
|
||||
|
||||
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.
|
||||
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 the patch releases below.
|
||||
|
||||
### Rebuild Demo Data (After Release)
|
||||
|
||||
@ -57,7 +92,7 @@ After the release of a new minor version, generate a new demo data snapshot comp
|
||||
|
||||
### 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.
|
||||
Begin by creating a new branch (based on `main`) to effect the release. This will comprise the changes listed below.
|
||||
|
||||
```
|
||||
git checkout main
|
||||
@ -85,7 +120,20 @@ In cases where upgrading a dependency to its most recent release is breaking, it
|
||||
|
||||
### 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
|
||||
|
||||
@ -116,9 +164,12 @@ Then, compile these portable (`.po`) files for use in the application:
|
||||
|
||||
### 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/`.
|
||||
* 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.
|
||||
|
||||
!!! 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 keywords or phrases (such as exception names) that can be easily searched.
|
||||
|
||||
### Submit a Pull Request
|
||||
|
||||
@ -126,6 +177,9 @@ Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** t
|
||||
|
||||
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](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.
|
||||
|
@ -22,7 +22,7 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
|
||||
|
||||
### 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/
|
||||
|
@ -30,7 +30,7 @@ To download translated strings automatically, you'll need to:
|
||||
1. Install the [Transifex CLI client](https://github.com/transifex/cli)
|
||||
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
|
||||
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.)
|
||||
|
||||
!!! tip
|
||||
Run `git status` to check that both `*.mo` & `*.po` files have been updated as expected.
|
||||
|
||||
## 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.
|
||||
|
@ -17,11 +17,51 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
|
||||
|
||||
NetBox requires the following dependencies:
|
||||
|
||||
| Dependency | Supported Versions |
|
||||
|------------|--------------------|
|
||||
| Python | 3.10, 3.11, 3.12 |
|
||||
| PostgreSQL | 14+ |
|
||||
| Redis | 4.0+ |
|
||||
### "Current Version"
|
||||
|
||||
| Dependency | Supported Versions |
|
||||
|------------|--------------------|
|
||||
| Python | 3.10, 3.11, 3.12 |
|
||||
| PostgreSQL | 14+ |
|
||||
| Redis | 4.0+ |
|
||||
|
||||
### "All Versions"
|
||||
|
||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
||||
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
|
||||
| 4.0 | **3.10** | **3.12** | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
|
||||
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
|
||||
| 3.6 | 3.8 | **3.11** | **12** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
|
||||
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
|
||||
| 3.4 | 3.8 | 3.10 | **11** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
|
||||
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
|
||||
| 3.2 | **3.8** | **3.10** | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
|
||||
| 3.1 | 3.7 | 3.9 | **10** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
|
||||
| 3.0 | **3.7** | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
|
||||
| 2.11 | 3.6 | **3.9** | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
|
||||
| 2.10 | 3.6 | 3.8 | **9.6** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
|
||||
| 2.9 | 3.6 | 3.8 | 9.5 | **4.0** | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
|
||||
| 2.8 | **3.6** | **3.8** | **9.5** | **3.4** | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
|
||||
| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
|
||||
| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
|
||||
| 2.5 | **3.5** | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
|
||||
| 2.4 | **3.4** | **3.7** | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
|
||||
| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
|
||||
| 2.2 | 2.7 | 3.6 | **9.4** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
|
||||
| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
|
||||
| 2.0 | 2.7 | **3.6** | **9.3** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
|
||||
| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
|
||||
| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
|
||||
| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
|
||||
| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
|
||||
| 1.5 | 2.7 | 3.5 | **9.2** | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
|
||||
| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
|
||||
| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
|
||||
| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
|
||||
| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
|
||||
| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
|
||||
|
||||
## 3. Install the Latest Release
|
||||
|
||||
|
@ -127,8 +127,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".
|
||||
|
||||
## Pagination
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
* `POST`: Create an object
|
||||
|
43
docs/models/extras/tableconfig.md
Normal file
43
docs/models/extras/tableconfig.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Table Configs
|
||||
|
||||
This object represents the saved configuration of an object table in NetBox. Table configs can be crafted, saved, and shared among users to apply specific views within object lists. Each table config can specify which table columns to display, the order in which to display them, and which columns are used for sorting.
|
||||
|
||||
For example, you might wish to create a table config for the devices list to assist in inventory tasks. This view might show the device name, location, serial number, and asset tag, but omit operational details like IP addresses. Once applied, this table config can be saved for reuse in future audits.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A human-friendly name for the table config.
|
||||
|
||||
### User
|
||||
|
||||
The user to which this filter belongs. The current user will be assigned automatically when saving a table config via the UI, and cannot be changed.
|
||||
|
||||
### Object Type
|
||||
|
||||
The type of NetBox object to which the table config pertains.
|
||||
|
||||
### Table
|
||||
|
||||
The name of the specific table to which the table config pertains. (Some NetBox object use multiple tables.)
|
||||
|
||||
### Weight
|
||||
|
||||
A numeric weight used to influence the order in which table configs are listed. Table configs with a lower weight will be listed before those with a higher weight. Table configs having the same weight will be ordered alphabetically.
|
||||
|
||||
### Enabled
|
||||
|
||||
Determines whether this table config can be used. Disabled table configs will not appear as options in the UI, however they will be included in API results.
|
||||
|
||||
### Shared
|
||||
|
||||
Determines whether this table config is intended for use by all users or only its owner. Note that deselecting this option does **not** hide the table config from other users; it is merely excluded from the list of available table configs in UI object list views.
|
||||
|
||||
### Ordering
|
||||
|
||||
A list of column names by which the table is to be ordered. If left blank, the table's default ordering will be used.
|
||||
|
||||
### Columns
|
||||
|
||||
A list of columns to be displayed in the table. The table will render these columns in the order they appear in the list. At least one column must be selected.
|
@ -6,6 +6,15 @@ To aid in the efficient creation of services, users may opt to first create a [s
|
||||
|
||||
## Fields
|
||||
|
||||
### Parent
|
||||
|
||||
The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md),
|
||||
[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
|
||||
|
||||
!!! note "Changed in NetBox v4.3"
|
||||
|
||||
Previously, `parent` was a property that pointed to either a Device or Virtual Machine. With the capability to assign services to FHRP groups, this is a unified in a concrete field.
|
||||
|
||||
### Name
|
||||
|
||||
A service or protocol name.
|
||||
|
@ -205,6 +205,7 @@ To ease development, it is recommended to go ahead and install the plugin at thi
|
||||
```no-highlight
|
||||
$ pip install -e .
|
||||
```
|
||||
|
||||
More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
|
||||
|
||||
## 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`
|
||||
* 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 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
|
||||
|
@ -1,5 +1,127 @@
|
||||
# NetBox v4.2
|
||||
|
||||
## v4.2.7 (2025-04-10)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#16144](https://github.com/netbox-community/netbox/issues/16144) - Add support for plugin models to GetReturnURLMixin
|
||||
* [#18138](https://github.com/netbox-community/netbox/issues/18138) - Enable filtering of ObjectVar and MultiObjectVar input selections for custom fields
|
||||
* [#18656](https://github.com/netbox-community/netbox/issues/18656) - Enable FHRP group assignment when bulk importing IP addresses
|
||||
* [#18980](https://github.com/netbox-community/netbox/issues/18980) - Optimize bulk updates of custom field values when custom fields are added/removed
|
||||
* [#19018](https://github.com/netbox-community/netbox/issues/19018) - Add MoCA interface type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#18553](https://github.com/netbox-community/netbox/issues/18553) - Avoid clearing site of assigned virtual machines when editing a cluster
|
||||
* [#18738](https://github.com/netbox-community/netbox/issues/18738) - Respect declared ordering of custom scripts within a module
|
||||
* [#18895](https://github.com/netbox-community/netbox/issues/18895) - Fix GraphQL support for interfaces which terminate virtual circuits
|
||||
* [#18904](https://github.com/netbox-community/netbox/issues/18904) - Add missing tags column to config contexts table
|
||||
* [#18964](https://github.com/netbox-community/netbox/issues/18964) - Fix "select all" behavior on object lists
|
||||
* [#18965](https://github.com/netbox-community/netbox/issues/18965) - "Run script" button should respect default commit toggle for custom scripts
|
||||
* [#18991](https://github.com/netbox-community/netbox/issues/18991) - Fix cable path tracing for pass-through ports in REST API
|
||||
* [#18999](https://github.com/netbox-community/netbox/issues/18999) - Fix filtering of inventory items with no manufacturer in GraphQL API
|
||||
* [#19021](https://github.com/netbox-community/netbox/issues/19021) - Preserve JSONField stylign when `help_text` is passed
|
||||
* [#19023](https://github.com/netbox-community/netbox/issues/19023) - `get_field_value()` should honor null values on bound form fields
|
||||
* [#19030](https://github.com/netbox-community/netbox/issues/19030) - Prevent pagination buttons from overlapping bulk action buttons on object lists
|
||||
* [#19041](https://github.com/netbox-community/netbox/issues/19041) - Fix `IndexError` exception when creating multiple front ports with a label
|
||||
* [#19092](https://github.com/netbox-community/netbox/issues/19092) - Fix clearing of scope field when bulk editing prefixes
|
||||
* [#19122](https://github.com/netbox-community/netbox/issues/19122) - Fix styling of server error page
|
||||
|
||||
---
|
||||
|
||||
## v4.2.6 (2025-03-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17503](https://github.com/netbox-community/netbox/issues/17503) - Add rack title above rack on rack detail view
|
||||
* [#17686](https://github.com/netbox-community/netbox/issues/17686) - Add config option for disk space divisor
|
||||
* [#18579](https://github.com/netbox-community/netbox/issues/18579) - Update filtersets and filter forms to include contact filters where missing
|
||||
* [#18744](https://github.com/netbox-community/netbox/issues/18744) - Ensure contact link in tables is hyperlinked
|
||||
* [#18816](https://github.com/netbox-community/netbox/issues/18816) - Add FC/UPC, FC/APC and FC/PC port types
|
||||
* [#18880](https://github.com/netbox-community/netbox/issues/18880) - Delay enqueuing background tasks until DB transaction is committed to avoid race condition
|
||||
* [#18939](https://github.com/netbox-community/netbox/issues/18939) - Support site group search for ASNs
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#18409](https://github.com/netbox-community/netbox/issues/18409) - Eliminate N+1 issue by adding generic prefetch operation to Interface API endpoint
|
||||
* [#18557](https://github.com/netbox-community/netbox/issues/18557) - Update JSONField to enclose bare string values in quotes
|
||||
* [#18582](https://github.com/netbox-community/netbox/issues/18582) - Fix prefix bulk import with associated VLAN and conflicting VLAN IDs
|
||||
* [#18742](https://github.com/netbox-community/netbox/issues/18742) - Ensure location list and detail views show related VLAN group information
|
||||
* [#18782](https://github.com/netbox-community/netbox/issues/18782) - Ensure misconfigured object list widgets on the dashboard now degrade gracefully
|
||||
* [#18833](https://github.com/netbox-community/netbox/issues/18833) - Fix inventory item bulk edit to ensure that component name and type are both validated Ensure
|
||||
* [#18838](https://github.com/netbox-community/netbox/issues/18838) - Ensure that local context data correctly rejects falsy values
|
||||
* [#18845](https://github.com/netbox-community/netbox/issues/18845) - Restore default sort behavior of name column on devices list view
|
||||
* [#18863](https://github.com/netbox-community/netbox/issues/18863) - Exempt MPTT-based models from ordering fix introduced in #18279
|
||||
* [#18869](https://github.com/netbox-community/netbox/issues/18869) - Ensure numeric conversion helper always return a clean decimal value
|
||||
* [#18872](https://github.com/netbox-community/netbox/issues/18872) - Ensure that `kind` is a required field when making journal entries
|
||||
* [#18884](https://github.com/netbox-community/netbox/issues/18884) - Ensure tag deserialization is handled correctly
|
||||
* [#18887](https://github.com/netbox-community/netbox/issues/18887) - Allow VM interface objects to be set on prefix object-type custom field
|
||||
* [#18926](https://github.com/netbox-community/netbox/issues/18926) - Fix icon displayed for GitHub authentication on login page
|
||||
* [#18928](https://github.com/netbox-community/netbox/issues/18928) - Support cascading deletions when cleaning up expired changelog records
|
||||
* [#18933](https://github.com/netbox-community/netbox/issues/18933) - Allow filtering VLAN groups by associated site groups
|
||||
* [#18944](https://github.com/netbox-community/netbox/issues/18944) - Ensure clearing "Widget type" field when adding widgets to dashboard does not cause a "ValueError: Unregistered widget class" error
|
||||
* [#18949](https://github.com/netbox-community/netbox/issues/18949) - Add missing contacts property to GraphQL types where the associated model has a connection to a contact
|
||||
|
||||
---
|
||||
|
||||
## v4.2.5 (2025-03-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#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
|
||||
|
@ -59,6 +59,8 @@ markdown_extensions:
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
not_in_nav: |
|
||||
/index.md
|
||||
nav:
|
||||
- Introduction: 'introduction.md'
|
||||
- Features:
|
||||
@ -233,6 +235,7 @@ nav:
|
||||
- NotificationGroup: 'models/extras/notificationgroup.md'
|
||||
- SavedFilter: 'models/extras/savedfilter.md'
|
||||
- Subscription: 'models/extras/subscription.md'
|
||||
- TableConfig: 'models/extras/tableconfig.md'
|
||||
- Tag: 'models/extras/tag.md'
|
||||
- Webhook: 'models/extras/webhook.md'
|
||||
- IPAM:
|
||||
|
@ -95,7 +95,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ProviderAccountFilterSet(NetBoxModelFilterSet):
|
||||
class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
label=_('Provider (ID)'),
|
||||
@ -234,6 +234,11 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
to_field_name='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(
|
||||
queryset=CircuitTermination.objects.all(),
|
||||
label=_('Termination A (ID)'),
|
||||
|
@ -66,11 +66,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
|
||||
class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ProviderAccount
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'account', name=_('Attributes')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
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',
|
||||
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('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
@ -181,6 +182,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location')
|
||||
)
|
||||
install_date = forms.DateField(
|
||||
label=_('Install date'),
|
||||
required=False,
|
||||
@ -322,7 +328,7 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
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')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
|
||||
|
@ -49,7 +49,7 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
|
||||
filters=ProviderAccountFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ProviderAccountType(NetBoxObjectType):
|
||||
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
|
||||
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
|
||||
|
||||
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
|
||||
|
@ -347,9 +347,8 @@ class CircuitTermination(
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Must define either site *or* provider network
|
||||
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):
|
||||
# Cache objects associated with the terminating object (for filtering)
|
||||
|
@ -111,7 +111,7 @@ class CircuitTerminationTable(NetBoxTable):
|
||||
provider = tables.Column(
|
||||
verbose_name=_('Provider'),
|
||||
linkify=True,
|
||||
accessor='circuit.provider'
|
||||
accessor='circuit__provider'
|
||||
)
|
||||
term_side = tables.Column(
|
||||
verbose_name=_('Side')
|
||||
|
@ -23,7 +23,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Accounts')
|
||||
)
|
||||
account_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('accounts__count'),
|
||||
viewname='circuits:provideraccount_list',
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name=_('Account Count')
|
||||
@ -33,7 +32,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('ASNs')
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
|
@ -3,8 +3,10 @@ from django.test import TestCase
|
||||
from circuits.choices import *
|
||||
from circuits.filtersets import *
|
||||
from circuits.models import *
|
||||
from dcim.choices import InterfaceTypeChoices
|
||||
from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
|
||||
from dcim.choices import InterfaceTypeChoices, LocationStatusChoices
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
|
||||
)
|
||||
from ipam.models import ASN, RIR
|
||||
from netbox.choices import DistanceUnitChoices
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@ -225,6 +227,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
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 = (
|
||||
Circuit(
|
||||
provider=providers[0],
|
||||
@ -305,7 +318,9 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
circuit_terminations = ((
|
||||
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=locations[1], term_side='Z'),
|
||||
CircuitTermination(circuit=circuits[2], termination=sites[2], 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'),
|
||||
@ -395,6 +410,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
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):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
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 dcim.views import PathTraceView
|
||||
from ipam.models import ASN
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.query import count_related
|
||||
@ -19,7 +20,9 @@ from .models import *
|
||||
@register_model_view(Provider, 'list', path='', detail=False)
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
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_form = forms.ProviderFilterForm
|
||||
|
@ -2,12 +2,13 @@ import re
|
||||
import typing
|
||||
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.plumbing import (
|
||||
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import Direction
|
||||
|
||||
from netbox.api.fields import ChoiceField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
@ -277,3 +278,40 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
|
||||
return component.ref if component else None
|
||||
else:
|
||||
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
|
||||
|
@ -3,7 +3,10 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.events import *
|
||||
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
||||
@ -27,6 +30,15 @@ class CoreConfig(AppConfig):
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# Register core events
|
||||
EventType(OBJECT_CREATED, _('Object created')).register()
|
||||
EventType(OBJECT_UPDATED, _('Object updated')).register()
|
||||
EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register()
|
||||
EventType(JOB_STARTED, _('Job started')).register()
|
||||
EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
|
||||
EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
|
||||
EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()
|
||||
|
||||
# Clear Redis cache on startup in development mode
|
||||
if settings.DEBUG:
|
||||
try:
|
||||
|
@ -81,8 +81,10 @@ class JobIntervalChoices(ChoiceSet):
|
||||
CHOICES = (
|
||||
(INTERVAL_MINUTELY, _('Minutely')),
|
||||
(INTERVAL_HOURLY, _('Hourly')),
|
||||
(INTERVAL_HOURLY * 12, _('12 hours')),
|
||||
(INTERVAL_DAILY, _('Daily')),
|
||||
(INTERVAL_WEEKLY, _('Weekly')),
|
||||
(INTERVAL_DAILY * 30, _('30 days')),
|
||||
)
|
||||
|
||||
|
||||
|
@ -1,7 +1,3 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
|
||||
|
||||
__all__ = (
|
||||
'JOB_COMPLETED',
|
||||
'JOB_ERRORED',
|
||||
@ -22,12 +18,3 @@ JOB_STARTED = 'job_started'
|
||||
JOB_COMPLETED = 'job_completed'
|
||||
JOB_FAILED = 'job_failed'
|
||||
JOB_ERRORED = 'job_errored'
|
||||
|
||||
# Register core events
|
||||
EventType(OBJECT_CREATED, _('Object created')).register()
|
||||
EventType(OBJECT_UPDATED, _('Object updated')).register()
|
||||
EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register()
|
||||
EventType(JOB_STARTED, _('Job started')).register()
|
||||
EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
|
||||
EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
|
||||
EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()
|
||||
|
@ -67,6 +67,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
|
||||
class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = Job
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'status', name=_('Attributes')),
|
||||
@ -167,6 +168,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigRevision
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
)
|
||||
|
@ -1,11 +1,12 @@
|
||||
import uuid
|
||||
from functools import partial
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
@ -258,10 +259,12 @@ class Job(models.Model):
|
||||
|
||||
# Schedule the job to run at a specific date & time.
|
||||
elif schedule_at:
|
||||
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
callback = partial(queue.enqueue_at, schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
transaction.on_commit(callback)
|
||||
|
||||
# Schedule the job to run asynchronously at this first available opportunity.
|
||||
else:
|
||||
queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
callback = partial(queue.enqueue, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
transaction.on_commit(callback)
|
||||
|
||||
return job
|
||||
|
@ -87,6 +87,13 @@ def get_local_plugins(plugins=None):
|
||||
if plugin_config.release_track:
|
||||
installed_version = f'{installed_version}-{plugin_config.release_track}'
|
||||
|
||||
if plugin_config.author:
|
||||
author = PluginAuthor(
|
||||
name=plugin_config.author,
|
||||
)
|
||||
else:
|
||||
author = None
|
||||
|
||||
local_plugins[plugin_config.name] = Plugin(
|
||||
config_name=plugin_config.name,
|
||||
title_short=plugin_config.verbose_name,
|
||||
@ -98,6 +105,7 @@ def get_local_plugins(plugins=None):
|
||||
installed_version=installed_version,
|
||||
netbox_min_version=plugin_config.min_version,
|
||||
netbox_max_version=plugin_config.max_version,
|
||||
author=author,
|
||||
)
|
||||
|
||||
# Update catalog entries for local plugins, or add them to the list if not listed
|
||||
|
@ -166,7 +166,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(Job, 'list', path='', detail=False)
|
||||
class JobListView(generic.ObjectListView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
filterset = filtersets.JobFilterSet
|
||||
filterset_form = forms.JobFilterForm
|
||||
table = tables.JobTable
|
||||
@ -183,12 +183,12 @@ class JobView(generic.ObjectView):
|
||||
|
||||
@register_model_view(Job, 'delete')
|
||||
class JobDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
|
||||
|
||||
@register_model_view(Job, 'bulk_delete', path='delete', detail=False)
|
||||
class JobBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
filterset = filtersets.JobFilterSet
|
||||
table = tables.JobTable
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.prefetch import GenericPrefetch
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@ -448,7 +449,18 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'_path', 'cable__terminations',
|
||||
GenericPrefetch(
|
||||
"cable__terminations__termination",
|
||||
[
|
||||
Interface.objects.select_related("device", "cable"),
|
||||
],
|
||||
),
|
||||
GenericPrefetch(
|
||||
"_path__path_objects",
|
||||
[
|
||||
Interface.objects.select_related("device", "cable"),
|
||||
],
|
||||
),
|
||||
'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
|
||||
'ip_addresses', # Referenced by Interface.count_ipaddresses()
|
||||
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()
|
||||
|
@ -989,6 +989,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
|
||||
# Coaxial
|
||||
TYPE_DOCSIS = 'docsis'
|
||||
TYPE_MOCA = 'moca'
|
||||
|
||||
# PON
|
||||
TYPE_BPON = 'bpon'
|
||||
@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
_('Coaxial'),
|
||||
(
|
||||
(TYPE_DOCSIS, 'DOCSIS'),
|
||||
(TYPE_MOCA, 'MoCA'),
|
||||
)
|
||||
),
|
||||
(
|
||||
@ -1348,6 +1350,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_SC_UPC = 'sc-upc'
|
||||
TYPE_SC_APC = 'sc-apc'
|
||||
TYPE_FC = 'fc'
|
||||
TYPE_FC_PC = 'fc-pc'
|
||||
TYPE_FC_UPC = 'fc-upc'
|
||||
TYPE_FC_APC = 'fc-apc'
|
||||
TYPE_LC = 'lc'
|
||||
TYPE_LC_PC = 'lc-pc'
|
||||
TYPE_LC_UPC = 'lc-upc'
|
||||
@ -1408,6 +1413,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
_('Fiber Optic'),
|
||||
(
|
||||
(TYPE_FC, 'FC'),
|
||||
(TYPE_FC_PC, 'FC/PC'),
|
||||
(TYPE_FC_UPC, 'FC/UPC'),
|
||||
(TYPE_FC_APC, 'FC/APC'),
|
||||
(TYPE_LC, 'LC'),
|
||||
(TYPE_LC_PC, 'LC/PC'),
|
||||
(TYPE_LC_UPC, 'LC/UPC'),
|
||||
|
2
netbox/dcim/exceptions.py
Normal file
2
netbox/dcim/exceptions.py
Normal file
@ -0,0 +1,2 @@
|
||||
class UnsupportedCablePath(Exception):
|
||||
pass
|
@ -1246,6 +1246,7 @@ class DeviceFilterSet(
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(virtual_chassis__name__icontains=value) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
|
@ -1458,7 +1458,7 @@ class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'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',
|
||||
'wireless_lans'
|
||||
'wireless_lans', 'vlan_translation_policy'
|
||||
])
|
||||
):
|
||||
enabled = forms.NullBooleanField(
|
||||
@ -1611,7 +1611,9 @@ class InterfaceBulkEditForm(
|
||||
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
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(
|
||||
TabbedGroups(
|
||||
FieldSet('tagged_vlans', name=_('Assignment')),
|
||||
@ -1626,7 +1628,7 @@ class InterfaceBulkEditForm(
|
||||
nullable_fields = (
|
||||
'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',
|
||||
'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):
|
||||
|
@ -1192,27 +1192,45 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
self.fields['parent'].queryset = InventoryItem.objects.none()
|
||||
|
||||
def clean_component_name(self):
|
||||
content_type = self.cleaned_data.get('component_type')
|
||||
component_name = self.cleaned_data.get('component_name')
|
||||
def clean(self):
|
||||
super().clean()
|
||||
cleaned_data = self.cleaned_data
|
||||
component_type = cleaned_data.get('component_type')
|
||||
component_name = cleaned_data.get('component_name')
|
||||
device = self.cleaned_data.get("device")
|
||||
|
||||
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
|
||||
device = self.instance.device
|
||||
|
||||
if not all([device, content_type, component_name]):
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
if component_type:
|
||||
if device is None:
|
||||
cleaned_data.pop('component_type', None)
|
||||
if component_name is None:
|
||||
cleaned_data.pop('component_type', None)
|
||||
raise forms.ValidationError(
|
||||
_("Component name must be specified when component type is specified")
|
||||
)
|
||||
if all([device, component_name]):
|
||||
try:
|
||||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
model = component_type.model_class()
|
||||
self.instance.component = model.objects.get(device=device, name=component_name)
|
||||
except ObjectDoesNotExist:
|
||||
cleaned_data.pop('component_type', None)
|
||||
cleaned_data.pop('component_name', None)
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
)
|
||||
)
|
||||
else:
|
||||
cleaned_data.pop('component_type', None)
|
||||
if not component_name:
|
||||
raise forms.ValidationError(
|
||||
_("Component name must be specified when component type is specified")
|
||||
)
|
||||
else:
|
||||
if component_name:
|
||||
raise forms.ValidationError(
|
||||
_("Component type must be specified when component name is specified")
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
#
|
||||
|
@ -304,7 +304,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
||||
model = RackType
|
||||
fieldsets = (
|
||||
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('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
)
|
||||
|
@ -153,6 +153,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
@ -302,6 +303,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
|
@ -30,6 +30,7 @@ class PathEndpointMixin:
|
||||
|
||||
connected_endpoints: List[Annotated[Union[
|
||||
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
|
||||
Annotated["VirtualCircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
|
||||
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
|
@ -471,7 +471,7 @@ class InterfaceTemplateType(ModularComponentTemplateType):
|
||||
)
|
||||
class InventoryItemType(ComponentType):
|
||||
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
|
||||
child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
@ -8,7 +8,7 @@ import utilities.json
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0204_device_role_rebuild'),
|
||||
('extras', '0125_exporttemplate_file_name'),
|
||||
('extras', '0126_exporttemplate_file_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
@ -1,5 +1,4 @@
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -15,7 +14,8 @@ from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from utilities.conversion import to_meters
|
||||
from utilities.fields import ColorField
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.fields import ColorField, GenericArrayForeignKey
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||
@ -26,6 +26,7 @@ __all__ = (
|
||||
'CableTermination',
|
||||
)
|
||||
|
||||
from ..exceptions import UnsupportedCablePath
|
||||
|
||||
trace_paths = Signal()
|
||||
|
||||
@ -236,8 +237,10 @@ class Cable(PrimaryModel):
|
||||
for termination in self.b_terminations:
|
||||
if not termination.pk or termination not in b_terminations:
|
||||
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
||||
|
||||
try:
|
||||
trace_paths.send(Cable, instance=self, created=_created)
|
||||
except UnsupportedCablePath as e:
|
||||
raise AbortRequest(e)
|
||||
|
||||
def get_status_color(self):
|
||||
return LinkStatusChoices.colors.get(self.status)
|
||||
@ -486,13 +489,16 @@ class CablePath(models.Model):
|
||||
return ObjectType.objects.get_for_id(ct_id)
|
||||
|
||||
@property
|
||||
def path_objects(self):
|
||||
"""
|
||||
Cache and return the complete path as lists of objects, derived from their annotation within the path.
|
||||
"""
|
||||
if not hasattr(self, '_path_objects'):
|
||||
self._path_objects = self._get_path()
|
||||
return self._path_objects
|
||||
def _path_decompiled(self):
|
||||
res = []
|
||||
for step in self.path:
|
||||
nodes = []
|
||||
for node in step:
|
||||
nodes.append(decompile_path_node(node))
|
||||
res.append(nodes)
|
||||
return res
|
||||
|
||||
path_objects = GenericArrayForeignKey("_path_decompiled")
|
||||
|
||||
@property
|
||||
def origins(self):
|
||||
@ -527,8 +533,8 @@ class CablePath(models.Model):
|
||||
return None
|
||||
|
||||
# Ensure all originating terminations are attached to the same link
|
||||
if len(terminations) > 1:
|
||||
assert all(t.link == terminations[0].link for t in terminations[1:])
|
||||
if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
|
||||
raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
|
||||
|
||||
path = []
|
||||
position_stack = []
|
||||
@ -539,12 +545,13 @@ class CablePath(models.Model):
|
||||
while terminations:
|
||||
|
||||
# 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
|
||||
if not isinstance(terminations[0], PathEndpoint):
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
||||
if (not isinstance(terminations[0], PathEndpoint) and not
|
||||
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
|
||||
# different cables attached)
|
||||
@ -567,8 +574,10 @@ class CablePath(models.Model):
|
||||
return None
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
||||
assert all(isinstance(link, type(links[0])) for link in links)
|
||||
if not all(type(link) in (Cable, WirelessLink) 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
|
||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||
@ -649,14 +658,18 @@ class CablePath(models.Model):
|
||||
positions = position_stack.pop()
|
||||
|
||||
# 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
|
||||
q_filter = Q()
|
||||
for rt in remote_terminations:
|
||||
position = positions.pop()
|
||||
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)
|
||||
# Obtain the individual front ports based on the termination and position
|
||||
elif position_stack:
|
||||
@ -742,42 +755,6 @@ class CablePath(models.Model):
|
||||
self.delete()
|
||||
retrace.alters_data = True
|
||||
|
||||
def _get_path(self):
|
||||
"""
|
||||
Return the path as a list of prefetched objects.
|
||||
"""
|
||||
# Compile a list of IDs to prefetch for each type of model in the path
|
||||
to_prefetch = defaultdict(list)
|
||||
for node in self._nodes:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
to_prefetch[ct_id].append(object_id)
|
||||
|
||||
# Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
|
||||
prefetched = {}
|
||||
for ct_id, object_ids in to_prefetch.items():
|
||||
model_class = ObjectType.objects.get_for_id(ct_id).model_class()
|
||||
queryset = model_class.objects.filter(pk__in=object_ids)
|
||||
if hasattr(model_class, 'device'):
|
||||
queryset = queryset.prefetch_related('device')
|
||||
prefetched[ct_id] = {
|
||||
obj.id: obj for obj in queryset
|
||||
}
|
||||
|
||||
# Replicate the path using the prefetched objects.
|
||||
path = []
|
||||
for step in self.path:
|
||||
nodes = []
|
||||
for node in step:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
try:
|
||||
nodes.append(prefetched[ct_id][object_id])
|
||||
except KeyError:
|
||||
# Ignore stale (deleted) object IDs
|
||||
pass
|
||||
path.append(nodes)
|
||||
|
||||
return path
|
||||
|
||||
def get_cable_ids(self):
|
||||
"""
|
||||
Return all Cable IDs within the path.
|
||||
|
@ -184,8 +184,11 @@ class CabledObjectModel(models.Model):
|
||||
@cached_property
|
||||
def link_peers(self):
|
||||
if self.cable:
|
||||
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
|
||||
return [peer.termination for peer in peers]
|
||||
return [
|
||||
peer.termination
|
||||
for peer in self.cable.terminations.all()
|
||||
if peer.cable_end != self.cable_end
|
||||
]
|
||||
return []
|
||||
|
||||
@property
|
||||
|
@ -3,7 +3,7 @@ import yaml
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@ -609,6 +609,12 @@ class Device(
|
||||
null=True,
|
||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||
)
|
||||
services = GenericRelation(
|
||||
to='ipam.Service',
|
||||
content_type_field='parent_object_type',
|
||||
object_id_field='parent_object_id',
|
||||
related_query_name='device',
|
||||
)
|
||||
|
||||
# Counter fields
|
||||
console_port_count = CounterCacheField(
|
||||
@ -690,14 +696,10 @@ class Device(
|
||||
verbose_name_plural = _('devices')
|
||||
|
||||
def __str__(self):
|
||||
if self.name and self.asset_tag:
|
||||
return f'{self.name} ({self.asset_tag})'
|
||||
elif self.name:
|
||||
return self.name
|
||||
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})'
|
||||
if self.label and self.asset_tag:
|
||||
return f'{self.label} ({self.asset_tag})'
|
||||
elif self.label:
|
||||
return self.label
|
||||
elif self.device_type and self.asset_tag:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
||||
elif self.device_type:
|
||||
@ -961,14 +963,22 @@ class Device(
|
||||
device.location = self.location
|
||||
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
|
||||
def identifier(self):
|
||||
"""
|
||||
Return the device name if set; otherwise return the Device's primary key as {pk}
|
||||
"""
|
||||
if self.name is not None:
|
||||
return self.name
|
||||
return '{{{}}}'.format(self.pk)
|
||||
return self.label or '{{{}}}'.format(self.pk)
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
@ -1268,7 +1278,10 @@ class MACAddress(PrimaryModel):
|
||||
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:
|
||||
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")
|
||||
|
@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import MODULE_TOKEN
|
||||
from dcim.utils import update_interface_bridges
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from netbox.models import PrimaryModel
|
||||
@ -341,6 +342,7 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
else:
|
||||
# ModuleBays must be saved individually for MPTT
|
||||
for instance in create_instances:
|
||||
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
||||
instance.save()
|
||||
|
||||
update_fields = ['module']
|
||||
|
@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex):
|
||||
('asset_tag', 50),
|
||||
('serial', 60),
|
||||
('name', 100),
|
||||
('virtual_chassis', 200),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff'
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
if device.virtual_chassis:
|
||||
name = f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||
elif device.name:
|
||||
name = device.name
|
||||
if device.label:
|
||||
name = device.label
|
||||
else:
|
||||
name = str(device.device_type)
|
||||
if device.devicebay_count:
|
||||
|
@ -144,7 +144,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
verbose_name=_('Name'),
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
linkify=True,
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
@ -678,7 +678,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
|
||||
'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',
|
||||
'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')
|
||||
|
||||
|
@ -31,6 +31,11 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
racktype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:racktype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name=_('Rack Types')
|
||||
)
|
||||
devicetype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:devicetype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
@ -58,12 +63,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug',
|
||||
'pk', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count', 'description', 'slug',
|
||||
)
|
||||
|
||||
|
||||
|
@ -100,7 +100,6 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('ASNs')
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'site_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
@ -153,6 +152,11 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name=_('Devices')
|
||||
)
|
||||
vlangroup_count = columns.LinkedCountColumn(
|
||||
viewname='ipam:vlangroup_list',
|
||||
url_params={'location': 'pk'},
|
||||
verbose_name=_('VLAN Groups')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
@ -168,7 +172,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
||||
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
'vlangroup_count',
|
||||
)
|
||||
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'
|
||||
)
|
||||
|
@ -35,7 +35,7 @@ WEIGHT = """
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
{{ value|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
|
||||
{{ record.label|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
|
||||
"""
|
||||
|
||||
DEVICEBAY_STATUS = """
|
||||
@ -164,8 +164,8 @@ CONSOLEPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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-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-transit-connection-variant" 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">
|
||||
<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>
|
||||
@ -177,7 +177,7 @@ CONSOLEPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% 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 %}
|
||||
"""
|
||||
|
||||
@ -214,8 +214,8 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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-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-transit-connection-variant" 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">
|
||||
<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>
|
||||
@ -227,7 +227,7 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% 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 %}
|
||||
"""
|
||||
|
||||
@ -264,8 +264,8 @@ POWERPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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-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-transit-connection-variant" 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">
|
||||
<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>
|
||||
@ -276,7 +276,7 @@ POWERPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% 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 %}
|
||||
"""
|
||||
|
||||
@ -313,14 +313,14 @@ POWEROUTLET_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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-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-transit-connection-variant" 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 %}
|
||||
<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>
|
||||
</a>
|
||||
{% 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 %}
|
||||
"""
|
||||
@ -407,8 +407,8 @@ INTERFACE_BUTTONS = """
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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-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-transit-connection-variant" 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 %}
|
||||
<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">
|
||||
@ -422,7 +422,7 @@ INTERFACE_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% 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 %}
|
||||
{% 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">
|
||||
@ -464,8 +464,8 @@ FRONTPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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-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-transit-connection-variant" 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 %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@ -481,7 +481,7 @@ FRONTPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% 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 %}
|
||||
"""
|
||||
@ -519,8 +519,8 @@ REARPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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-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-transit-connection-variant" 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 %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@ -536,7 +536,7 @@ REARPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% 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 %}
|
||||
"""
|
||||
|
@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from django.test import override_settings
|
||||
from django.test import override_settings, tag
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
@ -1979,6 +1979,27 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@tag('regression') # Issue #18991
|
||||
def test_front_port_paths(self):
|
||||
device = Device.objects.first()
|
||||
rear_port = RearPort.objects.create(
|
||||
device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
interface1 = Interface.objects.create(device=device, name='Interface 1')
|
||||
front_port = FrontPort.objects.create(
|
||||
device=device,
|
||||
name='Rear Port 10',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rear_port,
|
||||
)
|
||||
Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
|
||||
|
||||
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
|
||||
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': front_port.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RearPort
|
||||
@ -2022,6 +2043,23 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@tag('regression') # Issue #18991
|
||||
def test_rear_port_paths(self):
|
||||
device = Device.objects.first()
|
||||
interface1 = Interface.objects.create(device=device, name='Interface 1')
|
||||
rear_port = RearPort.objects.create(
|
||||
device=device,
|
||||
name='Rear Port 10',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
)
|
||||
Cable.objects.create(a_terminations=[interface1], b_terminations=[rear_port])
|
||||
|
||||
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
|
||||
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': rear_port.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBay
|
||||
|
@ -5,6 +5,7 @@ from dcim.choices import LinkStatusChoices
|
||||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from dcim.utils import object_to_path_node
|
||||
from utilities.exceptions import AbortRequest
|
||||
|
||||
|
||||
class CablePathTestCase(TestCase):
|
||||
@ -2470,7 +2471,7 @@ class CablePathTestCase(TestCase):
|
||||
b_terminations=[frontport1, frontport3],
|
||||
label='C1'
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
with self.assertRaises(AbortRequest):
|
||||
cable1.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
@ -2489,7 +2490,7 @@ class CablePathTestCase(TestCase):
|
||||
label='C3'
|
||||
)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
with self.assertRaises(AbortRequest):
|
||||
cable3.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
|
@ -1,7 +1,8 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import (
|
||||
DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices, PowerOutletStatusChoices
|
||||
DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices, PortTypeChoices,
|
||||
PowerOutletStatusChoices,
|
||||
)
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
@ -170,6 +171,51 @@ class DeviceTestCase(TestCase):
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
|
||||
class FrontPortTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.device = create_test_device('Panel Device 1')
|
||||
cls.rear_ports = (
|
||||
RearPort(name='RearPort1', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort2', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort3', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
)
|
||||
RearPort.objects.bulk_create(cls.rear_ports)
|
||||
|
||||
def test_front_port_label_count_valid(self):
|
||||
"""
|
||||
Test that generating an equal number of names and labels passes form validation.
|
||||
"""
|
||||
front_port_data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'FrontPort[1-4]',
|
||||
'label': 'Port[1-4]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
|
||||
}
|
||||
form = FrontPortCreateForm(front_port_data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_front_port_label_count_mismatch(self):
|
||||
"""
|
||||
Check that attempting to generate a differing number of names and labels results in a validation error.
|
||||
"""
|
||||
bad_front_port_data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'FrontPort[1-4]',
|
||||
'label': 'Port[1-2]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
|
||||
}
|
||||
form = FrontPortCreateForm(bad_front_port_data)
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('label', form.errors)
|
||||
|
||||
|
||||
class InterfaceTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
|
@ -1,5 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from circuits.models import *
|
||||
from core.models import ObjectType
|
||||
@ -12,6 +12,43 @@ from utilities.data import drange
|
||||
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):
|
||||
|
||||
def test_change_location_site(self):
|
||||
@ -592,6 +629,32 @@ class DeviceTestCase(TestCase):
|
||||
device2.full_clean()
|
||||
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):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||
|
@ -10,10 +10,9 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
@ -412,7 +411,8 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(Site, 'list', path='', detail=False)
|
||||
class SiteListView(generic.ObjectListView):
|
||||
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_form = forms.SiteFilterForm
|
||||
@ -489,6 +489,7 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(Location, 'list', path='', detail=False)
|
||||
class LocationListView(generic.ObjectListView):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
@ -500,6 +501,11 @@ class LocationListView(generic.ObjectListView):
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
),
|
||||
VLANGroup,
|
||||
'location',
|
||||
'vlangroup_count',
|
||||
cumulative=True
|
||||
)
|
||||
filterset = filtersets.LocationFilterSet
|
||||
filterset_form = forms.LocationFilterForm
|
||||
@ -512,6 +518,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
locations = instance.get_descendants(include_self=True)
|
||||
location_content_type = ContentType.objects.get_for_model(instance)
|
||||
return {
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
@ -529,6 +536,8 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
(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'),
|
||||
),
|
||||
),
|
||||
}
|
||||
@ -939,6 +948,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(Manufacturer, 'list', path='', detail=False)
|
||||
class ManufacturerListView(generic.ObjectListView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
racktype_count=count_related(RackType, 'manufacturer'),
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
moduletype_count=count_related(ModuleType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
@ -2049,7 +2059,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(Device, 'list', path='', detail=False)
|
||||
class DeviceListView(generic.ObjectListView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related('virtual_chassis')
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
filterset_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
@ -2277,51 +2287,14 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
|
||||
|
||||
@register_model_view(Device, 'render-config')
|
||||
class DeviceRenderConfigView(generic.ObjectView):
|
||||
class DeviceRenderConfigView(ObjectRenderConfigView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/render_config.html'
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
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'):
|
||||
response = context['config_template'].render_to_response(context=context['context_data'])
|
||||
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')
|
||||
class DeviceVirtualMachinesView(generic.ObjectChildrenView):
|
||||
|
@ -12,4 +12,5 @@ from .serializers_.configcontexts import *
|
||||
from .serializers_.configtemplates import *
|
||||
from .serializers_.savedfilters import *
|
||||
from .serializers_.scripts import *
|
||||
from .serializers_.tableconfigs import *
|
||||
from .serializers_.tags import *
|
||||
|
22
netbox/extras/api/serializers_/tableconfigs.py
Normal file
22
netbox/extras/api/serializers_/tableconfigs.py
Normal file
@ -0,0 +1,22 @@
|
||||
from core.models import ObjectType
|
||||
from extras.models import TableConfig
|
||||
from netbox.api.fields import ContentTypeField
|
||||
from netbox.api.serializers import ValidatedModelSerializer
|
||||
|
||||
__all__ = (
|
||||
'TableConfigSerializer',
|
||||
)
|
||||
|
||||
|
||||
class TableConfigSerializer(ValidatedModelSerializer):
|
||||
object_type = ContentTypeField(
|
||||
queryset=ObjectType.objects.all()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TableConfig
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'object_type', 'table', 'name', 'description', 'user', 'weight',
|
||||
'enabled', 'shared', 'columns', 'ordering', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'object_type', 'table')
|
@ -14,6 +14,7 @@ router.register('custom-field-choice-sets', views.CustomFieldChoiceSetViewSet)
|
||||
router.register('custom-links', views.CustomLinkViewSet)
|
||||
router.register('export-templates', views.ExportTemplateViewSet)
|
||||
router.register('saved-filters', views.SavedFilterViewSet)
|
||||
router.register('table-configs', views.TableConfigViewSet)
|
||||
router.register('bookmarks', views.BookmarkViewSet)
|
||||
router.register('notifications', views.NotificationViewSet)
|
||||
router.register('notification-groups', views.NotificationGroupViewSet)
|
||||
|
@ -131,6 +131,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.SavedFilterFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Table Configs
|
||||
#
|
||||
|
||||
class TableConfigViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = TableConfig.objects.all()
|
||||
serializer_class = serializers.TableConfigSerializer
|
||||
filterset_class = filtersets.TableConfigFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Bookmarks
|
||||
#
|
||||
|
@ -155,7 +155,6 @@ class JournalEntryKindChoices(ChoiceSet):
|
||||
class LogLevelChoices(ChoiceSet):
|
||||
|
||||
LOG_DEBUG = 'debug'
|
||||
LOG_DEFAULT = 'default'
|
||||
LOG_INFO = 'info'
|
||||
LOG_SUCCESS = 'success'
|
||||
LOG_WARNING = 'warning'
|
||||
@ -163,16 +162,15 @@ class LogLevelChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(LOG_DEBUG, _('Debug'), 'teal'),
|
||||
(LOG_DEFAULT, _('Default'), 'gray'),
|
||||
(LOG_INFO, _('Info'), 'cyan'),
|
||||
(LOG_SUCCESS, _('Success'), 'green'),
|
||||
(LOG_WARNING, _('Warning'), 'yellow'),
|
||||
(LOG_FAILURE, _('Failure'), 'red'),
|
||||
|
||||
)
|
||||
|
||||
SYSTEM_LEVELS = {
|
||||
LOG_DEBUG: logging.DEBUG,
|
||||
LOG_DEFAULT: logging.INFO,
|
||||
LOG_INFO: logging.INFO,
|
||||
LOG_SUCCESS: logging.INFO,
|
||||
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
|
||||
#
|
||||
|
@ -141,9 +141,8 @@ DEFAULT_DASHBOARD = [
|
||||
|
||||
LOG_LEVEL_RANK = {
|
||||
LogLevelChoices.LOG_DEBUG: 0,
|
||||
LogLevelChoices.LOG_DEFAULT: 1,
|
||||
LogLevelChoices.LOG_INFO: 2,
|
||||
LogLevelChoices.LOG_SUCCESS: 3,
|
||||
LogLevelChoices.LOG_WARNING: 4,
|
||||
LogLevelChoices.LOG_FAILURE: 5,
|
||||
LogLevelChoices.LOG_INFO: 1,
|
||||
LogLevelChoices.LOG_SUCCESS: 2,
|
||||
LogLevelChoices.LOG_WARNING: 3,
|
||||
LogLevelChoices.LOG_FAILURE: 4,
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import requests
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, resolve, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@ -43,6 +44,27 @@ def get_object_type_choices():
|
||||
]
|
||||
|
||||
|
||||
def object_list_widget_supports_model(model: Model) -> bool:
|
||||
"""Test whether a model is supported by the ObjectListWidget
|
||||
|
||||
In theory there could be more than one reason why a model isn't supported by the
|
||||
ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
|
||||
for the model. Add more tests if more conditions arise.
|
||||
"""
|
||||
def can_resolve_model_list_view(model: Model) -> bool:
|
||||
try:
|
||||
reverse(get_viewname(model, action='list'))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
tests = [
|
||||
can_resolve_model_list_view,
|
||||
]
|
||||
|
||||
return all(test(model) for test in tests)
|
||||
|
||||
|
||||
def get_bookmarks_object_type_choices():
|
||||
return [
|
||||
(object_type_identifier(ot), object_type_name(ot))
|
||||
@ -235,6 +257,17 @@ class ObjectListWidget(DashboardWidget):
|
||||
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def clean_model(self):
|
||||
if model_info := self.cleaned_data['model']:
|
||||
app_label, model_name = model_info.split('.')
|
||||
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
|
||||
if not object_list_widget_supports_model(model):
|
||||
raise forms.ValidationError(
|
||||
_(f"Invalid model selection: {self['model'].data} is not supported.")
|
||||
)
|
||||
|
||||
return model_info
|
||||
|
||||
def render(self, request):
|
||||
app_label, model_name = self.config['model'].split('.')
|
||||
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
|
||||
@ -258,7 +291,7 @@ class ObjectListWidget(DashboardWidget):
|
||||
parameters['per_page'] = page_size
|
||||
parameters['embedded'] = True
|
||||
|
||||
if parameters:
|
||||
if parameters and htmx_url is not None:
|
||||
try:
|
||||
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
|
||||
except ValueError:
|
||||
@ -285,7 +318,8 @@ class RSSFeedWidget(DashboardWidget):
|
||||
|
||||
class ConfigForm(WidgetConfigForm):
|
||||
feed_url = forms.URLField(
|
||||
label=_('Feed URL')
|
||||
label=_('Feed URL'),
|
||||
assume_scheme='https'
|
||||
)
|
||||
requires_internet = forms.BooleanField(
|
||||
label=_('Requires external connection'),
|
||||
|
@ -32,6 +32,7 @@ __all__ = (
|
||||
'ObjectTypeFilterSet',
|
||||
'SavedFilterFilterSet',
|
||||
'ScriptFilterSet',
|
||||
'TableConfigFilterSet',
|
||||
'TagFilterSet',
|
||||
'TaggedItemFilterSet',
|
||||
'WebhookFilterSet',
|
||||
@ -326,6 +327,59 @@ class SavedFilterFilterSet(ChangeLoggedModelFilterSet):
|
||||
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
||||
|
||||
|
||||
class TableConfigFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ObjectType.objects.all(),
|
||||
field_name='object_type'
|
||||
)
|
||||
object_type = ContentTypeFilter(
|
||||
field_name='object_type'
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
)
|
||||
user = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='user__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
label=_('User (name)'),
|
||||
)
|
||||
usable = django_filters.BooleanFilter(
|
||||
method='_usable'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TableConfig
|
||||
fields = ('id', 'name', 'description', 'table', 'enabled', 'shared', 'weight')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(table__icontains=value)
|
||||
)
|
||||
|
||||
def _usable(self, queryset, name, value):
|
||||
"""
|
||||
Return only TableConfigs that are both enabled and are shared (or belong to the current user).
|
||||
"""
|
||||
user = self.request.user if self.request else None
|
||||
if not user or user.is_anonymous:
|
||||
if value:
|
||||
return queryset.filter(enabled=True, shared=True)
|
||||
return queryset.filter(Q(enabled=False) | Q(shared=False))
|
||||
if value:
|
||||
return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user))
|
||||
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
||||
|
||||
|
||||
class BookmarkFilterSet(BaseFilterSet):
|
||||
created = django_filters.DateTimeFilter()
|
||||
object_type_id = MultiValueNumberFilter()
|
||||
|
@ -21,6 +21,7 @@ __all__ = (
|
||||
'JournalEntryBulkEditForm',
|
||||
'NotificationGroupBulkEditForm',
|
||||
'SavedFilterBulkEditForm',
|
||||
'TableConfigBulkEditForm',
|
||||
'TagBulkEditForm',
|
||||
'WebhookBulkEditForm',
|
||||
)
|
||||
@ -201,6 +202,34 @@ class SavedFilterBulkEditForm(BulkEditForm):
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class TableConfigBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=TableConfig.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
label=_('Weight'),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
shared = forms.NullBooleanField(
|
||||
label=_('Shared'),
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class WebhookBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = Webhook
|
||||
|
||||
|
@ -31,12 +31,14 @@ __all__ = (
|
||||
'LocalConfigContextFilterForm',
|
||||
'NotificationGroupFilterForm',
|
||||
'SavedFilterFilterForm',
|
||||
'TableConfigFilterForm',
|
||||
'TagFilterForm',
|
||||
'WebhookFilterForm',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomField
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet(
|
||||
@ -115,6 +117,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomFieldChoiceSet
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('base_choices', 'choice', name=_('Choices')),
|
||||
@ -129,6 +132,7 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomLink
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
|
||||
@ -159,6 +163,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ExportTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'object_type_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@ -204,6 +209,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ImageAttachment
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'name', name=_('Attributes')),
|
||||
@ -220,6 +226,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = SavedFilter
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
||||
@ -249,6 +256,36 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
||||
)
|
||||
object_type_id = ContentTypeMultipleChoiceField(
|
||||
label=_('Object types'),
|
||||
queryset=ObjectType.objects.public(),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
shared = forms.NullBooleanField(
|
||||
label=_('Shared'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
label=_('Weight'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class WebhookFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Webhook
|
||||
fieldsets = (
|
||||
@ -318,6 +355,7 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@ -407,6 +445,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@ -493,6 +532,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
|
||||
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = NotificationGroup
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
|
@ -2,6 +2,7 @@ import json
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -14,13 +15,14 @@ from netbox.events import get_event_type_choices
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group, User
|
||||
from utilities.forms import add_blank_choice, get_field_value
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, ObjectAttribute
|
||||
from utilities.forms.widgets import ChoicesWidget, HTMXSelect
|
||||
from utilities.tables import get_table_for_model
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
__all__ = (
|
||||
@ -37,6 +39,7 @@ __all__ = (
|
||||
'NotificationGroupForm',
|
||||
'SavedFilterForm',
|
||||
'SubscriptionForm',
|
||||
'TableConfigForm',
|
||||
'TagForm',
|
||||
'WebhookForm',
|
||||
)
|
||||
@ -162,6 +165,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
|
||||
|
||||
class CustomFieldChoiceSetForm(forms.ModelForm):
|
||||
# TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
|
||||
extra_choices = forms.CharField(
|
||||
widget=ChoicesWidget(),
|
||||
required=False,
|
||||
@ -178,12 +182,25 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
|
||||
def __init__(self, *args, initial=None, **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']:
|
||||
choices = []
|
||||
for choice in self.initial['extra_choices']:
|
||||
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
|
||||
choices.append(choice)
|
||||
extra_choices = self.initial['extra_choices']
|
||||
if isinstance(extra_choices, str):
|
||||
extra_choices = [extra_choices]
|
||||
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
|
||||
|
||||
@ -301,6 +318,65 @@ class SavedFilterForm(forms.ModelForm):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
|
||||
class TableConfigForm(forms.ModelForm):
|
||||
object_type = ContentTypeChoiceField(
|
||||
label=_('Object type'),
|
||||
queryset=ObjectType.objects.all()
|
||||
)
|
||||
ordering = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
label=_('Ordering'),
|
||||
help_text=_(
|
||||
"Enter a comma-separated list of column names. Prepend a name with a hyphen to reverse the order."
|
||||
)
|
||||
)
|
||||
available_columns = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(
|
||||
attrs={'size': 10, 'class': 'form-select'}
|
||||
),
|
||||
label=_('Available Columns')
|
||||
)
|
||||
columns = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
widget=forms.SelectMultiple(
|
||||
attrs={'size': 10, 'class': 'form-select select-all'}
|
||||
),
|
||||
label=_('Selected Columns')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TableConfig
|
||||
exclude = ('user',)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
object_type = ObjectType.objects.get(pk=get_field_value(self, 'object_type'))
|
||||
model = object_type.model_class()
|
||||
table_name = get_field_value(self, 'table')
|
||||
table_class = get_table_for_model(model, table_name)
|
||||
table = table_class([])
|
||||
|
||||
if columns := self._get_columns():
|
||||
table._set_columns(columns)
|
||||
|
||||
# Initialize columns field based on table attributes
|
||||
self.fields['available_columns'].widget.choices = table.available_columns
|
||||
self.fields['columns'].widget.choices = table.selected_columns
|
||||
|
||||
def _get_columns(self):
|
||||
if self.is_bound and (columns := self.data.getlist('columns')):
|
||||
return columns
|
||||
if 'columns' in self.initial:
|
||||
columns = self.get_initial_for_field(self.fields['columns'], 'columns')
|
||||
return columns.split(',') if type(columns) is str else columns
|
||||
if self.instance is not None:
|
||||
return self.instance.columns
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
object_type = ContentTypeChoiceField(
|
||||
label=_('Object type'),
|
||||
@ -681,8 +757,7 @@ class ImageAttachmentForm(forms.ModelForm):
|
||||
class JournalEntryForm(NetBoxModelForm):
|
||||
kind = forms.ChoiceField(
|
||||
label=_('Kind'),
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
required=False
|
||||
choices=JournalEntryKindChoices
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
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.datetime import local_now
|
||||
|
||||
@ -22,7 +22,7 @@ class ReportForm(forms.Form):
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
options=JobIntervalChoices
|
||||
),
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
@ -5,11 +5,11 @@ from django.conf import settings
|
||||
from django.core.files.storage import storages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import JobIntervalChoices
|
||||
from core.forms import ManagedFileForm
|
||||
from extras.choices import DurationChoices
|
||||
from extras.storage import ScriptFileSystemStorage
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
from utilities.datetime import local_now
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
|
||||
__all__ = (
|
||||
'ScriptFileForm',
|
||||
@ -35,7 +35,7 @@ class ScriptForm(forms.Form):
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
options=JobIntervalChoices
|
||||
),
|
||||
help_text=_("Interval at which this script is re-run (in minutes)")
|
||||
)
|
||||
|
@ -34,6 +34,7 @@ __all__ = (
|
||||
'JournalEntryFilter',
|
||||
'NotificationGroupFilter',
|
||||
'SavedFilterFilter',
|
||||
'TableConfigFilter',
|
||||
'TagFilter',
|
||||
'WebhookFilter',
|
||||
)
|
||||
@ -262,6 +263,19 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.filter(models.TableConfig, lookups=True)
|
||||
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
user_id: ID | None = strawberry_django.filter_field()
|
||||
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
shared: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter(models.Tag, lookups=True)
|
||||
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
|
||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||
|
@ -32,6 +32,9 @@ class ExtrasQuery:
|
||||
saved_filter: SavedFilterType = strawberry_django.field()
|
||||
saved_filter_list: List[SavedFilterType] = strawberry_django.field()
|
||||
|
||||
table_config: TableConfigType = strawberry_django.field()
|
||||
table_config_list: List[TableConfigType] = strawberry_django.field()
|
||||
|
||||
journal_entry: JournalEntryType = strawberry_django.field()
|
||||
journal_entry_list: List[JournalEntryType] = strawberry_django.field()
|
||||
|
||||
|
@ -38,6 +38,7 @@ __all__ = (
|
||||
'NotificationType',
|
||||
'SavedFilterType',
|
||||
'SubscriptionType',
|
||||
'TableConfigType',
|
||||
'TagType',
|
||||
'WebhookType',
|
||||
)
|
||||
@ -186,6 +187,16 @@ class SubscriptionType(ObjectType):
|
||||
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.TableConfig,
|
||||
fields='__all__',
|
||||
filters=TableConfigFilter,
|
||||
pagination=True
|
||||
)
|
||||
class TableConfigType(ObjectType):
|
||||
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.Tag,
|
||||
exclude=['extras_taggeditem_items', ],
|
||||
|
@ -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
|
||||
# change logging, event rules, etc.
|
||||
if commit:
|
||||
with ExitStack() as stack:
|
||||
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)
|
||||
|
@ -5,7 +5,6 @@ import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
@ -54,7 +53,7 @@ class Command(BaseCommand):
|
||||
ending=""
|
||||
)
|
||||
self.stdout.flush()
|
||||
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||
ObjectChange.objects.filter(time__lt=cutoff).delete()
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Done.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
|
@ -81,12 +81,17 @@ class Command(BaseCommand):
|
||||
logger.error(f'\t{field}: {error.get("message")}')
|
||||
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.
|
||||
job = ScriptJob.enqueue(
|
||||
instance=script_obj,
|
||||
user=user,
|
||||
immediate=True,
|
||||
data=data,
|
||||
data=form.cleaned_data,
|
||||
request=NetBoxFakeRequest({
|
||||
'META': {},
|
||||
'POST': data,
|
||||
|
25
netbox/extras/migrations/0123_journalentry_kind_default.py
Normal file
25
netbox/extras/migrations/0123_journalentry_kind_default.py
Normal file
@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
from extras.choices import JournalEntryKindChoices
|
||||
|
||||
|
||||
def set_kind_default(apps, schema_editor):
|
||||
"""
|
||||
Set kind to "info" on any entries with no kind assigned.
|
||||
"""
|
||||
JournalEntry = apps.get_model('extras', 'JournalEntry')
|
||||
JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0122_charfield_null_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=set_kind_default,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
@ -6,7 +6,7 @@ from django.db import migrations
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0122_charfield_null_choices'),
|
||||
('extras', '0123_journalentry_kind_default'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0123_remove_staging'),
|
||||
('extras', '0124_remove_staging'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0124_alter_tag_options_tag_weight'),
|
||||
('extras', '0125_alter_tag_options_tag_weight'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0125_exporttemplate_file_name'),
|
||||
('extras', '0126_exporttemplate_file_name'),
|
||||
]
|
||||
|
||||
operations = [
|
56
netbox/extras/migrations/0128_tableconfig.py
Normal file
56
netbox/extras/migrations/0128_tableconfig.py
Normal file
@ -0,0 +1,56 @@
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0014_remove_redundant_indexes'),
|
||||
('extras', '0127_configtemplate_as_attachment_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TableConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('table', models.CharField(max_length=100)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('weight', models.PositiveSmallIntegerField(default=100)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('shared', models.BooleanField(default=True)),
|
||||
(
|
||||
'columns',
|
||||
django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None),
|
||||
),
|
||||
(
|
||||
'ordering',
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=100), blank=True, null=True, size=None
|
||||
),
|
||||
),
|
||||
(
|
||||
'object_type',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, related_name='table_configs', to='core.objecttype'
|
||||
),
|
||||
),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'table config',
|
||||
'verbose_name_plural': 'table configs',
|
||||
'ordering': ('weight', 'name'),
|
||||
},
|
||||
),
|
||||
]
|
@ -197,7 +197,7 @@ class ConfigContextModel(models.Model):
|
||||
super().clean()
|
||||
|
||||
# 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(
|
||||
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
@ -9,6 +9,8 @@ from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Value
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@ -281,12 +283,20 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
b) the assignment of an existing CustomField to new object types.
|
||||
"""
|
||||
if self.default is None:
|
||||
# We have to convert None to a JSON null for jsonb_set()
|
||||
value = RawSQL("'null'::jsonb", [])
|
||||
else:
|
||||
value = Value(self.default, models.JSONField())
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.exclude(**{'custom_field_data__contains': self.name})
|
||||
for instance in instances:
|
||||
instance.custom_field_data[self.name] = self.default
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
ct.model_class().objects.update(
|
||||
custom_field_data=Func(
|
||||
F('custom_field_data'),
|
||||
Value([self.name]),
|
||||
value,
|
||||
function='jsonb_set'
|
||||
)
|
||||
)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
@ -295,22 +305,27 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
"""
|
||||
for ct in content_types:
|
||||
if model := ct.model_class():
|
||||
instances = model.objects.filter(custom_field_data__has_key=self.name)
|
||||
for instance in instances:
|
||||
del instance.custom_field_data[self.name]
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
model.objects.update(
|
||||
custom_field_data=F('custom_field_data') - self.name
|
||||
)
|
||||
|
||||
def rename_object_data(self, old_name, new_name):
|
||||
"""
|
||||
Called when a CustomField has been renamed. Updates all assigned object data.
|
||||
Called when a CustomField has been renamed. Removes the original key and inserts the new
|
||||
one, copying the value of the old key.
|
||||
"""
|
||||
for ct in self.object_types.all():
|
||||
model = ct.model_class()
|
||||
params = {f'custom_field_data__{old_name}__isnull': False}
|
||||
instances = model.objects.filter(**params)
|
||||
for instance in instances:
|
||||
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
ct.model_class().objects.update(
|
||||
custom_field_data=Func(
|
||||
F('custom_field_data') - old_name,
|
||||
Value([new_name]),
|
||||
Func(
|
||||
F('custom_field_data'),
|
||||
function='jsonb_extract_path_text',
|
||||
template=f"to_jsonb(%(expressions)s -> '{old_name}')"
|
||||
),
|
||||
function='jsonb_set')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@ -515,7 +530,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
field = LaxURLField(required=required, initial=initial)
|
||||
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
|
||||
|
||||
# JSON
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
@ -532,6 +547,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
}
|
||||
if not for_csv_import:
|
||||
kwargs['query_params'] = self.related_object_filter
|
||||
kwargs['selector'] = True
|
||||
|
||||
field = field_class(**kwargs)
|
||||
|
||||
@ -546,6 +562,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
}
|
||||
if not for_csv_import:
|
||||
kwargs['query_params'] = self.related_object_filter
|
||||
kwargs['selector'] = True
|
||||
|
||||
field = field_class(**kwargs)
|
||||
|
||||
|
@ -27,6 +27,7 @@ from utilities.html import clean_html
|
||||
from utilities.jinja2 import render_jinja2
|
||||
from utilities.querydict import dict_to_querydict
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.tables import get_table_for_model
|
||||
|
||||
__all__ = (
|
||||
'Bookmark',
|
||||
@ -36,6 +37,7 @@ __all__ = (
|
||||
'ImageAttachment',
|
||||
'JournalEntry',
|
||||
'SavedFilter',
|
||||
'TableConfig',
|
||||
'Webhook',
|
||||
)
|
||||
|
||||
@ -524,6 +526,121 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
return qd.urlencode()
|
||||
|
||||
|
||||
class TableConfig(CloningMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A saved configuration of columns and ordering which applies to a specific table.
|
||||
"""
|
||||
object_type = models.ForeignKey(
|
||||
to='core.ObjectType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='table_configs',
|
||||
help_text=_("The table's object type"),
|
||||
)
|
||||
table = models.CharField(
|
||||
verbose_name=_('table'),
|
||||
max_length=100,
|
||||
)
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100,
|
||||
)
|
||||
description = models.CharField(
|
||||
verbose_name=_('description'),
|
||||
max_length=200,
|
||||
blank=True,
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('weight'),
|
||||
default=100
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
verbose_name=_('enabled'),
|
||||
default=True
|
||||
)
|
||||
shared = models.BooleanField(
|
||||
verbose_name=_('shared'),
|
||||
default=True
|
||||
)
|
||||
columns = ArrayField(
|
||||
base_field=models.CharField(max_length=100),
|
||||
)
|
||||
ordering = ArrayField(
|
||||
base_field=models.CharField(max_length=100),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
clone_fields = ('object_type', 'table', 'enabled', 'shared', 'columns', 'ordering')
|
||||
|
||||
class Meta:
|
||||
ordering = ('weight', 'name')
|
||||
verbose_name = _('table config')
|
||||
verbose_name_plural = _('table configs')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:tableconfig', args=[self.pk])
|
||||
|
||||
@property
|
||||
def docs_url(self):
|
||||
return f'{settings.STATIC_URL}docs/models/extras/tableconfig/'
|
||||
|
||||
@property
|
||||
def table_class(self):
|
||||
return get_table_for_model(self.object_type.model_class(), name=self.table)
|
||||
|
||||
@property
|
||||
def ordering_items(self):
|
||||
"""
|
||||
Return a list of two-tuples indicating the column(s) by which the table is to be ordered and a boolean for each
|
||||
column indicating whether its ordering is ascending.
|
||||
"""
|
||||
items = []
|
||||
for col in self.ordering or []:
|
||||
if col.startswith('-'):
|
||||
ascending = False
|
||||
col = col[1:]
|
||||
else:
|
||||
ascending = True
|
||||
items.append((col, ascending))
|
||||
return items
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate table
|
||||
if self.table_class is None:
|
||||
raise ValidationError({
|
||||
'table': _("Unknown table: {name}").format(name=self.table)
|
||||
})
|
||||
|
||||
table = self.table_class([])
|
||||
|
||||
# Validate ordering columns
|
||||
for name in self.ordering:
|
||||
if name.startswith('-'):
|
||||
name = name[1:] # Strip leading hyphen
|
||||
if name not in table.columns:
|
||||
raise ValidationError({
|
||||
'ordering': _('Unknown column: {name}').format(name=name)
|
||||
})
|
||||
|
||||
# Validate selected columns
|
||||
for name in self.columns:
|
||||
if name not in table.columns:
|
||||
raise ValidationError({
|
||||
'columns': _('Unknown column: {name}').format(name=name)
|
||||
})
|
||||
|
||||
|
||||
class ImageAttachment(ChangeLoggedModel):
|
||||
"""
|
||||
An uploaded image which is associated with an object.
|
||||
|
@ -117,6 +117,15 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
def __str__(self):
|
||||
return self.python_name
|
||||
|
||||
@property
|
||||
def ordered_scripts(self):
|
||||
script_objects = {s.name: s for s in self.scripts.all()}
|
||||
ordered = [
|
||||
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
|
||||
]
|
||||
ordered.extend(script_objects.items())
|
||||
return ordered
|
||||
|
||||
@property
|
||||
def module_scripts(self):
|
||||
|
||||
|
@ -15,7 +15,7 @@ class Report(BaseScript):
|
||||
|
||||
# There is no generic log() equivalent on BaseScript
|
||||
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):
|
||||
super().log_success(message, obj)
|
||||
|
@ -213,10 +213,12 @@ class ObjectVar(ScriptVariable):
|
||||
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
|
||||
elements within the dropdown menu (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
|
||||
|
||||
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)
|
||||
|
||||
self.field_attrs.update({
|
||||
@ -224,6 +226,7 @@ class ObjectVar(ScriptVariable):
|
||||
'query_params': query_params,
|
||||
'context': context,
|
||||
'null_option': null_option,
|
||||
'selector': selector,
|
||||
})
|
||||
|
||||
|
||||
@ -499,7 +502,7 @@ class BaseScript:
|
||||
# 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.
|
||||
"""
|
||||
|
@ -27,6 +27,7 @@ __all__ = (
|
||||
'ReportResultsTable',
|
||||
'ScriptResultsTable',
|
||||
'SubscriptionTable',
|
||||
'TableConfigTable',
|
||||
'TaggedItemTable',
|
||||
'TagTable',
|
||||
'WebhookTable',
|
||||
@ -281,6 +282,36 @@ class SavedFilterTable(NetBoxTable):
|
||||
)
|
||||
|
||||
|
||||
class TableConfigTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Type'),
|
||||
)
|
||||
table = tables.Column(
|
||||
verbose_name=_('Table Name')
|
||||
)
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
shared = columns.BooleanColumn(
|
||||
verbose_name=_('Shared'),
|
||||
false_mark=None
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = TableConfig
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'object_type', 'table', 'description', 'user', 'weight', 'enabled', 'shared', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'object_type', 'table', 'user', 'description', 'enabled', 'shared',
|
||||
)
|
||||
|
||||
|
||||
class BookmarkTable(NetBoxTable):
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Types'),
|
||||
@ -508,13 +539,16 @@ class ConfigContextTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Synced')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:configcontext_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||
|
||||
|
48
netbox/extras/tests/test_dashboard.py
Normal file
48
netbox/extras/tests/test_dashboard.py
Normal file
@ -0,0 +1,48 @@
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from extras.dashboard.widgets import ObjectListWidget
|
||||
|
||||
|
||||
class ObjectListWidgetTests(TestCase):
|
||||
def test_widget_config_form_validates_model(self):
|
||||
model_info = 'extras.notification'
|
||||
form = ObjectListWidget.ConfigForm({'model': model_info})
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
@tag('regression')
|
||||
def test_widget_fails_gracefully(self):
|
||||
"""
|
||||
Example:
|
||||
'2829fd9b-5dee-4c9a-81f2-5bd84c350a27': {
|
||||
'class': 'extras.ObjectListWidget',
|
||||
'color': 'indigo',
|
||||
'title': 'Object List',
|
||||
'config': {
|
||||
'model': 'extras.notification',
|
||||
'page_size': None,
|
||||
'url_params': None
|
||||
}
|
||||
}
|
||||
"""
|
||||
config = {
|
||||
# 'class': 'extras.ObjectListWidget', # normally popped off, left for clarity
|
||||
'color': 'yellow',
|
||||
'title': 'this should fail',
|
||||
'config': {
|
||||
'model': 'extras.notification',
|
||||
'page_size': None,
|
||||
'url_params': None,
|
||||
},
|
||||
}
|
||||
|
||||
class Request:
|
||||
class User:
|
||||
def has_perm(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
user = User()
|
||||
|
||||
mock_request = Request()
|
||||
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
|
||||
rendered = widget.render(mock_request)
|
||||
self.assertTrue('Unable to load content. Invalid view name:' in rendered)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user