mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-13 13:53:31 +01:00
Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be903a64a2 | ||
|
|
0d7bac433e | ||
|
|
b1cfbbc472 | ||
|
|
6dd311f600 | ||
|
|
85d250014f | ||
|
|
552c81509a | ||
|
|
ed7a0a32cc | ||
|
|
a544b55e9e | ||
|
|
53e1ab5fc5 | ||
|
|
2c1a9ae455 | ||
|
|
1afa476a19 | ||
|
|
2c06616a1d | ||
|
|
335a8d6449 | ||
|
|
340f9f4fa8 | ||
|
|
c08784da46 | ||
|
|
a2efec09be | ||
|
|
d256c04d9c | ||
|
|
365bb4ba17 | ||
|
|
11816b45e7 | ||
|
|
693c6e4da5 | ||
|
|
c73a974fa9 | ||
|
|
4b21cf604b | ||
|
|
79b9dc2013 | ||
|
|
0e3c35ae58 | ||
|
|
cbfed83f60 | ||
|
|
3cbade536e | ||
|
|
9691bb29b6 | ||
|
|
851b4cc4d3 | ||
|
|
85db007ff5 | ||
|
|
cad3e34d8f | ||
|
|
7b1b91b8ee | ||
|
|
6f36b8513c | ||
|
|
07e2cf0ad2 | ||
|
|
d606cf1b3c | ||
|
|
0b0dab42eb | ||
|
|
d115601da3 | ||
|
|
a61e20849b | ||
|
|
1eca1c3d17 | ||
|
|
5d95d49268 | ||
|
|
6b8bfe9947 | ||
|
|
e87877b6ea | ||
|
|
ebe504c825 | ||
|
|
b6e38b2ebe | ||
|
|
90d0104359 | ||
|
|
88facbafbb | ||
|
|
c9de3128ca | ||
|
|
94c31622ac | ||
|
|
3d3c1c315b | ||
|
|
f4c8f5f5b6 | ||
|
|
19fe5ef25c | ||
|
|
928014c766 | ||
|
|
b5bb732031 | ||
|
|
b8cedfcc08 | ||
|
|
c5ae89ad03 | ||
|
|
4284028bb0 | ||
|
|
3c3943c809 | ||
|
|
17e8773c8c | ||
|
|
f47b158863 | ||
|
|
f7e4fe2a9c | ||
|
|
5098422f68 | ||
|
|
d7922a68d8 | ||
|
|
54c6d95fbb | ||
|
|
b7668fbfc3 | ||
|
|
1c76034069 | ||
|
|
ad0e476788 | ||
|
|
e10f5ec3b4 | ||
|
|
48a3f3cb70 | ||
|
|
238fa704b9 | ||
|
|
3b3511c43c | ||
|
|
da13fa5569 | ||
|
|
5b50920c61 | ||
|
|
d9a7b4ee0e | ||
|
|
282dc7a705 | ||
|
|
e1753c0f9b | ||
|
|
0e94f2e05d | ||
|
|
1c370f45d0 | ||
|
|
24e2fc253a | ||
|
|
fca23c6419 | ||
|
|
e4984d2883 | ||
|
|
6030c521f4 | ||
|
|
83dad6f771 | ||
|
|
bb4930b62f | ||
|
|
7d54357146 | ||
|
|
bbd7ddb7aa | ||
|
|
d285edc0c7 | ||
|
|
699dd72597 | ||
|
|
3cb68e4bc0 | ||
|
|
d37a6210fa | ||
|
|
69c0aac105 | ||
|
|
da6a1ef03e | ||
|
|
d2fee88600 | ||
|
|
710f9e3c46 | ||
|
|
59e12e73c2 | ||
|
|
6f9f1d9d43 | ||
|
|
35e20d156d | ||
|
|
4adb44f60d | ||
|
|
c2cabe0273 | ||
|
|
06bdfdc9e8 | ||
|
|
df7905d257 | ||
|
|
8bdbb49a27 | ||
|
|
7ac21690e5 | ||
|
|
7350950e88 | ||
|
|
8fe3f5e3fd | ||
|
|
44f7ab0970 | ||
|
|
eca2a77584 | ||
|
|
51b2bcf264 | ||
|
|
1ff4e1287f | ||
|
|
f0e137133f | ||
|
|
de622801f1 | ||
|
|
eeb732d96e | ||
|
|
8bb49d2296 | ||
|
|
6629c94148 | ||
|
|
bdcf4c4154 | ||
|
|
c45acf0a7c | ||
|
|
8afbb4421b | ||
|
|
55ef24d56d | ||
|
|
edb7d24b45 | ||
|
|
17ec264f3a | ||
|
|
c21ec2139d | ||
|
|
d7e7137582 | ||
|
|
b7f6b728b9 | ||
|
|
503c78b0db | ||
|
|
cb05288c4d | ||
|
|
0373b8aade | ||
|
|
580d417aa1 | ||
|
|
8571f428b1 | ||
|
|
276a73f820 | ||
|
|
d8fb5a819f | ||
|
|
f14eac58e4 | ||
|
|
1780acc8a6 | ||
|
|
a3b8262ab0 | ||
|
|
a1ee02cdf0 | ||
|
|
f751afcce7 | ||
|
|
17a321a340 | ||
|
|
cf3969bc6c | ||
|
|
9b9afdcf79 | ||
|
|
50e5bb9717 | ||
|
|
a063b5563c | ||
|
|
8678d1a577 | ||
|
|
839609d101 | ||
|
|
dbcd713fe7 | ||
|
|
d216161014 | ||
|
|
056543e1d2 | ||
|
|
af27bf5eff | ||
|
|
29f029d480 | ||
|
|
bd7d4a3f34 | ||
|
|
de5c5aeb2a | ||
|
|
2e74952ac6 | ||
|
|
7cc215437f | ||
|
|
e84e2a7969 | ||
|
|
2d70b50286 | ||
|
|
01fa2710eb | ||
|
|
12d830bcf2 | ||
|
|
c37dfdc150 | ||
|
|
df910928f2 | ||
|
|
1f800a975f | ||
|
|
c7ae2db8e3 | ||
|
|
ae7d6ffd92 | ||
|
|
011bc5bd78 | ||
|
|
040dbcc875 | ||
|
|
64b2ebdc79 | ||
|
|
4afebd3565 | ||
|
|
28aee9b69a | ||
|
|
8fadd6b744 | ||
|
|
c93413dc9c | ||
|
|
bf362f4679 | ||
|
|
da7f67c359 | ||
|
|
2c93dd03e1 | ||
|
|
ced44832f7 | ||
|
|
6af3aad362 | ||
|
|
c728d3c2e8 | ||
|
|
83e2c45e74 | ||
|
|
27864ec865 | ||
|
|
d44f67aea5 | ||
|
|
41e1f24cf7 | ||
|
|
d76ede17d3 |
11
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
11
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
description: Report a reproducible bug in the current release of NetBox
|
||||
labels: ["type: bug"]
|
||||
labels: ["type: bug", "status: needs triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -13,17 +13,20 @@ body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Deployment Type
|
||||
description: How are you running NetBox?
|
||||
description: >
|
||||
How are you running NetBox? (For issues with the Docker image, please go to the
|
||||
[netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
|
||||
options:
|
||||
- Self-hosted
|
||||
- NetBox Cloud
|
||||
- NetBox Enterprise
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.2
|
||||
placeholder: v3.7.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 📖 Documentation Change
|
||||
description: Suggest an addition or modification to the NetBox documentation
|
||||
labels: ["type: documentation"]
|
||||
labels: ["type: documentation", "status: needs triage"]
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
4
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
description: Propose a new NetBox feature or enhancement
|
||||
labels: ["type: feature"]
|
||||
labels: ["type: feature", "status: needs triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.2
|
||||
placeholder: v3.7.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
21
.github/workflows/auto-assign-issue.yml
vendored
Normal file
21
.github/workflows/auto-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# auto-assign-issue (https://github.com/marketplace/actions/auto-assign-issue)
|
||||
name: Issue assignment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: pozil/auto-assign-issue@v1
|
||||
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
|
||||
with:
|
||||
# Weighted assignments
|
||||
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
|
||||
numOfAssignee: 1
|
||||
abortIfPreviousAssignees: true
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -84,4 +84,4 @@ jobs:
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
|
||||
|
||||
- name: Show coverage report
|
||||
run: coverage report --skip-covered --omit *migrations*
|
||||
run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
|
||||
name: 'Close stale issues/PRs'
|
||||
name: Close stale issues/PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -12,10 +12,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v8
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
@@ -1,5 +1,5 @@
|
||||
# lock-threads (https://github.com/marketplace/actions/lock-threads)
|
||||
name: 'Lock threads'
|
||||
name: Lock threads
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -5,7 +5,7 @@
|
||||
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-6-blue" alt="Languages supported" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
|
||||
<p></p>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@ NetBox automatically logs the creation, modification, and deletion of all manage
|
||||
|
||||
<p align="center">
|
||||
<a href="https://netboxlabs.com/netbox-cloud/"><img src="docs/media/misc/netbox_cloud.png" alt="NetBox Cloud" /></a><br />
|
||||
Looking for an enterprise solution? Check out <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong>!
|
||||
Looking for a managed solution? Check out <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> or <strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>!
|
||||
</p>
|
||||
|
||||
## Get Involved
|
||||
|
||||
@@ -61,7 +61,8 @@ django-timezone-field
|
||||
|
||||
# A REST API framework for Django projects
|
||||
# https://www.django-rest-framework.org/community/release-notes/
|
||||
djangorestframework
|
||||
# Pinned to 3.14 for NetBox v3.7
|
||||
djangorestframework<3.15
|
||||
|
||||
# Sane and flexible OpenAPI 3 schema generation for Django REST framework.
|
||||
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
|
||||
@@ -101,11 +102,11 @@ markdown-include
|
||||
mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md
|
||||
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
||||
mkdocstrings[python-legacy]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
netaddr
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"type": "object",
|
||||
"$id": "urn:devicetype-library:generated-schema",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"airflow": {
|
||||
@@ -384,7 +386,10 @@
|
||||
"8gfc-sfpp",
|
||||
"16gfc-sfpp",
|
||||
"32gfc-sfp28",
|
||||
"32gfc-sfpp",
|
||||
"64gfc-qsfpp",
|
||||
"64gfc-sfpdd",
|
||||
"64gfc-sfpp",
|
||||
"128gfc-qsfp28",
|
||||
"infiniband-sdr",
|
||||
"infiniband-ddr",
|
||||
|
||||
@@ -14,3 +14,7 @@ timeout = 120
|
||||
# The maximum number of requests a worker can handle before being respawned
|
||||
max_requests = 5000
|
||||
max_requests_jitter = 500
|
||||
|
||||
# Uncomment this line to accept HTTP headers containing underscores, e.g. for remote
|
||||
# authentication support. See https://docs.gunicorn.org/en/stable/settings.html#header-map
|
||||
# header-map = 'dangerous'
|
||||
|
||||
4
docs/_theme/main.html
vendored
4
docs/_theme/main.html
vendored
@@ -2,8 +2,8 @@
|
||||
|
||||
{% block site_meta %}
|
||||
{{ super() }}
|
||||
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
|
||||
{% if page.canonical_url != 'https://docs.netbox.dev/' %}
|
||||
{# Disable search indexing unless we're building for ReadTheDocs #}
|
||||
{% if not config.extra.readthedocs %}
|
||||
<meta name="robots" content="noindex">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -26,7 +26,10 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
||||
|
||||
Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter.
|
||||
|
||||
Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the users profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
|
||||
Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the user's profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
|
||||
|
||||
!!! warning Verify Header Compatibility
|
||||
Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`.
|
||||
|
||||
### Single Sign-On (SSO)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
||||
|
||||
Default: `|` (Pipe)
|
||||
|
||||
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
@@ -85,6 +85,9 @@ Default: `'HTTP_REMOTE_USER'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
!!! warning Verify Header Compatibility
|
||||
Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`.
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_EMAIL
|
||||
|
||||
@@ -183,6 +183,30 @@ The view name or URL to which a user is redirected after logging out.
|
||||
|
||||
---
|
||||
|
||||
## SECURE_HSTS_INCLUDE_SUBDOMAINS
|
||||
|
||||
Default: False
|
||||
|
||||
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
|
||||
|
||||
---
|
||||
|
||||
## SECURE_HSTS_PRELOAD
|
||||
|
||||
Default: False
|
||||
|
||||
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
|
||||
|
||||
---
|
||||
|
||||
## SECURE_HSTS_SECONDS
|
||||
|
||||
Default: 0
|
||||
|
||||
If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict Transport Security (HSTS) header on all responses that do not already have it. This will instruct the browser that the website must be accessed via HTTPS, blocking any HTTP request.
|
||||
|
||||
---
|
||||
|
||||
## SECURE_SSL_REDIRECT
|
||||
|
||||
Default: False
|
||||
|
||||
@@ -16,10 +16,7 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
Default: `en-us` (US English)
|
||||
|
||||
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
|
||||
|
||||
!!! note
|
||||
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
|
||||
Defines the default preferred language/locale for requests that do not specify one. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -285,6 +285,14 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
* `min_prefix_length` - Minimum length of the mask
|
||||
* `max_prefix_length` - Maximum length of the mask
|
||||
|
||||
### DateVar
|
||||
|
||||
A calendar date. Returns a `datetime.date` object.
|
||||
|
||||
### DateTimeVar
|
||||
|
||||
A complete date & time. Returns a `datetime.datetime` object.
|
||||
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
@@ -390,7 +398,7 @@ class NewBranchScript(Script):
|
||||
name=f'{site.slug}-switch{i}',
|
||||
site=site,
|
||||
status=DeviceStatusChoices.STATUS_PLANNED,
|
||||
role=switch_role
|
||||
device_role=switch_role
|
||||
)
|
||||
switch.full_clean()
|
||||
switch.save()
|
||||
|
||||
@@ -31,8 +31,7 @@ This section entails the installation and configuration of a local PostgreSQL da
|
||||
Once PostgreSQL has been installed, start the service and enable it to run at boot:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl start postgresql
|
||||
sudo systemctl enable postgresql
|
||||
sudo systemctl enable --now postgresql
|
||||
```
|
||||
|
||||
Before continuing, verify that you have installed PostgreSQL 12 or later:
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y redis
|
||||
sudo systemctl start redis
|
||||
sudo systemctl enable redis
|
||||
sudo systemctl enable --now redis
|
||||
```
|
||||
|
||||
Before continuing, verify that your installed version of Redis is at least v4.0:
|
||||
|
||||
@@ -27,8 +27,7 @@ sudo systemctl daemon-reload
|
||||
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl start netbox netbox-rq
|
||||
sudo systemctl enable netbox netbox-rq
|
||||
sudo systemctl enable --now netbox netbox-rq
|
||||
```
|
||||
|
||||
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
|
||||
|
||||
@@ -85,13 +85,19 @@ Each model generally has two views associated with it: a list view and a detail
|
||||
* `/api/dcim/devices/` - List existing devices or create a new device
|
||||
* `/api/dcim/devices/123/` - Retrieve, update, or delete the device with ID 123
|
||||
|
||||
Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
|
||||
Lists of objects can be filtered and ordered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
|
||||
|
||||
```
|
||||
GET /api/dcim/interfaces/?device_id=123
|
||||
```
|
||||
|
||||
See the [filtering documentation](../reference/filtering.md) for more details.
|
||||
An optional `ordering` parameter can be used to define how to sort the results. Building off the previous example, to sort all the interfaces in reverse order of creation (newest to oldest) for a device with ID 123:
|
||||
|
||||
```
|
||||
GET /api/dcim/interfaces/?device_id=123&ordering=-created
|
||||
```
|
||||
|
||||
See the [filtering documentation](../reference/filtering.md) for more details on topics related to filtering, ordering and lookup expressions.
|
||||
|
||||
## Serialization
|
||||
|
||||
@@ -647,18 +653,20 @@ Note that we are _not_ passing an existing REST API token with this request. If
|
||||
{
|
||||
"id": 6,
|
||||
"url": "https://netbox/api/users/tokens/6/",
|
||||
"display": "3c9cb9 (hankhill)",
|
||||
"display": "**********************************3c9cb9",
|
||||
"user": {
|
||||
"id": 2,
|
||||
"url": "https://netbox/api/users/users/2/",
|
||||
"display": "hankhill",
|
||||
"username": "hankhill"
|
||||
},
|
||||
"created": "2021-06-11T20:09:13.339367Z",
|
||||
"created": "2024-03-11T20:09:13.339367Z",
|
||||
"expires": null,
|
||||
"last_used": null,
|
||||
"key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9",
|
||||
"write_enabled": true,
|
||||
"description": ""
|
||||
"description": "",
|
||||
"allowed_ips": []
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class MyModelImportForm(NetBoxModelImportForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Assigned site'
|
||||
help_text=_('Assigned site')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
!!! tip "Plugins Development Tutorial"
|
||||
Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time!
|
||||
|
||||
!!! tip "Plugin Certification Program"
|
||||
NetBox Labs offers a [**Plugin Certification Program**](https://github.com/netbox-community/netbox/wiki/Plugin-Certification-Program) for plugin developers interested in establishing a co-maintainer relationship. The program aims to assure ongoing compatibility, maintainability, and commercial supportability of key plugins.
|
||||
|
||||
NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently.
|
||||
|
||||
!!! info "Django Development"
|
||||
|
||||
@@ -157,7 +157,7 @@ These views are provided to enable or enhance certain NetBox model features, suc
|
||||
|
||||
### Additional Tabs
|
||||
|
||||
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`:
|
||||
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
|
||||
|
||||
```python
|
||||
from dcim.models import Site
|
||||
@@ -173,6 +173,16 @@ class MyView(generic.ObjectView):
|
||||
badge=lambda obj: Stuff.objects.filter(site=obj).count(),
|
||||
permission='myplugin.view_stuff'
|
||||
)
|
||||
|
||||
def get(self, request, pk):
|
||||
...
|
||||
return render(
|
||||
request,
|
||||
"myplugin/mytabview.html",
|
||||
context={
|
||||
"tab": self.tab,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
::: utilities.views.register_model_view
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own.
|
||||
|
||||
Please see the documented instructions for [installing a plugin](./installation.md) to get started.
|
||||
|
||||
## Capabilities
|
||||
|
||||
The NetBox plugin architecture allows for the following:
|
||||
@@ -23,122 +25,3 @@ Either by policy or by technical limitation, the interaction of plugins with Net
|
||||
* **Override core templates.** Plugins can inject additional content where supported, but may not manipulate or remove core content.
|
||||
* **Modify core settings.** A configuration registry is provided for plugins, however they cannot alter or delete the core configuration.
|
||||
* **Disable core components.** Plugins are not permitted to disable or hide core NetBox components.
|
||||
|
||||
## Installing Plugins
|
||||
|
||||
The instructions below detail the process for installing and enabling a NetBox plugin.
|
||||
|
||||
### Install Package
|
||||
|
||||
Download and install the plugin package per its installation instructions. Plugins published via PyPI are typically installed using pip. Be sure to install the plugin within NetBox's virtual environment.
|
||||
|
||||
```no-highlight
|
||||
$ source /opt/netbox/venv/bin/activate
|
||||
(venv) $ pip install <package>
|
||||
```
|
||||
|
||||
Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead.
|
||||
|
||||
### Enable the Plugin
|
||||
|
||||
In `configuration.py`, add the plugin's name to the `PLUGINS` list:
|
||||
|
||||
```python
|
||||
PLUGINS = [
|
||||
'plugin_name',
|
||||
]
|
||||
```
|
||||
|
||||
### Configure Plugin
|
||||
|
||||
If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's README file.
|
||||
|
||||
```no-highlight
|
||||
PLUGINS_CONFIG = {
|
||||
'plugin_name': {
|
||||
'foo': 'bar',
|
||||
'buzz': 'bazz'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Run Database Migrations
|
||||
|
||||
If the plugin introduces new database models, run the provided schema migrations:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox/
|
||||
(venv) $ python3 manage.py migrate
|
||||
```
|
||||
|
||||
### Collect Static Files
|
||||
|
||||
Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox/
|
||||
(venv) $ python3 manage.py collectstatic
|
||||
```
|
||||
|
||||
### Restart WSGI Service
|
||||
|
||||
Restart the WSGI service to load the new plugin:
|
||||
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
## Removing Plugins
|
||||
|
||||
Follow these steps to completely remove a plugin.
|
||||
|
||||
### Update Configuration
|
||||
|
||||
Remove the plugin from the `PLUGINS` list in `configuration.py`. Also remove any relevant configuration parameters from `PLUGINS_CONFIG`.
|
||||
|
||||
### Remove the Python Package
|
||||
|
||||
Use `pip` to remove the installed plugin:
|
||||
|
||||
```no-highlight
|
||||
$ source /opt/netbox/venv/bin/activate
|
||||
(venv) $ pip uninstall <package>
|
||||
```
|
||||
|
||||
### Restart WSGI Service
|
||||
|
||||
Restart the WSGI service:
|
||||
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
### Drop Database Tables
|
||||
|
||||
!!! note
|
||||
This step is necessary only for plugin which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure.
|
||||
|
||||
Enter the PostgreSQL database shell to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.)
|
||||
|
||||
```no-highlight
|
||||
netbox=> \dt pluginname_*
|
||||
List of relations
|
||||
List of relations
|
||||
Schema | Name | Type | Owner
|
||||
--------+----------------+-------+--------
|
||||
public | pluginname_foo | table | netbox
|
||||
public | pluginname_bar | table | netbox
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
|
||||
|
||||
Drop each of the listed tables to remove it from the database:
|
||||
|
||||
```no-highlight
|
||||
netbox=> DROP TABLE pluginname_foo;
|
||||
DROP TABLE
|
||||
netbox=> DROP TABLE pluginname_bar;
|
||||
DROP TABLE
|
||||
```
|
||||
|
||||
68
docs/plugins/installation.md
Normal file
68
docs/plugins/installation.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Installing a Plugin
|
||||
|
||||
!!! warning
|
||||
The instructions below detail the general process for installing and configuring a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to install it.
|
||||
|
||||
## Install the Python Package
|
||||
|
||||
Download and install the plugin's Python package per its installation instructions. Plugins published via PyPI are typically installed using the [`pip`](https://packaging.python.org/en/latest/tutorials/installing-packages/) command line utility. Be sure to install the plugin within NetBox's virtual environment.
|
||||
|
||||
```no-highlight
|
||||
$ source /opt/netbox/venv/bin/activate
|
||||
(venv) $ pip install <package>
|
||||
```
|
||||
|
||||
Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead.
|
||||
|
||||
## Enable the Plugin
|
||||
|
||||
In `configuration.py`, add the plugin's name to the `PLUGINS` list:
|
||||
|
||||
```python
|
||||
PLUGINS = [
|
||||
# ...
|
||||
'plugin_name',
|
||||
]
|
||||
```
|
||||
|
||||
## Configure the Plugin
|
||||
|
||||
If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's `README` file or other documentation.
|
||||
|
||||
```no-highlight
|
||||
PLUGINS_CONFIG = {
|
||||
'plugin_name': {
|
||||
'foo': 'bar',
|
||||
'buzz': 'bazz'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Run Database Migrations
|
||||
|
||||
If the plugin introduces new database models, run the provided schema migrations:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox/
|
||||
(venv) $ python3 manage.py migrate
|
||||
```
|
||||
|
||||
!!! tip
|
||||
It's okay to run the `migrate` management command even if the plugin does not include any migration files.
|
||||
|
||||
## Collect Static Files
|
||||
|
||||
Plugins may package static resources like images or scripts to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox/
|
||||
(venv) $ python3 manage.py collectstatic
|
||||
```
|
||||
|
||||
### Restart WSGI Service
|
||||
|
||||
Finally, restart the WSGI service and RQ workers to load the new plugin:
|
||||
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
72
docs/plugins/removal.md
Normal file
72
docs/plugins/removal.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Removing a Plugin
|
||||
|
||||
!!! warning
|
||||
The instructions below detail the general process for removing a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to remove it.
|
||||
|
||||
## Disable the Plugin
|
||||
|
||||
Disable the plugin by removing it from the `PLUGINS` list in `configuration.py`.
|
||||
|
||||
## Remove its Configuration
|
||||
|
||||
Delete the plugin's entry (if any) in the `PLUGINS_CONFIG` dictionary in `configuration.py`.
|
||||
|
||||
!!! tip
|
||||
If there's a chance you may reinstall the plugin, consider commenting out any configuration parameters instead of deleting them.
|
||||
|
||||
## Re-index Search Entries
|
||||
|
||||
Run the `reindex` management command to reindex the global search engine. This will remove any stale entries pertaining to objects provided by the plugin.
|
||||
|
||||
```no-highlight
|
||||
$ cd /opt/netbox/netbox/
|
||||
$ source /opt/netbox/venv/bin/activate
|
||||
(venv) $ python3 manage.py reindex
|
||||
```
|
||||
|
||||
## Uninstall its Python Package
|
||||
|
||||
Use `pip` to remove the installed plugin:
|
||||
|
||||
```no-highlight
|
||||
$ source /opt/netbox/venv/bin/activate
|
||||
(venv) $ pip uninstall <package>
|
||||
```
|
||||
|
||||
## Restart WSGI Service
|
||||
|
||||
Restart the WSGI service:
|
||||
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
## Drop Database Tables
|
||||
|
||||
!!! note
|
||||
This step is necessary only for plugins which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure.
|
||||
|
||||
Enter the PostgreSQL database shell (`manage.py dbshell`) to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.)
|
||||
|
||||
```no-highlight
|
||||
netbox=> \dt pluginname_*
|
||||
List of relations
|
||||
List of relations
|
||||
Schema | Name | Type | Owner
|
||||
--------+----------------+-------+--------
|
||||
public | pluginname_foo | table | netbox
|
||||
public | pluginname_bar | table | netbox
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
|
||||
|
||||
Drop each of the listed tables to remove it from the database:
|
||||
|
||||
```no-highlight
|
||||
netbox=> DROP TABLE pluginname_foo;
|
||||
DROP TABLE
|
||||
netbox=> DROP TABLE pluginname_bar;
|
||||
DROP TABLE
|
||||
```
|
||||
@@ -1,353 +1,254 @@
|
||||
---
|
||||
hide:
|
||||
- toc
|
||||
---
|
||||
|
||||
# Markdown
|
||||
|
||||
NetBox supports markdown rendering for certain text fields.
|
||||
NetBox supports Markdown rendering for certain text fields. Some common examples are provided below. For a complete Markdown reference, please see [Markdownguide.org](https://www.markdownguide.org/basic-syntax/).
|
||||
|
||||
## Syntax
|
||||
|
||||
##### Table of Contents
|
||||
[Headers](#headers)
|
||||
[Emphasis](#emphasis)
|
||||
[Lists](#lists)
|
||||
[Links](#links)
|
||||
[Images](#images)
|
||||
[Code Blocks](#code)
|
||||
[Tables](#tables)
|
||||
[Blockquotes](#blockquotes)
|
||||
[Inline HTML](#html)
|
||||
[Horizontal Rule](#hr)
|
||||
[Line Breaks](#lines)
|
||||
|
||||
<a name="headers"></a>
|
||||
|
||||
## Headers
|
||||
## Headings
|
||||
|
||||
```no-highlight
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
<h1>Heading 1</h1>
|
||||
<h2>Heading 2</h2>
|
||||
<h3>Heading 3</h3>
|
||||
<h4>Heading 4</h4>
|
||||
<h5>Heading 5</h5>
|
||||
<h6>Heading 6</h6>
|
||||
|
||||
Alternatively, for H1 and H2, an underline-ish style:
|
||||
|
||||
Alt-H1
|
||||
======
|
||||
```no-highlight
|
||||
Heading 1
|
||||
=========
|
||||
|
||||
Alt-H2
|
||||
------
|
||||
Heading 2
|
||||
---------
|
||||
```
|
||||
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
<h1>Heading 1</h1>
|
||||
<h2>Heading 2</h2>
|
||||
|
||||
<a name="emphasis"></a>
|
||||
|
||||
## Emphasis
|
||||
## Text
|
||||
|
||||
```no-highlight
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
Italicize text with *asterisks* or _underscores_.
|
||||
```
|
||||
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
|
||||
|
||||
<a name="lists"></a>
|
||||
|
||||
## Lists
|
||||
|
||||
(In this example, leading and trailing spaces are shown with with dots: ⋅)
|
||||
Italicize text with *asterisks* or _underscores_.
|
||||
|
||||
```no-highlight
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
⋅⋅* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
⋅⋅1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅
|
||||
⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅
|
||||
⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
Bold text with two **asterisks** or __underscores__.
|
||||
```
|
||||
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
To have a line break without a paragraph, you will need to use two trailing spaces.
|
||||
Note that this line is separate, but within the same paragraph.
|
||||
(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
|
||||
<a name="links"></a>
|
||||
|
||||
## Links
|
||||
|
||||
There are two ways to create links.
|
||||
Bold text with two **asterisks** or __underscores__.
|
||||
|
||||
```no-highlight
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
Strike text with two tildes. ~~Deleted text.~~
|
||||
```
|
||||
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
|
||||
<a name="images"></a>
|
||||
|
||||
## Images
|
||||
|
||||
```
|
||||
Here's the NetBox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /media/misc/netbox_logo.png "Logo Title Text 2"
|
||||
```
|
||||
|
||||
Here's the NetBox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: ../media/misc/netbox_logo.png "Logo Title Text 2"
|
||||
|
||||
<a name="code"></a>
|
||||
|
||||
## Code blocks
|
||||
|
||||
```
|
||||
Inline `code` has `back-ticks around` it.
|
||||
```
|
||||
|
||||
Inline `code` has `back-ticks around` it.
|
||||
|
||||
Blocks of code are fenced by lines with three back-ticks <code>```</code>
|
||||
|
||||
````
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
````
|
||||
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
|
||||
<a name="tables"></a>
|
||||
|
||||
## Tables
|
||||
|
||||
```no-highlight
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell.
|
||||
The outer pipes (|) are optional, and you don't need to make the
|
||||
raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
```
|
||||
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
|
||||
<a name="blockquotes"></a>
|
||||
|
||||
## Blockquotes
|
||||
|
||||
```no-highlight
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
```
|
||||
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
|
||||
<a name="html"></a>
|
||||
|
||||
## Inline HTML
|
||||
|
||||
You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
|
||||
|
||||
```no-highlight
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
|
||||
<a name="hr"></a>
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
```
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
```
|
||||
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
|
||||
<a name="lines"></a>
|
||||
Strike text with two tildes. ~~Deleted text.~~
|
||||
|
||||
## Line Breaks
|
||||
|
||||
By default, Markdown will remove line breaks between successive lines of text. For example:
|
||||
|
||||
```
|
||||
Here's a line for us to start with.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
|
||||
This line is also a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
```no-highlight
|
||||
This is one line.
|
||||
And this is another line.
|
||||
One more line here.
|
||||
```
|
||||
|
||||
Here's a line for us to start with.
|
||||
This is one line.
|
||||
And this is another line.
|
||||
One more line here.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
To preserve line breaks, append two spaces to each line (represented below with the `⋅` character).
|
||||
|
||||
This line is also begins a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
```no-highlight
|
||||
This is one line.⋅⋅
|
||||
And this is another line.⋅⋅
|
||||
One more line here.
|
||||
```
|
||||
|
||||
Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/)
|
||||
This is one line.
|
||||
And this is another line.
|
||||
One more line here.
|
||||
|
||||
## Lists
|
||||
|
||||
Use asterisks or hyphens for unordered lists. Indent items by four spaces to start a child list.
|
||||
|
||||
```no-highlight
|
||||
* Alpha
|
||||
* Bravo
|
||||
* Charlie
|
||||
* Child item 1
|
||||
* Child item 2
|
||||
* Delta
|
||||
```
|
||||
|
||||
* Alpha
|
||||
* Bravo
|
||||
* Charlie
|
||||
* Child item 1
|
||||
* Child item 2
|
||||
* Delta
|
||||
|
||||
Use digits followed by periods for ordered (numbered) lists.
|
||||
|
||||
```no-highlight
|
||||
1. Red
|
||||
2. Green
|
||||
3. Blue
|
||||
1. Light blue
|
||||
2. Dark blue
|
||||
4. Orange
|
||||
```
|
||||
|
||||
1. Red
|
||||
2. Green
|
||||
3. Blue
|
||||
1. Light blue
|
||||
2. Dark blue
|
||||
4. Orange
|
||||
|
||||
## Links
|
||||
|
||||
Text can be rendered as a hyperlink by encasing it in square brackets, followed by a URL in parentheses. A title (text displayed on hover) may optionally be included as well.
|
||||
|
||||
```no-highlight
|
||||
Here's an [example](https://www.example.com) of a link.
|
||||
|
||||
And here's [another link](https://www.example.com "Click me!"), this time with a title.
|
||||
```
|
||||
|
||||
Here's an [example](https://www.example.com) of a link.
|
||||
|
||||
And here's [another link](https://www.example.com "Click me!"), with a title.
|
||||
|
||||
## Images
|
||||
|
||||
The syntax for embedding an image is very similar to that used for a hyperlink. Alternate text should always be provided; this will be displayed if the image fails to load. As with hyperlinks, title text is optional.
|
||||
|
||||
```no-highlight
|
||||

|
||||
```
|
||||
|
||||
## Code Blocks
|
||||
|
||||
Single backticks can be used to annotate code inline. Text enclosed by lines of three backticks will be displayed as a code block.
|
||||
|
||||
```no-highlight
|
||||
Paragraphs are rendered in HTML using `<p>` and `</p>` tags.
|
||||
```
|
||||
|
||||
Paragraphs are rendered in HTML using `<p>` and `</p>` tags.
|
||||
|
||||
````
|
||||
```
|
||||
def my_func(foo, bar):
|
||||
# Do something
|
||||
return foo * bar
|
||||
```
|
||||
````
|
||||
|
||||
```no-highlight
|
||||
def my_func(foo, bar):
|
||||
# Do something
|
||||
return foo * bar
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
Simple tables can be constructed using the pipe character (`|`) to denote columns, and hyphens (`-`) to denote the heading. Inline Markdown can be used to style text within columns.
|
||||
|
||||
```no-highlight
|
||||
| Heading 1 | Heading 2 | Heading 3 |
|
||||
|-----------|-----------|-----------|
|
||||
| Row 1 | Alpha | Red |
|
||||
| Row 2 | **Bravo** | Green |
|
||||
| Row 3 | Charlie | ~~Blue~~ |
|
||||
```
|
||||
|
||||
| Heading 1 | Heading 2 | Heading 3 |
|
||||
|-----------|-----------|-----------|
|
||||
| _Row 1_ | Alpha | Red |
|
||||
| Row 2 | **Bravo** | Green |
|
||||
| Row 3 | Charlie | ~~Blue~~ |
|
||||
|
||||
Colons can be used to align text to the left or right side of a column.
|
||||
|
||||
```no-highlight
|
||||
| Left-aligned | Centered | Right-aligned |
|
||||
|:-------------|:--------:|--------------:|
|
||||
| Text | Text | Text |
|
||||
| Text | Text | Text |
|
||||
| Text | Text | Text |
|
||||
```
|
||||
|
||||
| Left-aligned | Centered | Right-aligned |
|
||||
|:-------------|:--------:|--------------:|
|
||||
| Text | Text | Text |
|
||||
| Text | Text | Text |
|
||||
| Text | Text | Text |
|
||||
|
||||
## Blockquotes
|
||||
|
||||
Text can be wrapped in a blockquote by prepending a right angle bracket (`>`) before each line.
|
||||
|
||||
```no-highlight
|
||||
> I think that I shall never see
|
||||
> a graph more lovely than a tree.
|
||||
> A tree whose crucial property
|
||||
> is loop-free connectivity.
|
||||
```
|
||||
|
||||
> I think that I shall never see
|
||||
> a graph more lovely than a tree.
|
||||
> A tree whose crucial property
|
||||
> is loop-free connectivity.
|
||||
|
||||
Markdown removes line breaks by default. To preserve line breaks, append two spaces to each line (represented below with the `⋅` character).
|
||||
|
||||
```no-highlight
|
||||
> I think that I shall never see⋅⋅
|
||||
> a graph more lovely than a tree.⋅⋅
|
||||
> A tree whose crucial property⋅⋅
|
||||
> is loop-free connectivity.
|
||||
```
|
||||
|
||||
> I think that I shall never see
|
||||
> a graph more lovely than a tree.
|
||||
> A tree whose crucial property
|
||||
> is loop-free connectivity.
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
A horizontal rule is a single line rendered across the width of the page using a series of three or more hyphens or asterisks. It can be useful for separating sections of content.
|
||||
|
||||
```no-highlight
|
||||
Content
|
||||
|
||||
---
|
||||
|
||||
More content
|
||||
|
||||
***
|
||||
|
||||
Final content
|
||||
```
|
||||
|
||||
Content
|
||||
|
||||
---
|
||||
|
||||
More content
|
||||
|
||||
***
|
||||
|
||||
Final content
|
||||
|
||||
@@ -1,5 +1,156 @@
|
||||
# NetBox v3.7
|
||||
|
||||
## v3.7.8 (2024-05-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12127](https://github.com/netbox-community/netbox/issues/12127) - Enable adding new cables directly from navigation menu
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#15877](https://github.com/netbox-community/netbox/issues/15877) - Account for virtual chassis membership when assigning related interfaces via bulk edit
|
||||
* [#15917](https://github.com/netbox-community/netbox/issues/15917) - Fix pagination through search results within dropdown fields
|
||||
* [#15925](https://github.com/netbox-community/netbox/issues/15925) - Fix SVG rendering of cable traces to circuit terminations
|
||||
* [#15948](https://github.com/netbox-community/netbox/issues/15948) - Fix cable trace SVG generation for cables with multiple terminations at both ends
|
||||
* [#15960](https://github.com/netbox-community/netbox/issues/15960) - Replace CSV export formatting for several many-to-many fields
|
||||
* [#15961](https://github.com/netbox-community/netbox/issues/15961) - Fix secret toggle button for IKE policies
|
||||
|
||||
---
|
||||
|
||||
## v3.7.7 (2024-05-01)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#15428](https://github.com/netbox-community/netbox/issues/15428) - Show usage counts for associated objects on config template list
|
||||
* [#15812](https://github.com/netbox-community/netbox/issues/15812) - Add Date & DateTime variable types for custom scripts
|
||||
* [#15894](https://github.com/netbox-community/netbox/issues/15894) - Cache the generated API schema definition for shorter loading times
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11460](https://github.com/netbox-community/netbox/issues/11460) - Fix AttributeError exception when editing a cable with only one end terminated
|
||||
* [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display
|
||||
* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display
|
||||
* [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices
|
||||
* [#14241](https://github.com/netbox-community/netbox/issues/14241) - Fix random interface swap when performing cable trace with multiple termination
|
||||
* [#14852](https://github.com/netbox-community/netbox/issues/14852) - Fix NoReverseMatch exception when viewing an event rule which references a deleted custom script
|
||||
* [#15524](https://github.com/netbox-community/netbox/issues/15524) - Fix rounding error when reporting IP range utilization
|
||||
* [#15548](https://github.com/netbox-community/netbox/issues/15548) - Ignore many-to-many mappings when checking dependencies of an object being deleted
|
||||
* [#15845](https://github.com/netbox-community/netbox/issues/15845) - Avoid extraneous database queries when fetching assigned IP addresses via REST API
|
||||
* [#15872](https://github.com/netbox-community/netbox/issues/15872) - `BANNER_MAINTENANCE` content should permit custom HTML
|
||||
* [#15891](https://github.com/netbox-community/netbox/issues/15891) - Ensure deterministic ordering for scripts & reports
|
||||
* [#15896](https://github.com/netbox-community/netbox/issues/15896) - Fix retention of default value when editing a custom JSON field
|
||||
* [#15899](https://github.com/netbox-community/netbox/issues/15899) - Fix exception when enabling the tags column on the L2VPN terminations table
|
||||
|
||||
---
|
||||
|
||||
## v3.7.6 (2024-04-22)
|
||||
|
||||
!!! warning
|
||||
If remote authentication is in use with Gunicorn v22.0 or later, it may be necessary to configure Gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to preserve authentication headers.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form
|
||||
* [#15427](https://github.com/netbox-community/netbox/issues/15427) - Enable compatibility with non-Amazon S3 providers for remote data sources
|
||||
* [#15640](https://github.com/netbox-community/netbox/issues/15640) - Add global search support for L2VPN identifiers
|
||||
* [#15644](https://github.com/netbox-community/netbox/issues/15644) - Introduce new configuration parameters for enabling HTTP Strict Transport Security (HSTS)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#15541](https://github.com/netbox-community/netbox/issues/15541) - Restore ability to modify assigned component template when adding/modifying an inventory item template
|
||||
* [#15582](https://github.com/netbox-community/netbox/issues/15582) - Fix permission constraints for synchronization of remote data sources
|
||||
* [#15588](https://github.com/netbox-community/netbox/issues/15588) - Correct OpenAPI schema definitions for read-only fields which may return null values
|
||||
* [#15635](https://github.com/netbox-community/netbox/issues/15635) - Extend plugin removal instruction to include reindexing the global search cache
|
||||
* [#15654](https://github.com/netbox-community/netbox/issues/15654) - Fix `AttributeError` exception when attempting to save an incomplete tunnel termination
|
||||
* [#15668](https://github.com/netbox-community/netbox/issues/15668) - Fix permission required to display virtual disks tab on virtual machine UI view
|
||||
* [#15685](https://github.com/netbox-community/netbox/issues/15685) - Allow filtering cables by decimal values using UI filter form
|
||||
* [#15761](https://github.com/netbox-community/netbox/issues/15761) - Add missing `ike_policy` & `ike_policy_id` filters for IKE proposals
|
||||
* [#15771](https://github.com/netbox-community/netbox/issues/15771) - Include `id` in list of supported fields for all bulk import forms
|
||||
* [#15790](https://github.com/netbox-community/netbox/issues/15790) - Fix live preview support for EventRule comments
|
||||
|
||||
---
|
||||
|
||||
## v3.7.5 (2024-04-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14707](https://github.com/netbox-community/netbox/issues/14707) - Clarify interface designation when creating tunnel terminations
|
||||
* [#15039](https://github.com/netbox-community/netbox/issues/15039) - Allow API tokens to be cloned
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14799](https://github.com/netbox-community/netbox/issues/14799) - Avoid caching modified reports & scripts
|
||||
* [#15029](https://github.com/netbox-community/netbox/issues/15029) - Raise a clean validation error when attempting to make duplicate FHRP group assignments
|
||||
* [#15102](https://github.com/netbox-community/netbox/issues/15102) - Fix usage of selector widget for form fields referencing users/groups
|
||||
* [#15435](https://github.com/netbox-community/netbox/issues/15435) - Correct permissions name to allow adding a module bay to a device via the UI
|
||||
* [#15502](https://github.com/netbox-community/netbox/issues/15502) - Fix KeyError exception when modifying an IP address assigned to a virtual machine
|
||||
* [#15597](https://github.com/netbox-community/netbox/issues/15597) - Restore help modal for `button_class` field on custom link bulk import form
|
||||
* [#15598](https://github.com/netbox-community/netbox/issues/15598) - Fix exception when creating a device from a device type with one or more child inventory items
|
||||
* [#15608](https://github.com/netbox-community/netbox/issues/15608) - Avoid caching values of null fields in search index
|
||||
* [#15609](https://github.com/netbox-community/netbox/issues/15609) - Fix filtering of the providers list by assigned ASN
|
||||
|
||||
---
|
||||
|
||||
## v3.7.4 (2024-03-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14206](https://github.com/netbox-community/netbox/issues/14206) - Add additional FibreChannel SFP+ interface types
|
||||
* [#14366](https://github.com/netbox-community/netbox/issues/14366) - Enable custom links for config contexts & templates
|
||||
* [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table
|
||||
* [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13722](https://github.com/netbox-community/netbox/issues/13722) - Fix range expansion for comma-separated numerical values
|
||||
* [#14832](https://github.com/netbox-community/netbox/issues/14832) - Enable querying IP addresses for an FHRP group via GraphQL
|
||||
* [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses
|
||||
* [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type
|
||||
* [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API
|
||||
* [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode
|
||||
* [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals
|
||||
* [#15322](https://github.com/netbox-community/netbox/issues/15322) - Add description field to YAML export for device & module types
|
||||
* [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs
|
||||
* [#15347](https://github.com/netbox-community/netbox/issues/15347) - Fix querying virtual machine contacts via GraphQL
|
||||
* [#15356](https://github.com/netbox-community/netbox/issues/15356) - Fix assignment of front & rear images to device types via REST API
|
||||
|
||||
---
|
||||
|
||||
## v3.7.3 (2024-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14587](https://github.com/netbox-community/netbox/issues/14587) - Display a human-friendly name for the OpenID Connect remote auth backend
|
||||
* [#14946](https://github.com/netbox-community/netbox/issues/14946) - Remove `associate_by_email()` from default social auth pipeline
|
||||
* [#14966](https://github.com/netbox-community/netbox/issues/14966) - Add PostgreSQL index for object type & ID on CachedValue table to improve performance
|
||||
* [#15177](https://github.com/netbox-community/netbox/issues/15177) - Add "last login" time to user display & REST API serializer
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14058](https://github.com/netbox-community/netbox/issues/14058) - Limit platform options by manufacturer when editing a device or device type
|
||||
* [#14064](https://github.com/netbox-community/netbox/issues/14064) - Resolving parent location should consider assigned site when bulk importing locations
|
||||
* [#14079](https://github.com/netbox-community/netbox/issues/14079) - Ensure changes are logged on related objects when deleting an object referenced via a many-to-many relationship (e.g. tags)
|
||||
* [#14405](https://github.com/netbox-community/netbox/issues/14405) - Clean up formatting of link peers in bulk CSV export of cable termination objects
|
||||
* [#14689](https://github.com/netbox-community/netbox/issues/14689) - Preserve "empty" default values for JSON custom fields
|
||||
* [#14952](https://github.com/netbox-community/netbox/issues/14952) - Update existing AutoSyncRecord when changing the data file of an auto-synced object
|
||||
* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table
|
||||
* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import
|
||||
* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines
|
||||
* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views
|
||||
* [#15090](https://github.com/netbox-community/netbox/issues/15090) - Ensure protection rules are evaluated prior to enqueueing events when deleting an object
|
||||
* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination
|
||||
* [#15101](https://github.com/netbox-community/netbox/issues/15101) - Correct OpenAPI schema for rack elevation REST API endpoint
|
||||
* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints
|
||||
* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API
|
||||
* [#15127](https://github.com/netbox-community/netbox/issues/15127) - Add missing group column to VPN tunnels table
|
||||
* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode
|
||||
* [#15174](https://github.com/netbox-community/netbox/issues/15174) - Warn that permission constraints are not supported for reports or scripts
|
||||
* [#15184](https://github.com/netbox-community/netbox/issues/15184) - Correct REST API schema definition for `front_image` & `rear_image` on DeviceType
|
||||
* [#15185](https://github.com/netbox-community/netbox/issues/15185) - Ensure error messages pertaining to related objects are displayed on the bulk import form
|
||||
* [#15192](https://github.com/netbox-community/netbox/issues/15192) - Fix exception when viewing current config when no history is present
|
||||
|
||||
---
|
||||
|
||||
## v3.7.2 (2024-02-05)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -42,6 +42,7 @@ plugins:
|
||||
show_root_toc_entry: false
|
||||
show_source: false
|
||||
extra:
|
||||
readthedocs: !ENV READTHEDOCS
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/netbox-community/netbox
|
||||
@@ -127,7 +128,9 @@ nav:
|
||||
- Synchronized Data: 'integrations/synchronized-data.md'
|
||||
- Prometheus Metrics: 'integrations/prometheus-metrics.md'
|
||||
- Plugins:
|
||||
- Using Plugins: 'plugins/index.md'
|
||||
- About Plugins: 'plugins/index.md'
|
||||
- Installing a Plugin: 'plugins/installation.md'
|
||||
- Removing a Plugin: 'plugins/removal.md'
|
||||
- Developing Plugins:
|
||||
- Getting Started: 'plugins/development/index.md'
|
||||
- Models: 'plugins/development/models.md'
|
||||
|
||||
@@ -64,6 +64,12 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
queryset=ASN.objects.all(),
|
||||
label=_('ASN (ID)'),
|
||||
)
|
||||
asn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='asns__asn',
|
||||
queryset=ASN.objects.all(),
|
||||
to_field_name='asn',
|
||||
label=_('ASN'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
|
||||
@@ -24,7 +24,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id')),
|
||||
(_('ASN'), ('asn',)),
|
||||
(_('ASN'), ('asn_id',)),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -46,10 +46,6 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
asn = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('ASN (legacy)')
|
||||
)
|
||||
asn_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -234,9 +234,9 @@ class CircuitTermination(
|
||||
|
||||
# Must define either site *or* provider network
|
||||
if self.site is None and self.provider_network is None:
|
||||
raise ValidationError("A circuit termination must attach to either a site or a provider network.")
|
||||
raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
|
||||
if self.site and self.provider_network:
|
||||
raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
|
||||
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
@@ -90,10 +90,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_asn_id(self): # ASN object assignment
|
||||
def test_asn(self):
|
||||
asns = ASN.objects.all()[:2]
|
||||
params = {'asn_id': [asns[0].pk, asns[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'asn': [asns[0].asn, asns[1].asn]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 rest_framework import serializers
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
|
||||
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
@@ -33,10 +33,11 @@ class DataSourceViewSet(NetBoxModelViewSet):
|
||||
"""
|
||||
Enqueue a job to synchronize the DataSource.
|
||||
"""
|
||||
if not request.user.has_perm('core.sync_datasource'):
|
||||
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
|
||||
|
||||
datasource = get_object_or_404(DataSource, pk=pk)
|
||||
|
||||
if not request.user.has_perm('core.sync_datasource', obj=datasource):
|
||||
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
|
||||
|
||||
datasource.enqueue_sync_job(request)
|
||||
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ class GitBackend(DataBackend):
|
||||
try:
|
||||
porcelain.clone(self.url, local_path.name, **clone_args)
|
||||
except BaseException as e:
|
||||
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
|
||||
raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
|
||||
|
||||
yield local_path.name
|
||||
|
||||
@@ -149,7 +149,8 @@ class S3Backend(DataBackend):
|
||||
region_name=self._region_name,
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
config=self.config
|
||||
config=self.config,
|
||||
endpoint_url=self._endpoint_url
|
||||
)
|
||||
bucket = s3.Bucket(self._bucket_name)
|
||||
|
||||
@@ -176,6 +177,11 @@ class S3Backend(DataBackend):
|
||||
url_path = urlparse(self.url).path.lstrip('/')
|
||||
return url_path.split('/')[0]
|
||||
|
||||
@property
|
||||
def _endpoint_url(self):
|
||||
url_path = urlparse(self.url)
|
||||
return url_path._replace(params="", fragment="", query="", path="").geturl()
|
||||
|
||||
@property
|
||||
def _remote_path(self):
|
||||
url_path = urlparse(self.url).path.lstrip('/')
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.forms.fields import JSONField as _JSONField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
@@ -12,7 +13,7 @@ from netbox.forms import NetBoxModelForm
|
||||
from netbox.registry import registry
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from utilities.forms import BootstrapMixin, get_field_value
|
||||
from utilities.forms.fields import CommentField
|
||||
from utilities.forms.fields import CommentField, JSONField
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
|
||||
__all__ = (
|
||||
@@ -103,9 +104,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
|
||||
super().clean()
|
||||
|
||||
if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Cannot upload a file and sync from an existing file")
|
||||
raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
|
||||
if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Must upload a file or select a data file to sync")
|
||||
raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
@@ -132,6 +133,9 @@ class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
|
||||
'help_text': param.description,
|
||||
}
|
||||
field_kwargs.update(**param.field_kwargs)
|
||||
if param.field is _JSONField:
|
||||
# Replace with our own JSONField to get pretty JSON in config editor
|
||||
param.field = JSONField
|
||||
param_fields[param.name] = param.field(**field_kwargs)
|
||||
attrs.update(param_fields)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
if self.data and item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
|
||||
"""
|
||||
if self.status == DataSourceStatusChoices.SYNCING:
|
||||
raise SyncError("Cannot initiate sync; syncing already in progress.")
|
||||
raise SyncError(_("Cannot initiate sync; syncing already in progress."))
|
||||
|
||||
# Emit the pre_sync signal
|
||||
pre_sync.send(sender=self.__class__, instance=self)
|
||||
@@ -190,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
backend = self.get_backend()
|
||||
except ModuleNotFoundError as e:
|
||||
raise SyncError(
|
||||
f"There was an error initializing the backend. A dependency needs to be installed: {e}"
|
||||
_("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
|
||||
)
|
||||
with backend.fetch() as local_path:
|
||||
|
||||
|
||||
@@ -181,7 +181,11 @@ class Job(models.Model):
|
||||
"""
|
||||
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
if status not in valid_statuses:
|
||||
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
|
||||
raise ValueError(
|
||||
_("Invalid status for job termination. Choices are: {choices}").format(
|
||||
choices=', '.join(valid_statuses)
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the job as completed
|
||||
self.status = status
|
||||
|
||||
@@ -166,7 +166,7 @@ class ConfigView(generic.ObjectView):
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config()
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -414,11 +414,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedModuleBaySerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
||||
module = NestedModuleSerializer(required=False, read_only=True, allow_null=True)
|
||||
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ModuleBay
|
||||
fields = ['id', 'url', 'display', 'module', 'name']
|
||||
fields = ['id', 'url', 'display', 'installed_module', 'name']
|
||||
|
||||
|
||||
class NestedDeviceBaySerializer(WritableNestedSerializer):
|
||||
|
||||
@@ -326,6 +326,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
front_image = serializers.ImageField(required=False, allow_null=True)
|
||||
rear_image = serializers.ImageField(required=False, allow_null=True)
|
||||
|
||||
# Counter fields
|
||||
console_port_template_count = serializers.IntegerField(read_only=True)
|
||||
@@ -610,7 +612,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
component = serializers.SerializerMethodField(read_only=True)
|
||||
component = serializers.SerializerMethodField(read_only=True, allow_null=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -666,7 +668,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
role = NestedDeviceRoleSerializer()
|
||||
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
|
||||
device_role = NestedDeviceRoleSerializer(read_only=True, help_text=_('Deprecated in v3.6 in favor of `role`.'))
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
|
||||
platform = NestedPlatformSerializer(required=False, allow_null=True)
|
||||
site = NestedSiteSerializer()
|
||||
@@ -683,7 +685,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
)
|
||||
status = ChoiceField(choices=DeviceStatusChoices, required=False)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True)
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
@@ -733,7 +735,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
|
||||
|
||||
class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
config_context = serializers.SerializerMethodField(read_only=True)
|
||||
config_context = serializers.SerializerMethodField(read_only=True, allow_null=True)
|
||||
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
@@ -1037,8 +1039,7 @@ class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
|
||||
'custom_fields',
|
||||
'created', 'last_updated',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -1066,7 +1067,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
component = serializers.SerializerMethodField(read_only=True)
|
||||
component = serializers.SerializerMethodField(read_only=True, allow_null=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -191,6 +191,12 @@ class RackViewSet(NetBoxModelViewSet):
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filtersets.RackFilterSet
|
||||
|
||||
@extend_schema(
|
||||
operation_id='dcim_racks_elevation_retrieve',
|
||||
filters=False,
|
||||
parameters=[serializers.RackElevationDetailFilterSerializer],
|
||||
responses={200: serializers.RackUnitSerializer(many=True)}
|
||||
)
|
||||
@action(detail=True)
|
||||
def elevation(self, request, pk=None):
|
||||
"""
|
||||
|
||||
@@ -889,7 +889,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
|
||||
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
|
||||
TYPE_32GFC_SFP28 = '32gfc-sfp28'
|
||||
TYPE_32GFC_SFP_PLUS = '32gfc-sfpp'
|
||||
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
|
||||
TYPE_64GFC_SFP_DD = '64gfc-sfpdd'
|
||||
TYPE_64GFC_SFP_PLUS = '64gfc-sfpp'
|
||||
TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
|
||||
|
||||
# InfiniBand
|
||||
@@ -1058,7 +1061,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
|
||||
(TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
|
||||
(TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
|
||||
(TYPE_32GFC_SFP_PLUS, 'SFP+ (32GFC)'),
|
||||
(TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
|
||||
(TYPE_64GFC_SFP_DD, 'SFP-DD (64GFC)'),
|
||||
(TYPE_64GFC_SFP_PLUS, 'SFP+ (64GFC)'),
|
||||
(TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
|
||||
)
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
|
||||
|
||||
from .lookups import PathContains
|
||||
@@ -41,7 +42,7 @@ class MACAddressField(models.Field):
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid MAC address format: {value}")
|
||||
raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr'
|
||||
@@ -67,7 +68,7 @@ class WWNField(models.Field):
|
||||
try:
|
||||
return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid WWN format: {value}")
|
||||
raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr8'
|
||||
|
||||
@@ -2,6 +2,8 @@ import django_filters
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
@@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Manufacturer (slug)'),
|
||||
)
|
||||
available_for_device_type = django_filters.ModelChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
method='get_for_device_type'
|
||||
)
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label=_('Config template (ID)'),
|
||||
@@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_device_type(self, queryset, name, value):
|
||||
"""
|
||||
Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
|
||||
manufacturer
|
||||
"""
|
||||
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
|
||||
|
||||
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
|
||||
@@ -1411,9 +1411,9 @@ class InterfaceBulkEditForm(
|
||||
device = Device.objects.filter(pk=self.initial['device']).first()
|
||||
|
||||
# Restrict parent/bridge/LAG interface assignment by device
|
||||
self.fields['parent'].widget.add_query_param('device_id', device.pk)
|
||||
self.fields['bridge'].widget.add_query_param('device_id', device.pk)
|
||||
self.fields['lag'].widget.add_query_param('device_id', device.pk)
|
||||
self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
||||
self.fields['bridge'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
||||
self.fields['lag'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
||||
|
||||
# Limit VLAN choices by device
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
|
||||
|
||||
@@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
|
||||
model = Location
|
||||
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit location queryset by assigned site
|
||||
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
|
||||
class RackRoleImportForm(NetBoxModelImportForm):
|
||||
slug = SlugField()
|
||||
@@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
def clean_vdcs(self):
|
||||
for vdc in self.cleaned_data['vdcs']:
|
||||
if vdc.device != self.cleaned_data['device']:
|
||||
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
|
||||
raise forms.ValidationError(
|
||||
_("VDC {vdc} is not assigned to device {device}").format(
|
||||
vdc=vdc, device=self.cleaned_data['device']
|
||||
)
|
||||
)
|
||||
return self.cleaned_data['vdcs']
|
||||
|
||||
|
||||
@@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
|
||||
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
).exclude(pk=device.pk)
|
||||
else:
|
||||
self.fields['installed_device'].queryset = Interface.objects.none()
|
||||
self.fields['installed_device'].queryset = Device.objects.none()
|
||||
|
||||
|
||||
class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
@@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
raise forms.ValidationError(
|
||||
_("Side {side_upper}: {device} {termination_object} is already connected").format(
|
||||
side_upper=side.upper(), device=device, termination_object=termination_object
|
||||
)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
raise forms.ValidationError(
|
||||
_("{side_upper} side termination not found: {device} {name}").format(
|
||||
side_upper=side.upper(), device=device, name=name
|
||||
)
|
||||
)
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
@@ -1350,14 +1373,14 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
|
||||
label=_('Device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Assigned role'
|
||||
help_text=_('Assigned role')
|
||||
)
|
||||
tenant = CSVModelChoiceField(
|
||||
label=_('Tenant'),
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Assigned tenant'
|
||||
help_text=_('Assigned tenant')
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
label=_('Status'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
@@ -82,14 +83,22 @@ def get_cable_form(a_type, b_type):
|
||||
|
||||
class _CableForm(CableForm, metaclass=FormMetaclass):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
initial = initial or {}
|
||||
if a_type:
|
||||
ct = ContentType.objects.get_for_model(a_type)
|
||||
initial['a_terminations_type'] = f'{ct.app_label}.{ct.model}'
|
||||
if b_type:
|
||||
ct = ContentType.objects.get_for_model(b_type)
|
||||
initial['b_terminations_type'] = f'{ct.app_label}.{ct.model}'
|
||||
|
||||
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
|
||||
for field_name in ('a_terminations', 'b_terminations'):
|
||||
if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
|
||||
kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
|
||||
if field_name in initial and type(initial[field_name]) is not list:
|
||||
initial[field_name] = [initial[field_name]]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
# Initialize A/B terminations when modifying an existing Cable instance
|
||||
@@ -100,7 +109,7 @@ def get_cable_form(a_type, b_type):
|
||||
super().clean()
|
||||
|
||||
# Set the A/B terminations on the Cable instance
|
||||
self.instance.a_terminations = self.cleaned_data['a_terminations']
|
||||
self.instance.b_terminations = self.cleaned_data['b_terminations']
|
||||
self.instance.a_terminations = self.cleaned_data.get('a_terminations', [])
|
||||
self.instance.b_terminations = self.cleaned_data.get('b_terminations', [])
|
||||
|
||||
return _CableForm
|
||||
|
||||
@@ -977,9 +977,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
length = forms.IntegerField(
|
||||
length = forms.DecimalField(
|
||||
label=_('Length'),
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
length_unit = forms.ChoiceField(
|
||||
label=_('Length unit'),
|
||||
|
||||
@@ -13,8 +13,7 @@ from netbox.forms import NetBoxModelForm
|
||||
from tenancy.forms import TenancyForm
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
||||
NumericArrayField, SlugField,
|
||||
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
|
||||
)
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||
from virtualization.models import Cluster
|
||||
@@ -291,7 +290,11 @@ class DeviceTypeForm(NetBoxModelForm):
|
||||
default_platform = DynamicModelChoiceField(
|
||||
label=_('Default platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'manufacturer_id': ['$manufacturer', 'null'],
|
||||
}
|
||||
)
|
||||
slug = SlugField(
|
||||
label=_('Slug'),
|
||||
@@ -444,7 +447,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
selector=True
|
||||
selector=True,
|
||||
query_params={
|
||||
'available_for_device_type': '$device_type',
|
||||
}
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
@@ -609,14 +615,33 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
self.fields['adopt_components'].disabled = True
|
||||
|
||||
|
||||
def get_termination_type_choices():
|
||||
return add_blank_choice([
|
||||
(f'{ct.app_label}.{ct.model}', ct.model_class()._meta.verbose_name.title())
|
||||
for ct in ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
])
|
||||
|
||||
|
||||
class CableForm(TenancyForm, NetBoxModelForm):
|
||||
a_terminations_type = forms.ChoiceField(
|
||||
choices=get_termination_type_choices,
|
||||
required=False,
|
||||
widget=HTMXSelect(),
|
||||
label=_('Type')
|
||||
)
|
||||
b_terminations_type = forms.ChoiceField(
|
||||
choices=get_termination_type_choices,
|
||||
required=False,
|
||||
widget=HTMXSelect(),
|
||||
label=_('Type')
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
||||
'comments', 'tags',
|
||||
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
error_messages = {
|
||||
'length': {
|
||||
@@ -969,21 +994,67 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
)
|
||||
component_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
|
||||
# Assigned component selectors
|
||||
consoleporttemplate = DynamicModelChoiceField(
|
||||
queryset=ConsolePortTemplate.objects.all(),
|
||||
required=False,
|
||||
widget=forms.HiddenInput
|
||||
query_params={
|
||||
'device_type_id': '$device_type'
|
||||
},
|
||||
label=_('Console port template')
|
||||
)
|
||||
component_id = forms.IntegerField(
|
||||
consoleserverporttemplate = DynamicModelChoiceField(
|
||||
queryset=ConsoleServerPortTemplate.objects.all(),
|
||||
required=False,
|
||||
widget=forms.HiddenInput
|
||||
query_params={
|
||||
'device_type_id': '$device_type'
|
||||
},
|
||||
label=_('Console server port template')
|
||||
)
|
||||
frontporttemplate = DynamicModelChoiceField(
|
||||
queryset=FrontPortTemplate.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_type_id': '$device_type'
|
||||
},
|
||||
label=_('Front port template')
|
||||
)
|
||||
interfacetemplate = DynamicModelChoiceField(
|
||||
queryset=InterfaceTemplate.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_type_id': '$device_type'
|
||||
},
|
||||
label=_('Interface template')
|
||||
)
|
||||
poweroutlettemplate = DynamicModelChoiceField(
|
||||
queryset=PowerOutletTemplate.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_type_id': '$device_type'
|
||||
},
|
||||
label=_('Power outlet template')
|
||||
)
|
||||
powerporttemplate = DynamicModelChoiceField(
|
||||
queryset=PowerPortTemplate.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_type_id': '$device_type'
|
||||
},
|
||||
label=_('Power port template')
|
||||
)
|
||||
rearporttemplate = DynamicModelChoiceField(
|
||||
queryset=RearPortTemplate.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_type_id': '$device_type'
|
||||
},
|
||||
label=_('Rear port template')
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
|
||||
'component_type', 'component_id',
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -991,9 +1062,52 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
|
||||
model = InventoryItemTemplate
|
||||
fields = [
|
||||
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
|
||||
'component_type', 'component_id',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
component_type = initial.get('component_type')
|
||||
component_id = initial.get('component_id')
|
||||
|
||||
# Used for picking the default active tab for component selection
|
||||
self.no_component = True
|
||||
|
||||
if instance:
|
||||
# When editing set the initial value for component selection
|
||||
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS):
|
||||
if type(instance.component) is component_model.model_class():
|
||||
initial[component_model.model] = instance.component
|
||||
self.no_component = False
|
||||
break
|
||||
elif component_type and component_id:
|
||||
# When adding the InventoryItem from a component page
|
||||
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS).filter(pk=component_type).first():
|
||||
if component := content_type.model_class().objects.filter(pk=component_id).first():
|
||||
initial[content_type.model] = component
|
||||
self.no_component = False
|
||||
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Handle object assignment
|
||||
selected_objects = [
|
||||
field for field in (
|
||||
'consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate',
|
||||
'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'
|
||||
) if self.cleaned_data[field]
|
||||
]
|
||||
if len(selected_objects) > 1:
|
||||
raise forms.ValidationError(_("An InventoryItem can only be assigned to a single component."))
|
||||
elif selected_objects:
|
||||
self.instance.component = self.cleaned_data[selected_objects[0]]
|
||||
else:
|
||||
self.instance.component = None
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
|
||||
@@ -160,25 +160,26 @@ class Cable(PrimaryModel):
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
raise ValidationError(_("Must specify a unit when setting a cable length"))
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
|
||||
|
||||
if self._terminations_modified:
|
||||
|
||||
# Check that all termination objects for either end are of the same type
|
||||
for terms in (self.a_terminations, self.b_terminations):
|
||||
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
|
||||
raise ValidationError("Cannot connect different termination types to same end of cable.")
|
||||
raise ValidationError(_("Cannot connect different termination types to same end of cable."))
|
||||
|
||||
# Check that termination types are compatible
|
||||
if self.a_terminations and self.b_terminations:
|
||||
a_type = self.a_terminations[0]._meta.model_name
|
||||
b_type = self.b_terminations[0]._meta.model_name
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
raise ValidationError(
|
||||
_("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
|
||||
)
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
@@ -323,17 +324,24 @@ class CableTermination(ChangeLoggedModel):
|
||||
).first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
_("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
|
||||
app_label=self.termination_type.app_label,
|
||||
model=self.termination_type.model,
|
||||
termination_id=self.termination_id,
|
||||
cable_pk=existing_termination.cable.pk
|
||||
))
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
raise ValidationError(
|
||||
_("Cables cannot be terminated to {type_display} interfaces").format(
|
||||
type_display=self.termination.get_type_display()
|
||||
)
|
||||
)
|
||||
|
||||
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
|
||||
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
|
||||
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
|
||||
raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
|
||||
@@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
super().clean()
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
if not self.device.device_type.is_parent_device:
|
||||
if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
|
||||
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
|
||||
device_type=self.device.device_type
|
||||
))
|
||||
|
||||
# Cannot install a device into itself, obviously
|
||||
if self.device == self.installed_device:
|
||||
if self.installed_device and getattr(self, 'device', None) == self.installed_device:
|
||||
raise ValidationError(_("Cannot install a device into itself."))
|
||||
|
||||
# Check that the installed device is not already installed elsewhere
|
||||
|
||||
@@ -229,15 +229,16 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'slug': self.slug,
|
||||
'description': self.description,
|
||||
'default_platform': self.default_platform.name if self.default_platform else None,
|
||||
'part_number': self.part_number,
|
||||
'u_height': float(self.u_height),
|
||||
'is_full_depth': self.is_full_depth,
|
||||
'subdevice_role': self.subdevice_role,
|
||||
'airflow': self.airflow,
|
||||
'comments': self.comments,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
@@ -415,9 +416,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'part_number': self.part_number,
|
||||
'comments': self.comments,
|
||||
'description': self.description,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
@@ -875,7 +877,7 @@ class Device(
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': _(
|
||||
"A U0 device type ({device_type}) cannot be assigned to a rack position."
|
||||
"A 0U device type ({device_type}) cannot be assigned to a rack position."
|
||||
).format(device_type=self.device_type)
|
||||
})
|
||||
|
||||
@@ -994,17 +996,16 @@ class Device(
|
||||
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
||||
(default). Otherwise, save() will be called on each instance individually.
|
||||
"""
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
|
||||
# Set default values for any applicable custom fields
|
||||
model = queryset.model.component_model
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
if bulk_create:
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
# Set default values for any applicable custom fields
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
model.objects.bulk_create(components)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
for component in components:
|
||||
@@ -1017,7 +1018,11 @@ class Device(
|
||||
update_fields=None
|
||||
)
|
||||
else:
|
||||
for component in components:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
# Set default values for any applicable custom fields
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
component.custom_field_data = cf_defaults
|
||||
component.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -8,17 +8,16 @@ from django.conf import settings
|
||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
from utilities.utils import foreground_color
|
||||
|
||||
|
||||
__all__ = (
|
||||
'CableTraceSVG',
|
||||
)
|
||||
|
||||
|
||||
OFFSET = 0.5
|
||||
PADDING = 10
|
||||
LINE_HEIGHT = 20
|
||||
FANOUT_HEIGHT = 35
|
||||
FANOUT_LEG_HEIGHT = 15
|
||||
CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
|
||||
|
||||
|
||||
class Node(Hyperlink):
|
||||
@@ -84,31 +83,38 @@ class Connector(Group):
|
||||
labels: Iterable of text labels
|
||||
"""
|
||||
|
||||
def __init__(self, start, url, color, labels=[], description=[], **extra):
|
||||
super().__init__(class_='connector', **extra)
|
||||
def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra):
|
||||
super().__init__(class_="connector", **extra)
|
||||
|
||||
self.start = start
|
||||
self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
|
||||
self.end = (start[0], start[1] + self.height)
|
||||
# Allow to specify end-position or auto-calculate
|
||||
self.end = end if end else (start[0], start[1] + self.height)
|
||||
self.color = color or '000000'
|
||||
|
||||
# Draw a "shadow" line to give the cable a border
|
||||
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
|
||||
self.add(cable_shadow)
|
||||
if wireless:
|
||||
# Draw the cable
|
||||
cable = Line(start=self.start, end=self.end, class_="wireless-link")
|
||||
self.add(cable)
|
||||
else:
|
||||
# Draw a "shadow" line to give the cable a border
|
||||
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
|
||||
self.add(cable_shadow)
|
||||
|
||||
# Draw the cable
|
||||
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
|
||||
self.add(cable)
|
||||
# Draw the cable
|
||||
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
|
||||
self.add(cable)
|
||||
|
||||
# Add link
|
||||
link = Hyperlink(href=url, target='_parent')
|
||||
|
||||
# Add text label(s)
|
||||
cursor = start[1]
|
||||
cursor += PADDING * 2
|
||||
cursor = start[1] + text_offset
|
||||
cursor += PADDING * 2 + LINE_HEIGHT * 2
|
||||
x_coord = (start[0] + end[0]) / 2 + PADDING
|
||||
for i, label in enumerate(labels):
|
||||
cursor += LINE_HEIGHT
|
||||
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
|
||||
text_coords = (x_coord, cursor - LINE_HEIGHT / 2)
|
||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||
link.add(text)
|
||||
if len(description) > 0:
|
||||
@@ -190,8 +196,9 @@ class CableTraceSVG:
|
||||
|
||||
def draw_parent_objects(self, obj_list):
|
||||
"""
|
||||
Draw a set of parent objects.
|
||||
Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes
|
||||
"""
|
||||
objects = []
|
||||
width = self.width / len(obj_list)
|
||||
for i, obj in enumerate(obj_list):
|
||||
node = Node(
|
||||
@@ -199,23 +206,26 @@ class CableTraceSVG:
|
||||
width=width,
|
||||
url=f'{self.base_url}{obj.get_absolute_url()}',
|
||||
color=self._get_color(obj),
|
||||
labels=self._get_labels(obj)
|
||||
labels=self._get_labels(obj),
|
||||
object=obj
|
||||
)
|
||||
objects.append(node)
|
||||
self.parent_objects.append(node)
|
||||
if i + 1 == len(obj_list):
|
||||
self.cursor += node.box['height']
|
||||
return objects
|
||||
|
||||
def draw_terminations(self, terminations):
|
||||
def draw_object_terminations(self, terminations, offset_x, width):
|
||||
"""
|
||||
Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
|
||||
Draw all terminations belonging to an object with specified offset and width
|
||||
Return all created nodes and their maximum height
|
||||
"""
|
||||
nodes = []
|
||||
nodes_height = 0
|
||||
width = self.width / len(terminations)
|
||||
|
||||
for i, term in enumerate(terminations):
|
||||
nodes = []
|
||||
# Sort them by name to make renders more readable
|
||||
for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
|
||||
node = Node(
|
||||
position=(i * width, self.cursor),
|
||||
position=(offset_x + i * width, self.cursor),
|
||||
width=width,
|
||||
url=f'{self.base_url}{term.get_absolute_url()}',
|
||||
color=self._get_color(term),
|
||||
@@ -225,133 +235,89 @@ class CableTraceSVG:
|
||||
)
|
||||
nodes_height = max(nodes_height, node.box['height'])
|
||||
nodes.append(node)
|
||||
return nodes, nodes_height
|
||||
|
||||
def draw_terminations(self, terminations, parent_object_nodes):
|
||||
"""
|
||||
Draw a row of terminating objects (e.g. interfaces) and return all created nodes
|
||||
Attach them to previously created parent objects
|
||||
"""
|
||||
nodes = []
|
||||
nodes_height = 0
|
||||
|
||||
# Draw terminations for each parent object
|
||||
for parent in parent_object_nodes:
|
||||
parent_terms = [term for term in terminations if term.parent_object == parent.object]
|
||||
|
||||
# Width and offset(position) for each termination box
|
||||
width = parent.box['width'] / len(parent_terms)
|
||||
offset_x = parent.box['x']
|
||||
|
||||
result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
|
||||
nodes.extend(result)
|
||||
|
||||
self.cursor += nodes_height
|
||||
self.terminations.extend(nodes)
|
||||
|
||||
return nodes
|
||||
|
||||
def draw_fanin(self, node, connector):
|
||||
points = (
|
||||
node.bottom_center,
|
||||
(node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
|
||||
connector.start,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
def draw_fanout(self, node, connector):
|
||||
points = (
|
||||
connector.end,
|
||||
(node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
|
||||
node.top_center,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
def draw_cable(self, cable, terminations, cable_count=0):
|
||||
def draw_far_objects(self, obj_list, terminations):
|
||||
"""
|
||||
Draw a single cable. Terminations and cable count are passed for determining position and padding
|
||||
|
||||
:param cable: The cable to draw
|
||||
:param terminations: List of terminations to build positioning data off of
|
||||
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
|
||||
tooltip.
|
||||
Draw the far-end objects and its terminations and return all created nodes
|
||||
"""
|
||||
# Make sure elements are sorted by name for readability
|
||||
objects = sorted(obj_list, key=lambda x: str(x))
|
||||
width = self.width / len(objects)
|
||||
|
||||
# If the cable count is higher than 2, collapse the description into a tooltip
|
||||
if cable_count > 2:
|
||||
# Use the cable __str__ function to denote the cable
|
||||
labels = [f'{cable}']
|
||||
# Max-height of created terminations
|
||||
terms_height = 0
|
||||
term_nodes = []
|
||||
|
||||
# Include the label and the status description in the tooltip
|
||||
description = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
# Draw the terminations by per object first
|
||||
for i, obj in enumerate(objects):
|
||||
obj_terms = [term for term in terminations if term.parent_object == obj]
|
||||
obj_pos = i * width
|
||||
result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
|
||||
|
||||
if cable.type:
|
||||
# Include the cable type in the tooltip
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
description.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
else:
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
description = []
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
terms_height = max(terms_height, result_nodes_height)
|
||||
term_nodes.extend(result)
|
||||
|
||||
# If there is only one termination, center on that termination
|
||||
# Otherwise average the center across the terminations
|
||||
if len(terminations) == 1:
|
||||
center = terminations[0].bottom_center[0]
|
||||
else:
|
||||
# Get a list of termination centers
|
||||
termination_centers = [term.bottom_center[0] for term in terminations]
|
||||
# Average the centers
|
||||
center = sum(termination_centers) / len(termination_centers)
|
||||
# Update cursor and draw the objects
|
||||
self.cursor += terms_height
|
||||
self.terminations.extend(term_nodes)
|
||||
object_nodes = self.draw_parent_objects(objects)
|
||||
|
||||
# Create the connector
|
||||
connector = Connector(
|
||||
start=(center, self.cursor),
|
||||
color=cable.color or '000000',
|
||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||
labels=labels,
|
||||
description=description
|
||||
)
|
||||
return object_nodes, term_nodes
|
||||
|
||||
# Set the cursor position
|
||||
self.cursor += connector.height
|
||||
|
||||
return connector
|
||||
|
||||
def draw_wirelesslink(self, wirelesslink):
|
||||
def draw_fanin(self, target, terminations, color):
|
||||
"""
|
||||
Draw a line with labels representing a WirelessLink.
|
||||
Draw the fan-in-lines from each of the terminations to the targetpoint
|
||||
"""
|
||||
group = Group(class_='connector')
|
||||
for term in terminations:
|
||||
points = (
|
||||
term.bottom_center,
|
||||
(term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT),
|
||||
target,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{color}'),
|
||||
))
|
||||
|
||||
labels = [
|
||||
f'Wireless link {wirelesslink}',
|
||||
wirelesslink.get_status_display()
|
||||
]
|
||||
if wirelesslink.ssid:
|
||||
labels.append(wirelesslink.ssid)
|
||||
|
||||
# Draw the wireless link
|
||||
start = (OFFSET + self.center, self.cursor)
|
||||
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
|
||||
end = (start[0], start[1] + height)
|
||||
line = Line(start=start, end=end, class_='wireless-link')
|
||||
group.add(line)
|
||||
|
||||
self.cursor += PADDING * 2
|
||||
|
||||
# Add link
|
||||
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent')
|
||||
|
||||
# Add text label(s)
|
||||
for i, label in enumerate(labels):
|
||||
self.cursor += LINE_HEIGHT
|
||||
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
|
||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||
link.add(text)
|
||||
|
||||
group.add(link)
|
||||
self.cursor += PADDING * 2
|
||||
|
||||
return group
|
||||
def draw_fanout(self, start, terminations, color):
|
||||
"""
|
||||
Draw the fan-out-lines from the startpoint to each of the terminations
|
||||
"""
|
||||
for term in terminations:
|
||||
points = (
|
||||
term.top_center,
|
||||
(term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
|
||||
start,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{color}'),
|
||||
))
|
||||
|
||||
def draw_attachment(self):
|
||||
"""
|
||||
@@ -378,86 +344,110 @@ class CableTraceSVG:
|
||||
|
||||
traced_path = self.origin.trace()
|
||||
|
||||
parent_object_nodes = []
|
||||
# Iterate through each (terms, cable, terms) segment in the path
|
||||
for i, segment in enumerate(traced_path):
|
||||
near_ends, links, far_ends = segment
|
||||
|
||||
# Near end parent
|
||||
# This is segment number one.
|
||||
if i == 0:
|
||||
# If this is the first segment, draw the originating termination's parent object
|
||||
self.draw_parent_objects(set(end.parent_object for end in near_ends))
|
||||
parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends))
|
||||
# Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
|
||||
|
||||
# Near end termination(s)
|
||||
terminations = self.draw_terminations(near_ends)
|
||||
near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
|
||||
self.cursor += CABLE_HEIGHT
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links:
|
||||
link_cables = {}
|
||||
fanin = False
|
||||
fanout = False
|
||||
|
||||
# Determine if we have fanins or fanouts
|
||||
if len(near_ends) > len(set(links)):
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
fanin = True
|
||||
if len(far_ends) > len(set(links)):
|
||||
fanout = True
|
||||
cursor = self.cursor
|
||||
for link in links:
|
||||
# Cable
|
||||
if type(link) is Cable and not link_cables.get(link.pk):
|
||||
# Reset cursor
|
||||
self.cursor = cursor
|
||||
# Generate a list of terminations connected to this cable
|
||||
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
|
||||
# Draw the cable
|
||||
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
|
||||
# Add cable to the list of cables
|
||||
link_cables.update({link.pk: cable})
|
||||
# Add cable to drawing
|
||||
self.connectors.append(cable)
|
||||
obj_list = {end.parent_object for end in far_ends}
|
||||
parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)
|
||||
for cable in links:
|
||||
# Fill in labels and description with all available data
|
||||
description = [
|
||||
f"Link {cable}",
|
||||
cable.get_status_display()
|
||||
]
|
||||
near = []
|
||||
far = []
|
||||
color = '000000'
|
||||
if cable.description:
|
||||
description.append(f"{cable.description}")
|
||||
if isinstance(cable, Cable):
|
||||
labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
|
||||
if cable.type:
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
description.append(f"{cable.length} {cable.get_length_unit_display()}")
|
||||
color = cable.color or '000000'
|
||||
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1 and fanin:
|
||||
for term in terminations:
|
||||
if term.object.cable == link:
|
||||
self.draw_fanin(term, cable)
|
||||
# Collect all connected nodes to this cable
|
||||
near = [term for term in near_terminations if term.object in cable.a_terminations]
|
||||
far = [term for term in far_terminations if term.object in cable.b_terminations]
|
||||
if not (near and far):
|
||||
# a and b terminations may be swapped
|
||||
near = [term for term in near_terminations if term.object in cable.b_terminations]
|
||||
far = [term for term in far_terminations if term.object in cable.a_terminations]
|
||||
elif isinstance(cable, WirelessLink):
|
||||
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
|
||||
if cable.ssid:
|
||||
description.append(f"{cable.ssid}")
|
||||
near = [term for term in near_terminations if term.object == cable.interface_a]
|
||||
far = [term for term in far_terminations if term.object == cable.interface_b]
|
||||
if not (near and far):
|
||||
# a and b terminations may be swapped
|
||||
near = [term for term in near_terminations if term.object == cable.interface_b]
|
||||
far = [term for term in far_terminations if term.object == cable.interface_a]
|
||||
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
# Select most-probable start and end position
|
||||
start = near[0].bottom_center
|
||||
end = far[0].top_center
|
||||
text_offset = 0
|
||||
|
||||
# Far end termination(s)
|
||||
if len(far_ends) > 1:
|
||||
if fanout:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
|
||||
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
|
||||
else:
|
||||
self.draw_terminations(far_ends)
|
||||
elif far_ends:
|
||||
self.draw_terminations(far_ends)
|
||||
else:
|
||||
# Link is not connected to anything
|
||||
break
|
||||
if len(near) > 1 and len(far) > 1:
|
||||
start_center = sum([pos.bottom_center[0] for pos in near]) / len(near)
|
||||
end_center = sum([pos.bottom_center[0] for pos in far]) / len(far)
|
||||
center_x = (start_center + end_center) / 2
|
||||
|
||||
# Far end parent
|
||||
parent_objects = set(end.parent_object for end in far_ends)
|
||||
self.draw_parent_objects(parent_objects)
|
||||
start = (center_x, start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
|
||||
end = (center_x, end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
|
||||
text_offset -= (FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
|
||||
self.draw_fanin(start, near, color)
|
||||
self.draw_fanout(end, far, color)
|
||||
elif len(near) > 1:
|
||||
# Handle Fan-In - change start position to be directly below start
|
||||
start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
|
||||
self.draw_fanin(start, near, color)
|
||||
text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
|
||||
elif len(far) > 1:
|
||||
# Handle Fan-Out - change end position to be directly above end
|
||||
end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
|
||||
self.draw_fanout(end, far, color)
|
||||
text_offset -= FANOUT_HEIGHT
|
||||
|
||||
# Create the connector
|
||||
connector = Connector(
|
||||
start=start,
|
||||
end=end,
|
||||
color=color,
|
||||
wireless=isinstance(cable, WirelessLink),
|
||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||
text_offset=text_offset,
|
||||
labels=labels,
|
||||
description=description
|
||||
)
|
||||
self.connectors.append(connector)
|
||||
|
||||
# Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
|
||||
# a CircuitTermination)
|
||||
elif far_ends:
|
||||
|
||||
# Attachment
|
||||
attachment = self.draw_attachment()
|
||||
self.connectors.append(attachment)
|
||||
|
||||
# Object
|
||||
self.draw_parent_objects(far_ends)
|
||||
parent_object_nodes = self.draw_parent_objects(far_ends)
|
||||
|
||||
# Determine drawing size
|
||||
self.drawing = svgwrite.Drawing(
|
||||
|
||||
@@ -51,34 +51,6 @@ def get_cabletermination_row_class(record):
|
||||
return ''
|
||||
|
||||
|
||||
def get_interface_row_class(record):
|
||||
if not record.enabled:
|
||||
return 'danger'
|
||||
elif record.is_virtual:
|
||||
return 'primary'
|
||||
return get_cabletermination_row_class(record)
|
||||
|
||||
|
||||
def get_interface_state_attribute(record):
|
||||
"""
|
||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.enabled:
|
||||
return 'enabled'
|
||||
else:
|
||||
return 'disabled'
|
||||
|
||||
|
||||
def get_interface_connected_attribute(record):
|
||||
"""
|
||||
Get interface disconnected state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.mark_connected or record.cable:
|
||||
return 'connected'
|
||||
else:
|
||||
return 'disconnected'
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
@@ -210,6 +182,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
platform = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Platform')
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
@@ -294,7 +270,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
model = models.Device
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
@@ -359,6 +335,11 @@ class CableTerminationTable(NetBoxTable):
|
||||
verbose_name=_('Mark Connected'),
|
||||
)
|
||||
|
||||
def value_link_peer(self, value):
|
||||
return ', '.join([
|
||||
f"{termination.parent_object} > {termination}" for termination in value
|
||||
])
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = columns.TemplateColumn(
|
||||
@@ -637,7 +618,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
verbose_name=_('VRF'),
|
||||
linkify=True
|
||||
)
|
||||
inventory_items = tables.ManyToManyColumn(
|
||||
inventory_items = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name=_('Inventory Items'),
|
||||
)
|
||||
@@ -697,11 +678,12 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'cable', 'connection',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
'data-connected': get_interface_connected_attribute
|
||||
'data-enabled': lambda record: "enabled" if record.enabled else "disabled",
|
||||
'data-virtual': lambda record: "true" if record.is_virtual else "false",
|
||||
'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
|
||||
'data-cable-status': lambda record: record.cable.status if record.cable else "",
|
||||
'data-type': lambda record: record.type
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ DEVICEBAY_STATUS = """
|
||||
INTERFACE_IPADDRESSES = """
|
||||
<div class="table-badge-group">
|
||||
{% if value.count >= 3 %}
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?interface_id={{ record.pk }}">{{ value.count }}</a>
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }}</a>
|
||||
{% else %}
|
||||
{% for ip in value.all %}
|
||||
{% if ip.status != 'active' %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import *
|
||||
@@ -45,7 +46,7 @@ class Mixins:
|
||||
name='Peer Device'
|
||||
)
|
||||
if self.peer_termination_type is None:
|
||||
raise NotImplementedError("Test case must set peer_termination_type")
|
||||
raise NotImplementedError(_("Test case must set peer_termination_type"))
|
||||
peer_obj = self.peer_termination_type.objects.create(
|
||||
device=peer_device,
|
||||
name='Peer Termination'
|
||||
@@ -1754,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBay
|
||||
brief_fields = ['display', 'id', 'module', 'name', 'url']
|
||||
brief_fields = ['display', 'id', 'installed_module', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@@ -394,6 +394,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 2
|
||||
cable2.delete()
|
||||
path1 = self.assertPathExists(
|
||||
@@ -450,6 +453,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 2
|
||||
cable2.delete()
|
||||
path1 = self.assertPathExists(
|
||||
@@ -558,6 +564,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 4)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 3
|
||||
cable3.delete()
|
||||
|
||||
@@ -673,6 +682,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 4)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 3
|
||||
cable3.delete()
|
||||
|
||||
@@ -804,6 +816,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 4)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 3
|
||||
cable3.delete()
|
||||
|
||||
@@ -931,6 +946,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 4)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 5
|
||||
cable5.delete()
|
||||
|
||||
@@ -1034,6 +1052,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 4)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 3
|
||||
cable3.delete()
|
||||
|
||||
@@ -1093,6 +1114,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
|
||||
@@ -1135,6 +1159,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
def test_210_interface_to_circuittermination(self):
|
||||
"""
|
||||
[IF1] --C1-- [CT1]
|
||||
@@ -1156,6 +1183,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
@@ -1212,6 +1242,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 2
|
||||
cable2.delete()
|
||||
path1 = self.assertPathExists(
|
||||
@@ -1277,6 +1310,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 2
|
||||
cable2.delete()
|
||||
path1 = self.assertPathExists(
|
||||
@@ -1314,6 +1350,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
@@ -1342,6 +1381,9 @@ class CablePathTestCase(TestCase):
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
self.assertTrue(CablePath.objects.first().is_complete)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
@@ -1439,6 +1481,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 4)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cables 3-4
|
||||
cable3.delete()
|
||||
cable4.delete()
|
||||
@@ -1495,6 +1540,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 2
|
||||
cable2.delete()
|
||||
path1 = self.assertPathExists(
|
||||
@@ -1578,6 +1626,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 2
|
||||
cable2.delete()
|
||||
|
||||
@@ -1697,6 +1748,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 4)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
# Delete cable 3
|
||||
cable3.delete()
|
||||
|
||||
@@ -1784,6 +1838,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
@@ -1877,6 +1934,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
def test_221_non_symmetric_paths(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
|
||||
@@ -1997,6 +2057,9 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
def test_301_create_path_via_existing_cable(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
|
||||
@@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
|
||||
Platform(name='Platform 4', slug='platform-4'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
@@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_available_for_device_type(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturers[0],
|
||||
model='Device Type 1',
|
||||
slug='device-type-1',
|
||||
u_height=1
|
||||
)
|
||||
params = {'available_for_device_type': device_type.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Device.objects.all()
|
||||
|
||||
@@ -1079,7 +1079,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
|
||||
tab = ViewTab(
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventory_item_template_count,
|
||||
permission='dcim.view_invenotryitemtemplate',
|
||||
permission='dcim.view_inventoryitemtemplate',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
)
|
||||
@@ -1656,6 +1656,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = InventoryItemTemplate.objects.all()
|
||||
form = forms.InventoryItemTemplateCreateForm
|
||||
model_form = forms.InventoryItemTemplateForm
|
||||
template_name = 'dcim/inventoryitemtemplate_edit.html'
|
||||
|
||||
def alter_object(self, instance, request):
|
||||
# Set component (if any)
|
||||
@@ -1673,6 +1674,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
|
||||
class InventoryItemTemplateEditView(generic.ObjectEditView):
|
||||
queryset = InventoryItemTemplate.objects.all()
|
||||
form = forms.InventoryItemTemplateForm
|
||||
template_name = 'dcim/inventoryitemtemplate_edit.html'
|
||||
|
||||
|
||||
@register_model_view(InventoryItemTemplate, 'delete')
|
||||
@@ -3164,12 +3166,6 @@ class CableListView(generic.ObjectListView):
|
||||
filterset = filtersets.CableFilterSet
|
||||
filterset_form = forms.CableFilterForm
|
||||
table = tables.CableTable
|
||||
actions = {
|
||||
'import': {'add'},
|
||||
'export': {'view'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Cable)
|
||||
@@ -3181,34 +3177,29 @@ class CableView(generic.ObjectView):
|
||||
class CableEditView(generic.ObjectEditView):
|
||||
queryset = Cable.objects.all()
|
||||
template_name = 'dcim/cable_edit.html'
|
||||
htmx_template_name = 'dcim/htmx/cable_edit.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
# If creating a new Cable, initialize the form class using URL query params
|
||||
if 'pk' not in kwargs:
|
||||
self.form = forms.get_cable_form(
|
||||
a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')),
|
||||
b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type'))
|
||||
)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||
"""
|
||||
Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView
|
||||
Hack into alter_object() to set the form class when editing an existing Cable, since ObjectEditView
|
||||
doesn't currently provide a hook for dynamic class resolution.
|
||||
"""
|
||||
obj = super().get_object(**kwargs)
|
||||
a_terminations_type = CABLE_TERMINATION_TYPES.get(
|
||||
request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type')
|
||||
)
|
||||
b_terminations_type = CABLE_TERMINATION_TYPES.get(
|
||||
request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type')
|
||||
)
|
||||
|
||||
if obj.pk:
|
||||
# TODO: Optimize this logic
|
||||
termination_a = obj.terminations.filter(cable_end='A').first()
|
||||
a_type = termination_a.termination._meta.model if termination_a else None
|
||||
termination_b = obj.terminations.filter(cable_end='B').first()
|
||||
b_type = termination_b.termination._meta.model if termination_b else None
|
||||
self.form = forms.get_cable_form(a_type, b_type)
|
||||
if not a_terminations_type and (termination_a := obj.terminations.filter(cable_end='A').first()):
|
||||
a_terminations_type = termination_a.termination._meta.model
|
||||
if not b_terminations_type and (termination_b := obj.terminations.filter(cable_end='B').first()):
|
||||
b_terminations_type = termination_b.termination._meta.model
|
||||
|
||||
return obj
|
||||
self.form = forms.get_cable_form(a_terminations_type, b_terminations_type)
|
||||
|
||||
return super().alter_object(obj, request, url_args, url_kwargs)
|
||||
|
||||
def get_extra_addanother_params(self, request):
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework.fields import Field
|
||||
@@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
|
||||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
@@ -88,8 +89,11 @@ class EventRuleSerializer(NetBoxModelSerializer):
|
||||
# We need to manually instantiate the serializer for scripts
|
||||
if instance.action_type == EventRuleActionChoices.SCRIPT:
|
||||
script_name = instance.action_parameters['script_name']
|
||||
script = instance.action_object.scripts[script_name]()
|
||||
return NestedScriptSerializer(script, context=context).data
|
||||
if script_name in instance.action_object.scripts:
|
||||
script = instance.action_object.scripts[script_name]()
|
||||
return NestedScriptSerializer(script, context=context).data
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
serializer = get_serializer_for_model(
|
||||
model=instance.action_object_type.model_class(),
|
||||
@@ -150,7 +154,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
|
||||
|
||||
return value
|
||||
|
||||
@@ -545,12 +549,12 @@ class ReportInputSerializer(serializers.Serializer):
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
|
||||
return value
|
||||
|
||||
|
||||
@@ -595,12 +599,12 @@ class ScriptInputSerializer(serializers.Serializer):
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import functools
|
||||
import re
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'Condition',
|
||||
@@ -50,11 +51,13 @@ class Condition:
|
||||
|
||||
def __init__(self, attr, value, op=EQ, negate=False):
|
||||
if op not in self.OPERATORS:
|
||||
raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
|
||||
raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
|
||||
op=op, operators=', '.join(self.OPERATORS)
|
||||
))
|
||||
if type(value) not in self.TYPES:
|
||||
raise ValueError(f"Unsupported value type: {type(value)}")
|
||||
raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
|
||||
if op not in self.TYPES[type(value)]:
|
||||
raise ValueError(f"Invalid type for {op} operation: {type(value)}")
|
||||
raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
|
||||
|
||||
self.attr = attr
|
||||
self.value = value
|
||||
@@ -131,14 +134,17 @@ class ConditionSet:
|
||||
"""
|
||||
def __init__(self, ruleset):
|
||||
if type(ruleset) is not dict:
|
||||
raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.")
|
||||
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
||||
if len(ruleset) != 1:
|
||||
raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})")
|
||||
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
|
||||
ruleset=len(ruleset)))
|
||||
|
||||
# Determine the logic type
|
||||
logic = list(ruleset.keys())[0]
|
||||
if type(logic) is not str or logic.lower() not in (AND, OR):
|
||||
raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')")
|
||||
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
|
||||
logic=logic, op_and=AND, op_or=OR
|
||||
))
|
||||
self.logic = logic.lower()
|
||||
|
||||
# Compile the set of Conditions
|
||||
|
||||
@@ -2,6 +2,7 @@ import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from extras.constants import DEFAULT_DASHBOARD
|
||||
@@ -32,7 +33,7 @@ def get_widget_class(name):
|
||||
try:
|
||||
return registry['widgets'][name]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unregistered widget class: {name}")
|
||||
raise ValueError(_("Unregistered widget class: {name}").format(name=name))
|
||||
|
||||
|
||||
def get_dashboard(user):
|
||||
|
||||
@@ -112,7 +112,9 @@ class DashboardWidget:
|
||||
Params:
|
||||
request: The current request
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__} must define a render() method.")
|
||||
raise NotImplementedError(_("{class_name} must define a render() method.").format(
|
||||
class_name=self.__class__
|
||||
))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -178,7 +180,7 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
try:
|
||||
dict(data)
|
||||
except TypeError:
|
||||
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
|
||||
raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
@@ -232,7 +234,7 @@ class ObjectListWidget(DashboardWidget):
|
||||
try:
|
||||
urlencode(data)
|
||||
except (TypeError, ValueError):
|
||||
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
|
||||
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django_rq import get_queue
|
||||
|
||||
from core.models import Job
|
||||
@@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
|
||||
raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
|
||||
action_type=event_rule.action_type
|
||||
))
|
||||
|
||||
|
||||
def process_event_queue(events):
|
||||
@@ -175,4 +178,4 @@ def flush_events(queue):
|
||||
func = import_string(name)
|
||||
func(queue)
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot import events pipeline {name} error: {e}")
|
||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||
|
||||
@@ -116,6 +116,12 @@ class CustomLinkImportForm(CSVModelForm):
|
||||
queryset=ContentType.objects.with_feature('custom_links'),
|
||||
help_text=_("One or more assigned object types")
|
||||
)
|
||||
button_class = CSVChoiceField(
|
||||
label=_('button class'),
|
||||
required=False,
|
||||
choices=CustomLinkButtonClassChoices,
|
||||
help_text=_('The class of the first link in a group will be used for the dropdown button')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomLink
|
||||
@@ -202,7 +208,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
||||
try:
|
||||
webhook = Webhook.objects.get(name=action_object)
|
||||
except Webhook.DoesNotExist:
|
||||
raise forms.ValidationError(f"Webhook {action_object} not found")
|
||||
raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object))
|
||||
self.instance.action_object = webhook
|
||||
# Script
|
||||
elif action_type == EventRuleActionChoices.SCRIPT:
|
||||
@@ -211,7 +217,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
||||
try:
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Script {action_object} not found")
|
||||
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
|
||||
self.instance.action_object = module
|
||||
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
|
||||
self.instance.action_parameters = {
|
||||
|
||||
@@ -265,6 +265,7 @@ class EventRuleForm(NetBoxModelForm):
|
||||
required=False,
|
||||
help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
(_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),
|
||||
|
||||
@@ -7,6 +7,7 @@ from extras.models import ObjectChange
|
||||
__all__ = (
|
||||
'ChangelogMixin',
|
||||
'ConfigContextMixin',
|
||||
'ContactsMixin',
|
||||
'CustomFieldsMixin',
|
||||
'ImageAttachmentsMixin',
|
||||
'JournalEntriesMixin',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from netbox.search.backends import search_backend
|
||||
@@ -62,7 +63,7 @@ class Command(BaseCommand):
|
||||
# Determine which models to reindex
|
||||
indexers = self._get_indexers(*model_labels)
|
||||
if not indexers:
|
||||
raise CommandError("No indexers found!")
|
||||
raise CommandError(_("No indexers found!"))
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
|
||||
@@ -50,6 +50,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'ordering': ('file_root', 'file_path'),
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
@@ -61,6 +62,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'ordering': ('file_root', 'file_path'),
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.9 on 2024-02-20 17:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0106_bookmark_user_cascade_deletion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='cachedvalue',
|
||||
index=models.Index(fields=['object_type', 'object_id'], name='extras_cachedvalue_object'),
|
||||
),
|
||||
]
|
||||
@@ -11,7 +11,7 @@ from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.config import get_config
|
||||
from netbox.registry import registry
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from utilities.jinja2 import ConfigTemplateLoader
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
@@ -26,7 +26,7 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
||||
class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
@@ -210,7 +210,7 @@ class ConfigContextModel(models.Model):
|
||||
# Config templates
|
||||
#
|
||||
|
||||
class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import decimal
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
|
||||
@@ -484,7 +485,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# JSON
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
field = JSONField(required=required, initial=initial)
|
||||
field = JSONField(required=required, initial=json.dumps(initial) if initial else '')
|
||||
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
|
||||
@@ -43,6 +43,7 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
ordering = ('file_root', 'file_path')
|
||||
verbose_name = _('report module')
|
||||
verbose_name_plural = _('report modules')
|
||||
|
||||
@@ -52,7 +53,7 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
def __str__(self):
|
||||
return self.python_name
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def reports(self):
|
||||
|
||||
def _get_name(cls):
|
||||
|
||||
@@ -2,6 +2,7 @@ import inspect
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -41,8 +42,16 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
"""
|
||||
objects = ScriptModuleManager()
|
||||
|
||||
event_rules = GenericRelation(
|
||||
to='extras.EventRule',
|
||||
content_type_field='action_object_type',
|
||||
object_id_field='action_object_id',
|
||||
for_concrete_model=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
ordering = ('file_root', 'file_path')
|
||||
verbose_name = _('script module')
|
||||
verbose_name_plural = _('script modules')
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ class CachedValue(models.Model):
|
||||
ordering = ('weight', 'object_type', 'value', 'object_id')
|
||||
verbose_name = _('cached value')
|
||||
verbose_name_plural = _('cached values')
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||
|
||||
@@ -37,7 +37,7 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
|
||||
to='contenttypes.ContentType',
|
||||
related_name='+',
|
||||
blank=True,
|
||||
help_text=_("The object type(s) to which this this tag can be applied.")
|
||||
help_text=_("The object type(s) to which this tag can be applied.")
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from django.utils.functional import classproperty
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
@@ -23,6 +24,7 @@ from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator,
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import DatePicker, DateTimePicker
|
||||
from .context_managers import event_tracking
|
||||
from .forms import ScriptForm
|
||||
|
||||
@@ -30,6 +32,8 @@ __all__ = (
|
||||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'ChoiceVar',
|
||||
'DateVar',
|
||||
'DateTimeVar',
|
||||
'FileVar',
|
||||
'IntegerVar',
|
||||
'IPAddressVar',
|
||||
@@ -171,6 +175,28 @@ class ChoiceVar(ScriptVariable):
|
||||
self.field_attrs['choices'] = add_blank_choice(choices)
|
||||
|
||||
|
||||
class DateVar(ScriptVariable):
|
||||
"""
|
||||
A date.
|
||||
"""
|
||||
form_field = forms.DateField
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.form_field.widget = DatePicker()
|
||||
|
||||
|
||||
class DateTimeVar(ScriptVariable):
|
||||
"""
|
||||
A date and a time.
|
||||
"""
|
||||
form_field = forms.DateTimeField
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.form_field.widget = DateTimePicker()
|
||||
|
||||
|
||||
class MultiChoiceVar(ScriptVariable):
|
||||
"""
|
||||
Like ChoiceVar, but allows for the selection of multiple choices.
|
||||
@@ -356,7 +382,7 @@ class BaseScript:
|
||||
return ordered_vars
|
||||
|
||||
def run(self, data, commit):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
raise NotImplementedError(_("The script must define a run() method."))
|
||||
|
||||
# Form rendering
|
||||
|
||||
@@ -367,11 +393,11 @@ class BaseScript:
|
||||
fieldsets.extend(self.fieldsets)
|
||||
else:
|
||||
fields = list(name for name, _ in self._get_vars().items())
|
||||
fieldsets.append(('Script Data', fields))
|
||||
fieldsets.append((_('Script Data'), fields))
|
||||
|
||||
# Append the default fieldset if defined in the Meta class
|
||||
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
|
||||
fieldsets.append(('Script Execution Parameters', exec_parameters))
|
||||
fieldsets.append((_('Script Execution Parameters'), exec_parameters))
|
||||
|
||||
return fieldsets
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -12,9 +12,10 @@ from core.signals import job_end, job_start
|
||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from extras.events import process_event_rules
|
||||
from extras.models import EventRule
|
||||
from extras.validators import CustomValidator
|
||||
from extras.validators import run_validators
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .choices import ObjectChangeActionChoices
|
||||
@@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
else:
|
||||
return
|
||||
|
||||
# Create/update an ObejctChange record for this change
|
||||
# Create/update an ObjectChange record for this change
|
||||
objectchange = instance.to_objectchange(action)
|
||||
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
|
||||
# for this object by this request and update it
|
||||
@@ -108,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
# Run any deletion protection rules for the object. Note that this must occur prior
|
||||
# to queueing any events for the object being deleted, in case a validation error is
|
||||
# raised, causing the deletion to fail.
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
raise AbortRequest(
|
||||
_("Deletion is prevented by a protection rule: {message}").format(message=e)
|
||||
)
|
||||
|
||||
# Get the current request, or bail if not set
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
@@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# Django does not automatically send an m2m_changed signal for the reverse direction of a
|
||||
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
|
||||
# trigger one manually. We do this by checking for any reverse M2M relationships on the
|
||||
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
|
||||
# the association. This triggers an m2m_changed signal with the `post_remove` action type
|
||||
# for the forward direction of the relationship, ensuring that the change is recorded.
|
||||
for relation in instance._meta.related_objects:
|
||||
if type(relation) is not ManyToManyRel:
|
||||
continue
|
||||
related_model = relation.related_model
|
||||
related_field_name = relation.remote_field.name
|
||||
if not issubclass(related_model, ChangeLoggingMixin):
|
||||
# We only care about triggering the m2m_changed signal for models which support
|
||||
# change logging
|
||||
continue
|
||||
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
|
||||
obj.snapshot() # Ensure the change record includes the "before" state
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
|
||||
# Enqueue webhooks
|
||||
queue = events_queue.get()
|
||||
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
@@ -186,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
|
||||
# Custom validation
|
||||
#
|
||||
|
||||
def run_validators(instance, validators):
|
||||
|
||||
for validator in validators:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
if type(validator) is str:
|
||||
module, cls = validator.rsplit('.', 1)
|
||||
validator = getattr(importlib.import_module(module), cls)()
|
||||
|
||||
# Constructing a new instance on the fly from a ruleset
|
||||
elif type(validator) is dict:
|
||||
validator = CustomValidator(validator)
|
||||
|
||||
validator(instance)
|
||||
|
||||
|
||||
@receiver(post_clean)
|
||||
def run_save_validators(sender, instance, **kwargs):
|
||||
"""
|
||||
Run any custom validation rules for the model prior to calling save().
|
||||
"""
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
|
||||
|
||||
run_validators(instance, validators)
|
||||
|
||||
|
||||
@receiver(pre_delete)
|
||||
def run_delete_validators(sender, instance, **kwargs):
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
raise AbortRequest(
|
||||
_("Deletion is prevented by a protection rule: {message}").format(
|
||||
message=e
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
@@ -414,15 +414,35 @@ class ConfigTemplateTable(NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:configtemplate_list'
|
||||
)
|
||||
role_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:devicerole_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Device Roles')
|
||||
)
|
||||
platform_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:platform_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Platforms')
|
||||
)
|
||||
device_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Devices')
|
||||
)
|
||||
vm_count = columns.LinkedCountColumn(
|
||||
viewname='virtualization:virtualmachine_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Virtual Machines')
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigTemplate
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
'tags',
|
||||
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
|
||||
'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'description', 'is_synced',
|
||||
'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import tempfile
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
@@ -322,3 +323,47 @@ class ScriptVariablesTest(TestCase):
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
|
||||
|
||||
def test_datevar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = DateVar()
|
||||
var2 = DateVar(required=False)
|
||||
|
||||
# Test date validation
|
||||
data = {'var1': 'not a date'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
input_date = date(2024, 4, 1)
|
||||
data = {'var1': input_date}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], input_date)
|
||||
# Validate required=False works for this Var type
|
||||
self.assertEqual(form.cleaned_data['var2'], None)
|
||||
|
||||
def test_datetimevar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = DateTimeVar()
|
||||
var2 = DateTimeVar(required=False)
|
||||
|
||||
# Test datetime validation
|
||||
data = {'var1': 'not a datetime'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
input_datetime = datetime(2024, 4, 1, 8, 0, 0, 0, timezone.utc)
|
||||
data = {'var1': input_datetime}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], input_datetime)
|
||||
# Validate required=False works for this Var type
|
||||
self.assertEqual(form.cleaned_data['var2'], None)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import importlib
|
||||
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -149,3 +151,21 @@ class CustomValidator:
|
||||
if field is not None:
|
||||
raise ValidationError({field: message})
|
||||
raise ValidationError(message)
|
||||
|
||||
|
||||
def run_validators(instance, validators):
|
||||
"""
|
||||
Run the provided iterable of validators for the instance.
|
||||
"""
|
||||
for validator in validators:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
if type(validator) is str:
|
||||
module, cls = validator.rsplit('.', 1)
|
||||
validator = getattr(importlib.import_module(module), cls)()
|
||||
|
||||
# Constructing a new instance on the fly from a ruleset
|
||||
elif type(validator) is dict:
|
||||
validator = CustomValidator(validator)
|
||||
|
||||
validator(instance)
|
||||
|
||||
@@ -13,6 +13,7 @@ from core.choices import JobStatusChoices, ManagedFileRootPathChoices
|
||||
from core.forms import ManagedFileForm
|
||||
from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from dcim.models import Device, DeviceRole, Platform
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
@@ -24,6 +25,7 @@ from utilities.rqworker import get_workers_for_queue
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filtersets, forms, tables
|
||||
from .forms.reports import ReportForm
|
||||
from .models import *
|
||||
@@ -624,7 +626,12 @@ class ObjectConfigContextView(generic.ObjectView):
|
||||
#
|
||||
|
||||
class ConfigTemplateListView(generic.ObjectListView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
queryset = ConfigTemplate.objects.annotate(
|
||||
device_count=count_related(Device, 'config_template'),
|
||||
vm_count=count_related(VirtualMachine, 'config_template'),
|
||||
role_count=count_related(DeviceRole, 'config_template'),
|
||||
platform_count=count_related(Platform, 'config_template'),
|
||||
)
|
||||
filterset = filtersets.ConfigTemplateFilterSet
|
||||
filterset_form = forms.ConfigTemplateFilterForm
|
||||
table = tables.ConfigTemplateTable
|
||||
@@ -1035,7 +1042,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request):
|
||||
report_modules = ReportModule.objects.restrict(request.user)
|
||||
report_modules = ReportModule.objects.restrict(request.user).prefetch_related('data_source', 'data_file')
|
||||
|
||||
return render(request, 'extras/report_list.html', {
|
||||
'model': ReportModule,
|
||||
@@ -1210,7 +1217,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
script_modules = ScriptModule.objects.restrict(request.user)
|
||||
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('data_source', 'data_file')
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'model': ScriptModule,
|
||||
|
||||
@@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
|
||||
group = NestedFHRPGroupSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.FHRPGroupAssignment
|
||||
fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority']
|
||||
fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -262,7 +262,7 @@ class AvailableVLANSerializer(serializers.Serializer):
|
||||
Representation of a VLAN which does not exist in the database.
|
||||
"""
|
||||
vid = serializers.IntegerField(read_only=True)
|
||||
group = NestedVLANGroupSerializer(read_only=True)
|
||||
group = NestedVLANGroupSerializer(read_only=True, allow_null=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {
|
||||
@@ -348,9 +348,9 @@ class AvailablePrefixSerializer(serializers.Serializer):
|
||||
"""
|
||||
Representation of a prefix which does not exist in the database.
|
||||
"""
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
family = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
prefix = serializers.CharField(read_only=True)
|
||||
vrf = NestedVRFSerializer(read_only=True)
|
||||
vrf = NestedVRFSerializer(read_only=True, allow_null=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
if self.context.get('vrf'):
|
||||
@@ -429,9 +429,9 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
"""
|
||||
Representation of an IP address which does not exist in the database.
|
||||
"""
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
family = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
address = serializers.CharField(read_only=True)
|
||||
vrf = NestedVRFSerializer(read_only=True)
|
||||
vrf = NestedVRFSerializer(read_only=True, allow_null=True)
|
||||
description = serializers.CharField(required=False)
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
||||
@@ -3,6 +3,7 @@ from copy import deepcopy
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from netaddr import IPSet
|
||||
@@ -118,7 +119,7 @@ class IPRangeViewSet(NetBoxModelViewSet):
|
||||
|
||||
class IPAddressViewSet(NetBoxModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object', 'assigned_object_type'
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filtersets.IPAddressFilterSet
|
||||
@@ -379,7 +380,7 @@ class AvailablePrefixesView(AvailableObjectsView):
|
||||
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||
})
|
||||
else:
|
||||
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
|
||||
raise ValidationError(_("Insufficient space is available to accommodate the requested prefix size(s)"))
|
||||
|
||||
return requested_objects
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, IPNetwork
|
||||
|
||||
from . import lookups, validators
|
||||
@@ -32,7 +33,7 @@ class BaseIPField(models.Field):
|
||||
# Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
|
||||
return IPNetwork(value)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Invalid IP address format: {}".format(value))
|
||||
raise ValidationError(_("Invalid IP address format: {address}").format(address=value))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_ipv4_address, validate_ipv6_address
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netaddr import IPAddress, IPNetwork, AddrFormatError
|
||||
|
||||
|
||||
@@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
|
||||
|
||||
class IPAddressFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
|
||||
'invalid': _("Enter a valid IPv4 or IPv6 address (without a mask)."),
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
@@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
|
||||
try:
|
||||
validate_ipv6_address(value)
|
||||
except ValidationError:
|
||||
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
|
||||
raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
|
||||
|
||||
try:
|
||||
return IPAddress(value)
|
||||
except ValueError:
|
||||
raise ValidationError('This field requires an IP address without a mask.')
|
||||
raise ValidationError(_('This field requires an IP address without a mask.'))
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
||||
|
||||
class IPNetworkFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
|
||||
'invalid': _("Enter a valid IPv4 or IPv6 address (with CIDR mask)."),
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
@@ -52,9 +53,9 @@ class IPNetworkFormField(forms.Field):
|
||||
|
||||
# Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
|
||||
if len(value.split('/')) != 2:
|
||||
raise ValidationError('CIDR mask (e.g. /24) is required.')
|
||||
raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
|
||||
|
||||
try:
|
||||
return IPNetwork(value)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
||||
@@ -378,7 +378,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
|
||||
# Set as primary for device/VM
|
||||
if self.cleaned_data.get('is_primary'):
|
||||
parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
|
||||
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
|
||||
if self.instance.address.version == 4:
|
||||
parent.primary_ip4 = ipaddress
|
||||
elif self.instance.address.version == 6:
|
||||
|
||||
@@ -367,20 +367,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
|
||||
)
|
||||
|
||||
# Do not allow assigning a network ID or broadcast address to an interface.
|
||||
if interface and (address := self.cleaned_data.get('address')):
|
||||
if address.ip == address.network:
|
||||
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip)
|
||||
if address.version == 4 and address.prefixlen not in (31, 32):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 6 and address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
|
||||
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
|
||||
ip=address.ip
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
@@ -521,6 +507,24 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
|
||||
for ipaddress in ipaddresses:
|
||||
self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk)
|
||||
|
||||
def clean_group(self):
|
||||
group = self.cleaned_data['group']
|
||||
|
||||
conflicting_assignments = FHRPGroupAssignment.objects.filter(
|
||||
interface_type=self.instance.interface_type,
|
||||
interface_id=self.instance.interface_id,
|
||||
group=group
|
||||
)
|
||||
if self.instance.id:
|
||||
conflicting_assignments = conflicting_assignments.exclude(id=self.instance.id)
|
||||
|
||||
if conflicting_assignments.exists():
|
||||
raise forms.ValidationError(
|
||||
_('Assignment already exists')
|
||||
)
|
||||
|
||||
return group
|
||||
|
||||
|
||||
class VLANGroupForm(NetBoxModelForm):
|
||||
scope_type = ContentTypeChoiceField(
|
||||
@@ -751,4 +755,4 @@ class ServiceCreateForm(ServiceForm):
|
||||
if not self.cleaned_data['description']:
|
||||
self.cleaned_data['description'] = service_template.description
|
||||
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
|
||||
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
|
||||
raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import graphene
|
||||
|
||||
from ipam import filtersets, models
|
||||
from .mixins import IPAddressesMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
|
||||
@@ -71,7 +72,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
filterset_class = filtersets.AggregateFilterSet
|
||||
|
||||
|
||||
class FHRPGroupType(NetBoxObjectType):
|
||||
class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.FHRPGroup
|
||||
|
||||
@@ -692,7 +692,7 @@ class IPRange(PrimaryModel):
|
||||
ip.address.ip for ip in self.get_child_ips()
|
||||
]).size
|
||||
|
||||
return int(float(child_count) / self.size * 100)
|
||||
return min(float(child_count) / self.size * 100, 100)
|
||||
|
||||
|
||||
class IPAddress(PrimaryModel):
|
||||
@@ -844,6 +844,25 @@ class IPAddress(PrimaryModel):
|
||||
'address': _("Cannot create IP address with /0 mask.")
|
||||
})
|
||||
|
||||
# Do not allow assigning a network ID or broadcast address to an interface.
|
||||
if self.assigned_object:
|
||||
if self.address.ip == self.address.network:
|
||||
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(
|
||||
ip=self.address.ip
|
||||
)
|
||||
if self.address.version == 4 and self.address.prefixlen not in (31, 32):
|
||||
raise ValidationError(msg)
|
||||
if self.address.version == 6 and self.address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if (
|
||||
self.address.version == 4 and self.address.ip == self.address.broadcast and
|
||||
self.address.prefixlen not in (31, 32)
|
||||
):
|
||||
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
|
||||
ip=self.address.ip
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
# Enforce unique IP space (if applicable)
|
||||
if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
|
||||
duplicate_ips = self.get_duplicates()
|
||||
|
||||
@@ -378,7 +378,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('NAT (Inside)')
|
||||
)
|
||||
nat_outside = tables.ManyToManyColumn(
|
||||
nat_outside = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
orderable=False,
|
||||
verbose_name=_('NAT (Outside)')
|
||||
|
||||
@@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FHRPGroupAssignment
|
||||
brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url']
|
||||
brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
|
||||
bulk_update_data = {
|
||||
'priority': 100,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator, RegexValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def prefix_validator(prefix):
|
||||
if prefix.ip != prefix.cidr.ip:
|
||||
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
|
||||
raise ValidationError(
|
||||
_("{prefix} is not a valid prefix. Did you mean {suggested}?").format(
|
||||
prefix=prefix, suggested=prefix.cidr
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MaxPrefixLengthValidator(BaseValidator):
|
||||
message = 'The prefix length must be less than or equal to %(limit_value)s.'
|
||||
message = _('The prefix length must be less than or equal to %(limit_value)s.')
|
||||
code = 'max_prefix_length'
|
||||
|
||||
def compare(self, a, b):
|
||||
@@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(BaseValidator):
|
||||
|
||||
|
||||
class MinPrefixLengthValidator(BaseValidator):
|
||||
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
|
||||
message = _('The prefix length must be greater than or equal to %(limit_value)s.')
|
||||
code = 'min_prefix_length'
|
||||
|
||||
def compare(self, a, b):
|
||||
@@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
|
||||
|
||||
DNSValidator = RegexValidator(
|
||||
regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
|
||||
message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names',
|
||||
message=_('Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names'),
|
||||
code='invalid'
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from netaddr import IPNetwork
|
||||
@@ -58,11 +59,11 @@ class ChoiceField(serializers.Field):
|
||||
if data == '':
|
||||
if self.allow_blank:
|
||||
return data
|
||||
raise ValidationError("This field may not be blank.")
|
||||
raise ValidationError(_("This field may not be blank."))
|
||||
|
||||
# Provide an explicit error message if the request is trying to write a dict or list
|
||||
if isinstance(data, (dict, list)):
|
||||
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
|
||||
raise ValidationError(_('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.'))
|
||||
|
||||
# Check for string representations of boolean/integer values
|
||||
if hasattr(data, 'lower'):
|
||||
@@ -82,7 +83,7 @@ class ChoiceField(serializers.Field):
|
||||
except TypeError: # Input is an unhashable type
|
||||
pass
|
||||
|
||||
raise ValidationError(f"{data} is not a valid choice.")
|
||||
raise ValidationError(_("{value} is not a valid choice.").format(value=data))
|
||||
|
||||
@property
|
||||
def choices(self):
|
||||
@@ -95,8 +96,8 @@ class ContentTypeField(RelatedField):
|
||||
Represent a ContentType as '<app_label>.<model>'
|
||||
"""
|
||||
default_error_messages = {
|
||||
"does_not_exist": "Invalid content type: {content_type}",
|
||||
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
|
||||
"does_not_exist": _("Invalid content type: {content_type}"),
|
||||
"invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user