Compare commits
186 Commits
v4.0-beta1
...
v4.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f156de33 | ||
|
|
fce54f3733 | ||
|
|
f12b2fad1f | ||
|
|
0f7e207674 | ||
|
|
840dcaa509 | ||
|
|
32b3961802 | ||
|
|
e6a7971110 | ||
|
|
51bd98bdfc | ||
|
|
f00eceb1eb | ||
|
|
be903a64a2 | ||
|
|
0d7bac433e | ||
|
|
b1cfbbc472 | ||
|
|
6dd311f600 | ||
|
|
85d250014f | ||
|
|
552c81509a | ||
|
|
ed7a0a32cc | ||
|
|
a544b55e9e | ||
|
|
53e1ab5fc5 | ||
|
|
2c1a9ae455 | ||
|
|
1afa476a19 | ||
|
|
c02bd0ab19 | ||
|
|
c7d53ed8eb | ||
|
|
15cc50fc1d | ||
|
|
60aee6f5e1 | ||
|
|
56e0449ebc | ||
|
|
4cc5079ecb | ||
|
|
c6f833e83b | ||
|
|
b91741dd75 | ||
|
|
8e1c2ecd92 | ||
|
|
88f2735087 | ||
|
|
a9b311b100 | ||
|
|
41504425ac | ||
|
|
f8cf2a3786 | ||
|
|
408e0c5a9b | ||
|
|
c8a9bc006d | ||
|
|
d824e90e0a | ||
|
|
f8eee45ba3 | ||
|
|
3d4bb209ee | ||
|
|
8f2eba24bb | ||
|
|
8f92b8519c | ||
|
|
0bc2bffb81 | ||
|
|
3d3c2e9e1f | ||
|
|
17e6d1076a | ||
|
|
4c93a2d084 | ||
|
|
6530051958 | ||
|
|
44a7cd9876 | ||
|
|
312291b010 | ||
|
|
39a830798e | ||
|
|
2c06616a1d | ||
|
|
335a8d6449 | ||
|
|
340f9f4fa8 | ||
|
|
c08784da46 | ||
|
|
a2efec09be | ||
|
|
1add918d31 | ||
|
|
778b8b9b48 | ||
|
|
d0e0dcb652 | ||
|
|
209f596397 | ||
|
|
0a7d1e29b4 | ||
|
|
d256c04d9c | ||
|
|
365bb4ba17 | ||
|
|
11816b45e7 | ||
|
|
693c6e4da5 | ||
|
|
c73a974fa9 | ||
|
|
4b21cf604b | ||
|
|
79b9dc2013 | ||
|
|
0e3c35ae58 | ||
|
|
cbfed83f60 | ||
|
|
3cbade536e | ||
|
|
9691bb29b6 | ||
|
|
851b4cc4d3 | ||
|
|
835012f2ed | ||
|
|
5af3c659a5 | ||
|
|
4923025fec | ||
|
|
ded2fe9471 | ||
|
|
e05ca710ae | ||
|
|
85db007ff5 | ||
|
|
cad3e34d8f | ||
|
|
7b1b91b8ee | ||
|
|
6f36b8513c | ||
|
|
07e2cf0ad2 | ||
|
|
d606cf1b3c | ||
|
|
c32dff5649 | ||
|
|
8364e632b7 | ||
|
|
c43b929542 | ||
|
|
e3c418263e | ||
|
|
46bd62fdc9 | ||
|
|
0b0dab42eb | ||
|
|
d115601da3 | ||
|
|
a61e20849b | ||
|
|
1eca1c3d17 | ||
|
|
5d95d49268 | ||
|
|
6b8bfe9947 | ||
|
|
e87877b6ea | ||
|
|
ebe504c825 | ||
|
|
b6e38b2ebe | ||
|
|
90d0104359 | ||
|
|
781d932b2a | ||
|
|
781409b5ae | ||
|
|
88facbafbb | ||
|
|
c9de3128ca | ||
|
|
94c31622ac | ||
|
|
3d3c1c315b | ||
|
|
f42d0336c2 | ||
|
|
db87fe96b7 | ||
|
|
0f0ab1a3be | ||
|
|
824d66a54c | ||
|
|
3551f3e021 | ||
|
|
1a1300716c | ||
|
|
4b83b5d0e1 | ||
|
|
174865b9aa | ||
|
|
c9bd59ab02 | ||
|
|
1efd80954e | ||
|
|
f4c8f5f5b6 | ||
|
|
480b36d65e | ||
|
|
d0f0782bc0 | ||
|
|
19fe5ef25c | ||
|
|
e303ccfd12 | ||
|
|
928014c766 | ||
|
|
75d6bfe42f | ||
|
|
b5bb732031 | ||
|
|
95cc29d898 | ||
|
|
157df069e8 | ||
|
|
77a4300888 | ||
|
|
b8cedfcc08 | ||
|
|
c5ae89ad03 | ||
|
|
f0aca5bac1 | ||
|
|
c858aa33cc | ||
|
|
4284028bb0 | ||
|
|
21db54ae2f | ||
|
|
2a8876846f | ||
|
|
4562e347fd | ||
|
|
4e4c277711 | ||
|
|
0da8164600 | ||
|
|
5e05041b8b | ||
|
|
815cab5c9a | ||
|
|
3c3943c809 | ||
|
|
17e8773c8c | ||
|
|
f47b158863 | ||
|
|
f7e4fe2a9c | ||
|
|
5098422f68 | ||
|
|
d7922a68d8 | ||
|
|
54c6d95fbb | ||
|
|
379fe7c160 | ||
|
|
89dd423080 | ||
|
|
dfae19ca1c | ||
|
|
89453a49d6 | ||
|
|
c7f6c206cf | ||
|
|
9f16f1466a | ||
|
|
d34f188d40 | ||
|
|
ccca0580f7 | ||
|
|
99fe63569d | ||
|
|
25c39ce480 | ||
|
|
b7668fbfc3 | ||
|
|
1c76034069 | ||
|
|
ad0e476788 | ||
|
|
e10f5ec3b4 | ||
|
|
48a3f3cb70 | ||
|
|
238fa704b9 | ||
|
|
3b3511c43c | ||
|
|
da13fa5569 | ||
|
|
5b50920c61 | ||
|
|
d9a7b4ee0e | ||
|
|
282dc7a705 | ||
|
|
e1753c0f9b | ||
|
|
0e94f2e05d | ||
|
|
1c370f45d0 | ||
|
|
0abd0948b6 | ||
|
|
24e2fc253a | ||
|
|
fca23c6419 | ||
|
|
e4984d2883 | ||
|
|
6030c521f4 | ||
|
|
83dad6f771 | ||
|
|
933fdf3ce9 | ||
|
|
8fadd6b744 | ||
|
|
c93413dc9c | ||
|
|
bf362f4679 | ||
|
|
da7f67c359 | ||
|
|
2c93dd03e1 | ||
|
|
ced44832f7 | ||
|
|
6af3aad362 | ||
|
|
c728d3c2e8 | ||
|
|
83e2c45e74 | ||
|
|
27864ec865 | ||
|
|
d44f67aea5 | ||
|
|
41e1f24cf7 | ||
|
|
d76ede17d3 |
4
.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", "needs triage"]
|
||||
labels: ["type: bug", "status: needs triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -26,7 +26,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.4
|
||||
placeholder: v4.0.0
|
||||
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", "needs triage"]
|
||||
labels: ["type: documentation", "status: needs triage"]
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
|
||||
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", "needs triage"]
|
||||
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.4
|
||||
placeholder: v4.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
5
.github/workflows/auto-assign-issue.yml
vendored
@@ -13,8 +13,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: pozil/auto-assign-issue@v1
|
||||
if: "contains(github.event.issue.labels.*.name, 'type: bug') || contains(github.event.issue.labels.*.name, 'type: feature')"
|
||||
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
|
||||
with:
|
||||
assignees: abhi1693,arthanson,DanSheps,jeffgdotorg,jeremystretch
|
||||
# Weighted assignments
|
||||
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
|
||||
numOfAssignee: 1
|
||||
abortIfPreviousAssignees: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
version: 2
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.9"
|
||||
python: "3.12"
|
||||
mkdocs:
|
||||
configuration: mkdocs.yml
|
||||
python:
|
||||
|
||||
@@ -132,8 +132,7 @@ strawberry-graphql
|
||||
|
||||
# Strawberry GraphQL Django extension
|
||||
# https://github.com/strawberry-graphql/strawberry-django/blob/main/CHANGELOG.md
|
||||
# Pinned per #15574
|
||||
strawberry-graphql-django==0.34.0
|
||||
strawberry-graphql-django
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Date & Time Parameters
|
||||
|
||||
## TIME_ZONE
|
||||
|
||||
Default: UTC
|
||||
|
||||
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
|
||||
|
||||
## Date and Time Formatting
|
||||
|
||||
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below.
|
||||
|
||||
!!! note
|
||||
These system defaults will be overridden by a user's selected language/locale when [localization](./system.md#enable_localization) is enabled.
|
||||
|
||||
```python
|
||||
DATE_FORMAT = 'N j, Y' # June 26, 2016
|
||||
SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26
|
||||
TIME_FORMAT = 'g:i a' # 1:23 p.m.
|
||||
SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00
|
||||
DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m.
|
||||
SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-26 13:23
|
||||
```
|
||||
@@ -33,9 +33,6 @@ This defines custom content to be displayed on the login page above the login fo
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
|
||||
|
||||
---
|
||||
@@ -115,9 +112,6 @@ Default: True
|
||||
|
||||
By default, NetBox will prevent the creation of duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This validation can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to False.
|
||||
|
||||
!!! info "Changed in v3.7"
|
||||
The default value for this parameter was changed from False to True in NetBox v3.7.
|
||||
|
||||
---
|
||||
|
||||
## FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||
@@ -142,9 +136,6 @@ Setting this to False will disable the GraphQL API.
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
!!! note
|
||||
This parameter was renamed from `JOBRESULT_RETENTION` in NetBox v3.5.
|
||||
|
||||
Default: 90
|
||||
|
||||
The number of days to retain job results (scripts and reports). Set this to `0` to retain job results in the database indefinitely.
|
||||
@@ -239,9 +230,6 @@ The maximum execution time of a background task (such as running a custom script
|
||||
|
||||
## RQ_RETRY_INTERVAL
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
Default: `60`
|
||||
|
||||
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
|
||||
@@ -250,9 +238,6 @@ This parameter controls how frequently a failed job is retried, up to the maximu
|
||||
|
||||
## RQ_RETRY_MAX
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
Default: `0` (retries disabled)
|
||||
|
||||
The maximum number of times a background task will be retried before being marked as failed.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -181,6 +181,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.)
|
||||
|
||||
---
|
||||
|
||||
@@ -65,14 +62,6 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
||||
|
||||
---
|
||||
|
||||
## ENABLE_LOCALIZATION
|
||||
|
||||
Default: False
|
||||
|
||||
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding any configured [system defaults](./date-time.md#date-and-time-formatting)) based on the browser locale as well as translate certain strings from third party modules.
|
||||
|
||||
---
|
||||
|
||||
## HTTP_PROXIES
|
||||
|
||||
Default: None
|
||||
@@ -203,3 +192,9 @@ A dictionary of configuration parameters for the storage backend configured as `
|
||||
If `STORAGE_BACKEND` is not defined, this setting will be ignored.
|
||||
|
||||
---
|
||||
|
||||
## TIME_ZONE
|
||||
|
||||
Default: UTC
|
||||
|
||||
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
|
||||
|
||||
@@ -42,8 +42,6 @@ This parameter has no effect on the API representation of custom field data.
|
||||
|
||||
### Visibility & Editing
|
||||
|
||||
!!! info "This feature was improved in NetBox v3.7."
|
||||
|
||||
When creating a custom field, users can control the conditions under which it may be displayed and edited within the NetBox user interface. The following choices are available for controlling the display of a custom field on an object:
|
||||
|
||||
* **Always** (default): The custom field is included when viewing an object.
|
||||
|
||||
@@ -371,6 +371,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
|
||||
|
||||
@@ -59,7 +59,7 @@ Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker)
|
||||
* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.)
|
||||
* Any changes to the reference installation
|
||||
|
||||
### Update Requirements
|
||||
### Update Python Dependencies
|
||||
|
||||
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
|
||||
|
||||
@@ -70,6 +70,10 @@ Before each release, update each of NetBox's Python dependencies to its most rec
|
||||
|
||||
In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
|
||||
|
||||
### Update UI Dependencies
|
||||
|
||||
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](http://0.0.0.0:9000/development/web-ui/#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
|
||||
|
||||
### Rebuild the Device Type Definition Schema
|
||||
|
||||
Run the following command to update the device type definition validation schema:
|
||||
|
||||
@@ -11,4 +11,3 @@ The `users.UserConfig` model holds individual preferences for each user in the f
|
||||
| pagination.placement | Where to display the paginator controls relative to the table |
|
||||
| tables.${table}.columns | The ordered list of columns to display when viewing the table |
|
||||
| tables.${table}.ordering | A list of column names by which the table should be ordered |
|
||||
| ui.colormode | Light or dark mode in the user interface |
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
# Web UI Development
|
||||
|
||||
## Code Structure
|
||||
|
||||
Most static resources for the NetBox UI are housed within the `netbox/project-static/` directory.
|
||||
|
||||
| Path | Description |
|
||||
|-----------|----------------------------------------------------|
|
||||
| `dist/` | Destination path for installed dependencies |
|
||||
| `docs/` | Local build path for documentation |
|
||||
| `img/` | Image files |
|
||||
| `js/` | Miscellaneous JavaScript resources served directly |
|
||||
| `src/` | TypeScript resources (to be compiled into JS) |
|
||||
| `styles/` | Sass resources (to be compiled into CSS) |
|
||||
|
||||
## Front End Technologies
|
||||
|
||||
The NetBox UI is built on languages and frameworks:
|
||||
Front end scripting is written in [TypeScript](https://www.typescriptlang.org/), which is a strongly-typed extension to JavaScript. TypeScript is "transpiled" into JavaScript resources which are served to and executed by the client web browser.
|
||||
|
||||
### Styling & HTML Elements
|
||||
All UI styling is written in [Sass](https://sass-lang.com/), which is an extension to browser-native [Cascading Stylesheets (CSS)](https://developer.mozilla.org/en-US/docs/Web/CSS). Similar to how TypeScript content is transpiled to JavaScript, Sass resources (`.scss` files) are compiled to CSS.
|
||||
|
||||
#### [Bootstrap](https://getbootstrap.com/) 5
|
||||
## Dependencies
|
||||
|
||||
The majority of the NetBox UI is made up of stock Bootstrap components, with some styling modifications and custom components added on an as-needed basis. Bootstrap uses [Sass](https://sass-lang.com/), and NetBox extends Bootstrap's core Sass files for theming and customization.
|
||||
The following software is employed by the NetBox user interface.
|
||||
|
||||
### Client-side Scripting
|
||||
|
||||
#### [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
All client-side scripting is transpiled from TypeScript to JavaScript and served by Django. In development, TypeScript is an _extremely_ effective tool for accurately describing and checking the code, which leads to significantly fewer bugs, a better development experience, and more predictable/readable code.
|
||||
|
||||
As part of the [bundling](#bundling) process, Bootstrap's JavaScript plugins are imported and bundled alongside NetBox's front-end code.
|
||||
|
||||
!!! danger "NetBox is jQuery-free"
|
||||
Following the Bootstrap team's deprecation of jQuery in Bootstrap 5, NetBox also no longer uses jQuery in front-end code.
|
||||
* [Bootstrap 5](https://getbootstrap.com/) - A popular CSS & JS framework
|
||||
* [clipboard.js](https://clipboardjs.com/) - A lightweight package for enabling copy-to-clipboard functionality
|
||||
* [flatpickr](https://flatpickr.js.org/) - A lightweight date & time selection widget
|
||||
* [gridstack.js](https://gridstackjs.com/) - Enables interactive grid layouts (for the dashboard)
|
||||
* [HTMX](https://htmx.org/) - Enables dynamic web interfaces through the use of HTML element attributes
|
||||
* [Material Design Icons](https://pictogrammers.com/library/mdi/) - An extensive open source collection of graphical icons, delivered as a web font
|
||||
* [query-string](https://www.npmjs.com/package/query-string) - Assists with parsing URL query strings
|
||||
* [Tabler](https://tabler.io/) - A web application UI toolkit & theme based on Bootstrap 5
|
||||
* [Tom Select](https://tom-select.js.org/) - Provides dynamic selection form fields
|
||||
|
||||
## Guidance
|
||||
|
||||
@@ -54,6 +66,41 @@ $ yarn
|
||||
!!! warning "Check Your Working Directory"
|
||||
You need to be in the `netbox/project-static` directory to run the below `yarn` commands.
|
||||
|
||||
### Updating Dependencies
|
||||
|
||||
Run `yarn outdated` to identify outdated dependencies.
|
||||
|
||||
```
|
||||
$ yarn outdated
|
||||
yarn outdated v1.22.19
|
||||
info Color legend :
|
||||
"<red>" : Major Update backward-incompatible updates
|
||||
"<yellow>" : Minor Update backward-compatible features
|
||||
"<green>" : Patch Update backward-compatible bug fixes
|
||||
Package Current Wanted Latest Workspace Package Type URL
|
||||
bootstrap 5.3.1 5.3.1 5.3.3 netbox dependencies https://getbootstrap.com/
|
||||
```
|
||||
|
||||
Run `yarn upgrade --latest` to automatically upgrade these packages to their most recent versions.
|
||||
|
||||
```
|
||||
$ yarn upgrade bootstrap --latest
|
||||
yarn upgrade v1.22.19
|
||||
[1/4] Resolving packages...
|
||||
[2/4] Fetching packages...
|
||||
[3/4] Linking dependencies...
|
||||
[4/4] Rebuilding all packages...
|
||||
success Saved lockfile.
|
||||
success Saved 1 new dependency.
|
||||
info Direct dependencies
|
||||
└─ bootstrap@5.3.3
|
||||
info All dependencies
|
||||
└─ bootstrap@5.3.3
|
||||
Done in 0.95s.
|
||||
```
|
||||
|
||||
`package.json` will be updated to reflect the new package versions automatically.
|
||||
|
||||
### Bundling
|
||||
|
||||
In order for the TypeScript and Sass (SCSS) source files to be usable by a browser, they must first be transpiled (TypeScript → JavaScript, Sass → CSS), bundled, and minified. After making changes to TypeScript or Sass source files, run `yarn bundle`.
|
||||
|
||||
@@ -12,7 +12,7 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
1. [Redis](2-redis.md)
|
||||
3. [NetBox components](3-netbox.md)
|
||||
4. [Gunicorn](4-gunicorn.md)
|
||||
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
|
||||
5. [HTTP server](5-http-server.md)
|
||||
6. [LDAP authentication](6-ldap.md) (optional)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 341 KiB |
@@ -8,7 +8,6 @@ A plugin can extend NetBox's GraphQL API by registering its own schema class. By
|
||||
|
||||
```python
|
||||
# graphql.py
|
||||
from typing import List
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
|
||||
@@ -28,7 +27,7 @@ class MyQuery:
|
||||
@strawberry.field
|
||||
def dummymodel(self, id: int) -> DummyModelType:
|
||||
return None
|
||||
dummymodel_list: List[DummyModelType] = strawberry_django.field()
|
||||
dummymodel_list: list[DummyModelType] = strawberry_django.field()
|
||||
|
||||
|
||||
schema = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -85,7 +85,7 @@ from django import forms
|
||||
class MyForm(forms.Form):
|
||||
```
|
||||
|
||||
### Update Fieldset Definitions
|
||||
### Update Fieldset definitions
|
||||
|
||||
NetBox v4.0 introduces [several new classes](./forms.md#form-rendering) for advanced form rendering, including FieldSet. Fieldset definitions on forms should use this new class instead of a tuple or list.
|
||||
|
||||
@@ -252,7 +252,7 @@ class SiteSerializer(NetBoxModelSerializer):
|
||||
|
||||
### Include description fields in brief mode
|
||||
|
||||
NetBox now includes the `description` the field in "brief" mode for all models which have one. This is not required for plugins, but you may opt to do the same for consistency.
|
||||
NetBox now includes the `description` field in "brief" mode for all models which have one. This is not required for plugins, but you may opt to do the same for consistency.
|
||||
|
||||
## GraphQL
|
||||
|
||||
@@ -260,7 +260,7 @@ NetBox has replaced [Graphene-Django](https://github.com/graphql-python/graphene
|
||||
|
||||
### Change schema.py
|
||||
|
||||
Strawberry uses [python typing](https://docs.python.org/3/library/typing.html) and generally only requires a small refactoring of the schema definition to update:
|
||||
Strawberry uses [Python typing](https://docs.python.org/3/library/typing.html) and generally only requires a small refactoring of the schema definition to update:
|
||||
|
||||
```python title="Old"
|
||||
import graphene
|
||||
@@ -276,8 +276,6 @@ class CircuitsQuery(graphene.ObjectType):
|
||||
```
|
||||
|
||||
```python title="New"
|
||||
from typing import List
|
||||
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
|
||||
@@ -286,7 +284,7 @@ class CircuitsQuery:
|
||||
@strawberry.field
|
||||
def circuit(self, id: int) -> CircuitType:
|
||||
return models.Circuit.objects.get(pk=id)
|
||||
circuit_list: List[CircuitType] = strawberry_django.field()
|
||||
circuit_list: list[CircuitType] = strawberry_django.field()
|
||||
```
|
||||
|
||||
### Change types.py
|
||||
@@ -307,7 +305,7 @@ class CircuitType(NetBoxObjectType, ContactsMixin):
|
||||
```
|
||||
|
||||
```python title="New"
|
||||
from typing import Annotated, List
|
||||
from typing import Annotated
|
||||
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
@@ -321,7 +319,7 @@ class CircuitTypeType(OrganizationalObjectType):
|
||||
color: str
|
||||
|
||||
@strawberry_django.field
|
||||
def circuits(self) -> List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]:
|
||||
def circuits(self) -> list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]:
|
||||
return self.circuits.all()
|
||||
```
|
||||
|
||||
|
||||
@@ -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 and RQ workers to load the new plugin:
|
||||
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
## 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
@@ -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
@@ -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,11 +1,93 @@
|
||||
# NetBox v3.7
|
||||
|
||||
## v3.7.5 (FUTURE)
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# NetBox v4.0
|
||||
|
||||
## v4.0-beta1 (2024-04-03)
|
||||
|
||||
**WARNING:** This is a beta release of NetBox intended for testing and evaluation. **Do not use this software in production.** Also be aware that no upgrade path is provided to future releases.
|
||||
## v4.0.0 (2024-05-06)
|
||||
|
||||
!!! tip "Plugin Maintainers"
|
||||
Please see the dedicated [plugin migration guide](../plugins/development/migration-v4.md) for a checklist of changes that may be needed to ensure compatibility with NetBox v4.0.
|
||||
@@ -18,6 +16,7 @@
|
||||
* The `object_type` field on the CustomField model has been renamed to `related_object_type`.
|
||||
* The `utilities.utils` module has been removed and its resources reorganized into separate modules organized by function.
|
||||
* The obsolete `NullableCharField` class has been removed. (Use Django's stock `CharField` class with `null=True` instead.)
|
||||
* The `annotated_date` template filter and `annotated_now` template tag have been removed.
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -66,7 +65,7 @@ The legacy admin user interface is now disabled by default, and the few remainin
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12776](https://github.com/netbox-community/netbox/issues/12776) - Introduce the `htmx_talble` template tag to simplify the rendering of embedded tables
|
||||
* [#12776](https://github.com/netbox-community/netbox/issues/12776) - Introduce the `htmx_table` template tag to simplify the rendering of embedded tables
|
||||
* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace the deprecated Bleach HTML sanitization library with nh3
|
||||
* [#13283](https://github.com/netbox-community/netbox/issues/13283) - Display additional context on API-backed dropdown form fields (e.g. object descriptions)
|
||||
* [#13918](https://github.com/netbox-community/netbox/issues/13918) - Add `facility` field to Location model
|
||||
@@ -86,6 +85,26 @@ The legacy admin user interface is now disabled by default, and the few remainin
|
||||
* [#15383](https://github.com/netbox-community/netbox/issues/15383) - Standardize filtering logic for the parents of recursively-nested models (parent & ancestor filters)
|
||||
* [#15413](https://github.com/netbox-community/netbox/issues/15413) - The global search engine now supports caching of non-field object attributes
|
||||
* [#15490](https://github.com/netbox-community/netbox/issues/15490) - Custom validators can now reference related object attributes via dotted paths
|
||||
* [#15547](https://github.com/netbox-community/netbox/issues/15547) - Add comments field to CustomField model
|
||||
* [#15712](https://github.com/netbox-community/netbox/issues/15712) - Enable image attachments for virtual machines
|
||||
* [#15735](https://github.com/netbox-community/netbox/issues/15735) - Display all dates & times in ISO 8601 format consistently
|
||||
* [#15754](https://github.com/netbox-community/netbox/issues/15754) - Remove `is_staff` restriction on admin menu items
|
||||
* [#15764](https://github.com/netbox-community/netbox/issues/15764) - Increase maximum value of Device `vc_position` field
|
||||
* [#15915](https://github.com/netbox-community/netbox/issues/15915) - Provide a comprehensive system status view with export functionality
|
||||
|
||||
### Bug Fixes (from Beta2)
|
||||
|
||||
* [#15630](https://github.com/netbox-community/netbox/issues/15630) - Ensure consistent toggling between light & dark UI modes
|
||||
* [#15802](https://github.com/netbox-community/netbox/issues/15802) - Improve hyperlink color contrast in dark mode
|
||||
* [#15809](https://github.com/netbox-community/netbox/issues/15809) - Fix GraphQL union support for nullable fields
|
||||
* [#15815](https://github.com/netbox-community/netbox/issues/15815) - Convert dashboard widgets referencing old user/group models
|
||||
* [#15826](https://github.com/netbox-community/netbox/issues/15826) - Update `EXEMPT_EXCLUDE_MODELS` to reference new user & group models
|
||||
* [#15831](https://github.com/netbox-community/netbox/issues/15831) - Fix LDAP group mirroring
|
||||
* [#15838](https://github.com/netbox-community/netbox/issues/15838) - Fix AttributeError exception when rendering custom date fields
|
||||
* [#15852](https://github.com/netbox-community/netbox/issues/15852) - Update total results count when filtering object lists
|
||||
* [#15853](https://github.com/netbox-community/netbox/issues/15853) - Correct background color for cable trace SVG images in dark mode
|
||||
* [#15855](https://github.com/netbox-community/netbox/issues/15855) - Fix AttributeError exception when creating an event rule tied to a custom script
|
||||
* [#15944](https://github.com/netbox-community/netbox/issues/15944) - Fix styling of paginator when displayed above an object list
|
||||
|
||||
### Other Changes
|
||||
|
||||
@@ -110,6 +129,10 @@ The legacy admin user interface is now disabled by default, and the few remainin
|
||||
* [#15401](https://github.com/netbox-community/netbox/issues/15401) - PostgreSQL indexes and sequence tables for the relocated L2VPN models (see [#14311](https://github.com/netbox-community/netbox/issues/14311)) have been renamed
|
||||
* [#15462](https://github.com/netbox-community/netbox/issues/15462) - Relocate resources from the `utilities.utils` module
|
||||
* [#15464](https://github.com/netbox-community/netbox/issues/15464) - The many-to-many relationships for ObjectPermission are now defined on the custom User and Group models
|
||||
* [#15736](https://github.com/netbox-community/netbox/issues/15736) - Remove obsolete `annotated_date` template filter & `annotated_now` template tag
|
||||
* [#15738](https://github.com/netbox-community/netbox/issues/15738) - Remove obsolete configuration parameters for date & time formatting
|
||||
* [#15752](https://github.com/netbox-community/netbox/issues/15752) - Remove the obsolete `ENABLE_LOCALIZATION` configuration parameter
|
||||
* [#15942](https://github.com/netbox-community/netbox/issues/15942) - Refactor `settings_and_registry()` context processor
|
||||
|
||||
### REST API Changes
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -112,7 +113,6 @@ nav:
|
||||
- Default Values: 'configuration/default-values.md'
|
||||
- Error Reporting: 'configuration/error-reporting.md'
|
||||
- Plugins: 'configuration/plugins.md'
|
||||
- Date & Time: 'configuration/date-time.md'
|
||||
- Miscellaneous: 'configuration/miscellaneous.md'
|
||||
- Development: 'configuration/development.md'
|
||||
- Customization:
|
||||
@@ -129,7 +129,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'
|
||||
|
||||
@@ -30,10 +30,12 @@ class UserTokenTable(NetBoxTable):
|
||||
write_enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Write Enabled')
|
||||
)
|
||||
created = columns.DateColumn(
|
||||
created = columns.DateTimeColumn(
|
||||
timespec='minutes',
|
||||
verbose_name=_('Created'),
|
||||
)
|
||||
expires = columns.DateColumn(
|
||||
expires = columns.DateTimeColumn(
|
||||
timespec='minutes',
|
||||
verbose_name=_('Expires'),
|
||||
)
|
||||
last_used = columns.DateTimeColumn(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('asn', name=_('ASN')),
|
||||
FieldSet('asn_id', name=_('ASN')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -47,10 +47,6 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
asn = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('ASN (legacy)')
|
||||
)
|
||||
asn_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -30,10 +30,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})
|
||||
|
||||
|
||||
@@ -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 get_field_value
|
||||
from utilities.forms.fields import CommentField
|
||||
from utilities.forms.fields import CommentField, JSONField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
|
||||
@@ -133,6 +134,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)
|
||||
|
||||
|
||||
@@ -35,5 +35,5 @@ class PluginTable(BaseTable):
|
||||
'name', 'version', 'package', 'author', 'author_email', 'description',
|
||||
)
|
||||
default_columns = (
|
||||
'name', 'version', 'package', 'author', 'author_email', 'description',
|
||||
'name', 'version', 'package', 'description',
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_tables2.utils import A
|
||||
|
||||
from core.tables.columns import RQJobStatusColumn
|
||||
from netbox.tables import BaseTable
|
||||
from netbox.tables import BaseTable, columns
|
||||
|
||||
|
||||
class BackgroundQueueTable(BaseTable):
|
||||
@@ -75,13 +75,13 @@ class BackgroundTaskTable(BaseTable):
|
||||
linkify=("core:background_task", [A("id")]),
|
||||
verbose_name=_("ID")
|
||||
)
|
||||
created_at = tables.DateTimeColumn(
|
||||
created_at = columns.DateTimeColumn(
|
||||
verbose_name=_("Created")
|
||||
)
|
||||
enqueued_at = tables.DateTimeColumn(
|
||||
enqueued_at = columns.DateTimeColumn(
|
||||
verbose_name=_("Enqueued")
|
||||
)
|
||||
ended_at = tables.DateTimeColumn(
|
||||
ended_at = columns.DateTimeColumn(
|
||||
verbose_name=_("Ended")
|
||||
)
|
||||
status = RQJobStatusColumn(
|
||||
@@ -117,7 +117,7 @@ class WorkerTable(BaseTable):
|
||||
state = tables.Column(
|
||||
verbose_name=_("State")
|
||||
)
|
||||
birth_date = tables.DateTimeColumn(
|
||||
birth_date = columns.DateTimeColumn(
|
||||
verbose_name=_("Birth")
|
||||
)
|
||||
pid = tables.Column(
|
||||
|
||||
@@ -43,9 +43,6 @@ urlpatterns = (
|
||||
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
|
||||
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
|
||||
|
||||
# Configuration
|
||||
path('config/', views.ConfigView.as_view(), name='config'),
|
||||
|
||||
# Plugins
|
||||
path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
|
||||
# System
|
||||
path('system/', views.SystemView.as_view(), name='system'),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import json
|
||||
import platform
|
||||
|
||||
from django import __version__ as DJANGO_VERSION
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponseForbidden, Http404
|
||||
from django.db import connection, ProgrammingError
|
||||
from django.http import HttpResponse, HttpResponseForbidden, Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from django_rq.queues import get_queue_by_index, get_redis_connection
|
||||
from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
|
||||
from django_rq.settings import QUEUES_MAP, QUEUES_LIST
|
||||
from django_rq.utils import get_jobs, get_statistics, stop_jobs
|
||||
from rq import requeue_job
|
||||
@@ -175,20 +180,6 @@ class JobBulkDeleteView(generic.BulkDeleteView):
|
||||
# Config Revisions
|
||||
#
|
||||
|
||||
class ConfigView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
revision_id = cache.get('config_version')
|
||||
try:
|
||||
return ConfigRevision.objects.get(pk=revision_id)
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionListView(generic.ObjectListView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
@@ -527,21 +518,69 @@ class WorkerView(BaseRQView):
|
||||
# Plugins
|
||||
#
|
||||
|
||||
class PluginListView(UserPassesTestMixin, View):
|
||||
class SystemView(UserPassesTestMixin, View):
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_staff
|
||||
|
||||
def get(self, request):
|
||||
|
||||
# System stats
|
||||
psql_version = db_name = db_size = None
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT version()")
|
||||
psql_version = cursor.fetchone()[0]
|
||||
psql_version = psql_version.split('(')[0].strip()
|
||||
cursor.execute("SELECT current_database()")
|
||||
db_name = cursor.fetchone()[0]
|
||||
cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))")
|
||||
db_size = cursor.fetchone()[0]
|
||||
except (ProgrammingError, IndexError):
|
||||
pass
|
||||
stats = {
|
||||
'netbox_version': settings.VERSION,
|
||||
'django_version': DJANGO_VERSION,
|
||||
'python_version': platform.python_version(),
|
||||
'postgresql_version': psql_version,
|
||||
'database_name': db_name,
|
||||
'database_size': db_size,
|
||||
'rq_worker_count': Worker.count(get_connection('default')),
|
||||
}
|
||||
|
||||
# Plugins
|
||||
plugins = [
|
||||
# Look up app config by package name
|
||||
apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS
|
||||
]
|
||||
table = tables.PluginTable(plugins, user=request.user)
|
||||
table.configure(request)
|
||||
|
||||
return render(request, 'core/plugin_list.html', {
|
||||
'plugins': plugins,
|
||||
'active_tab': 'api-tokens',
|
||||
'table': table,
|
||||
# Configuration
|
||||
try:
|
||||
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
config = ConfigRevision(data=get_config().defaults)
|
||||
|
||||
# Raw data export
|
||||
if 'export' in request.GET:
|
||||
data = {
|
||||
**stats,
|
||||
'plugins': {
|
||||
plugin.name: plugin.version for plugin in plugins
|
||||
},
|
||||
'config': {
|
||||
k: config.data[k] for k in sorted(config.data)
|
||||
},
|
||||
}
|
||||
response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')
|
||||
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
|
||||
return response
|
||||
|
||||
plugins_table = tables.PluginTable(plugins, orderable=False)
|
||||
plugins_table.configure(request)
|
||||
|
||||
return render(request, 'core/system.html', {
|
||||
'stats': stats,
|
||||
'plugins_table': plugins_table,
|
||||
'config': config,
|
||||
})
|
||||
|
||||
@@ -347,7 +347,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:
|
||||
|
||||
@@ -53,7 +53,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
)
|
||||
status = ChoiceField(choices=DeviceStatusChoices, required=False)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||
primary_ip = IPAddressSerializer(nested=True, read_only=True)
|
||||
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
|
||||
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
@@ -101,7 +101,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 = [
|
||||
|
||||
@@ -307,7 +307,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:
|
||||
|
||||
@@ -1420,9 +1420,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)
|
||||
|
||||
@@ -1373,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
|
||||
@@ -88,14 +89,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
|
||||
@@ -106,7 +115,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
|
||||
|
||||
@@ -975,9 +975,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 add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
||||
NumericArrayField, SlugField,
|
||||
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||
@@ -629,14 +628,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': {
|
||||
@@ -1003,31 +1021,128 @@ 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 = (
|
||||
FieldSet(
|
||||
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
|
||||
'component_type', 'component_id',
|
||||
),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('interfacetemplate', name=_('Interface')),
|
||||
FieldSet('consoleporttemplate', name=_('Console Port')),
|
||||
FieldSet('consoleserverporttemplate', name=_('Console Server Port')),
|
||||
FieldSet('frontporttemplate', name=_('Front Port')),
|
||||
FieldSet('rearporttemplate', name=_('Rear Port')),
|
||||
FieldSet('powerporttemplate', name=_('Power Port')),
|
||||
FieldSet('poweroutlettemplate', name=_('Power Outlet')),
|
||||
),
|
||||
name=_('Component Assignment')
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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')
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -130,7 +130,7 @@ class CableTerminationType(NetBoxObjectType):
|
||||
Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')],
|
||||
], strawberry.union("CableTerminationTerminationType")]
|
||||
], strawberry.union("CableTerminationTerminationType")] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
@@ -302,7 +302,7 @@ class InventoryItemTemplateType(ComponentTemplateType):
|
||||
Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')],
|
||||
], strawberry.union("InventoryItemTemplateComponentType")]
|
||||
], strawberry.union("InventoryItemTemplateComponentType")] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
@@ -431,7 +431,7 @@ class InventoryItemType(ComponentType):
|
||||
Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')],
|
||||
], strawberry.union("InventoryItemComponentType")]
|
||||
], strawberry.union("InventoryItemComponentType")] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
||||
18
netbox/dcim/migrations/0187_alter_device_vc_position.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-19 16:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0186_location_facility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='vc_position',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -689,11 +689,10 @@ class Device(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
vc_position = models.PositiveSmallIntegerField(
|
||||
vc_position = models.PositiveIntegerField(
|
||||
verbose_name=_('VC position'),
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MaxValueValidator(255)],
|
||||
help_text=_('Virtual chassis position')
|
||||
)
|
||||
vc_priority = models.PositiveSmallIntegerField(
|
||||
@@ -982,17 +981,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:
|
||||
@@ -1005,7 +1003,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.html 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
|
||||
#
|
||||
@@ -646,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'),
|
||||
)
|
||||
@@ -706,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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -3160,12 +3160,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)
|
||||
@@ -3177,34 +3171,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):
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label',
|
||||
'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
|
||||
'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'choice_set', 'created', 'last_updated',
|
||||
'choice_set', 'comments', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
@@ -47,8 +47,7 @@ class EventRuleSerializer(NetBoxModelSerializer):
|
||||
# We need to manually instantiate the serializer for scripts
|
||||
if instance.action_type == EventRuleActionChoices.SCRIPT:
|
||||
script = instance.action_object
|
||||
instance = script.python_class() if script.python_class else None
|
||||
return ScriptSerializer(instance, nested=True, context=context).data
|
||||
return ScriptSerializer(script, nested=True, context=context).data
|
||||
else:
|
||||
serializer = get_serializer_for_model(instance.action_object_type.model_class())
|
||||
return serializer(instance.action_object, nested=True, context=context).data
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import uuid
|
||||
from functools import cached_property
|
||||
from hashlib import sha256
|
||||
@@ -32,6 +33,8 @@ __all__ = (
|
||||
'WidgetConfigForm',
|
||||
)
|
||||
|
||||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
def get_object_type_choices():
|
||||
return [
|
||||
@@ -54,8 +57,15 @@ def get_models_from_content_types(content_types):
|
||||
models = []
|
||||
for content_type_id in content_types:
|
||||
app_label, model_name = content_type_id.split('.')
|
||||
content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
|
||||
models.append(content_type.model_class())
|
||||
try:
|
||||
content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
|
||||
if content_type.model_class():
|
||||
models.append(content_type.model_class())
|
||||
else:
|
||||
logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
|
||||
except ObjectType.DoesNotExist:
|
||||
logger.debug(f"Dashboard Widget ObjectType not found: {app_label}:{model_name}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
|
||||
# Enqueue a Job to record the script's execution
|
||||
Job.enqueue(
|
||||
"extras.scripts.run_script",
|
||||
instance=script.module,
|
||||
instance=event_rule.action_object,
|
||||
name=script.name,
|
||||
user=user,
|
||||
data=data
|
||||
|
||||
@@ -165,7 +165,8 @@ class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
|
||||
Q(name__icontains=value) |
|
||||
Q(label__icontains=value) |
|
||||
Q(group_name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
Q(description__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from utilities.forms import BulkEditForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelChoiceField
|
||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
|
||||
__all__ = (
|
||||
@@ -64,6 +64,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
nullable_fields = ('group_name', 'description', 'choice_set')
|
||||
|
||||
@@ -316,8 +317,4 @@ class JournalEntryBulkEditForm(BulkEditForm):
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
required=False
|
||||
)
|
||||
comments = forms.CharField(
|
||||
label=_('Comments'),
|
||||
required=False,
|
||||
widget=forms.Textarea()
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
@@ -71,7 +71,7 @@ class CustomFieldImportForm(CSVModelForm):
|
||||
fields = (
|
||||
'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description',
|
||||
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
|
||||
'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', 'comments',
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +116,12 @@ class CustomLinkImportForm(CSVModelForm):
|
||||
queryset=ObjectType.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
|
||||
|
||||
@@ -53,6 +53,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
queryset=CustomFieldChoiceSet.objects.all(),
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
@@ -272,6 +273,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 = (
|
||||
FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
|
||||
|
||||
@@ -68,6 +68,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'ordering': ('file_root', 'file_path'),
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
@@ -79,6 +80,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'ordering': ('file_root', 'file_path'),
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
|
||||
@@ -27,7 +27,11 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.objecttype'),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE extras_customfield_content_types_id_seq RENAME TO extras_customfield_object_types_id_seq"
|
||||
"ALTER TABLE IF EXISTS extras_customfield_content_types_id_seq RENAME TO extras_customfield_object_types_id_seq"
|
||||
),
|
||||
# Pre-v2.10 sequence name (see #15605)
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE IF EXISTS extras_customfield_obj_type_id_seq RENAME TO extras_customfield_object_types_id_seq"
|
||||
),
|
||||
|
||||
# Custom links
|
||||
|
||||
18
netbox/extras/migrations/0114_customfield_add_comments.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-19 18:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0113_customfield_rename_object_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='comments',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
29
netbox/extras/migrations/0115_convert_dashboard_widgets.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.0.4 on 2024-04-24 20:09
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_dashboard_widgets(apps, schema_editor):
|
||||
Dashboard = apps.get_model('extras', 'Dashboard')
|
||||
|
||||
for dashboard in Dashboard.objects.all():
|
||||
for key, widget in dashboard.config.items():
|
||||
if models := widget['config'].get('models'):
|
||||
models = list(map(lambda x: x.replace('users.netboxgroup', 'users.group'), models))
|
||||
models = list(map(lambda x: x.replace('users.netboxuser', 'users.user'), models))
|
||||
dashboard.config[key]['config']['models'] = models
|
||||
dashboard.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0114_customfield_add_comments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=update_dashboard_widgets,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import decimal
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
|
||||
@@ -205,6 +206,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
verbose_name=_('is cloneable'),
|
||||
help_text=_('Replicate this value when cloning objects')
|
||||
)
|
||||
comments = models.TextField(
|
||||
verbose_name=_('comments'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = CustomFieldManager()
|
||||
|
||||
@@ -484,7 +489,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:
|
||||
|
||||
@@ -732,7 +732,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
||||
|
||||
def __str__(self):
|
||||
created = timezone.localtime(self.created)
|
||||
return f"{date_format(created, format='SHORT_DATETIME_FORMAT')} ({self.get_kind_display()})"
|
||||
return f"{created.date().isoformat()} {created.time().isoformat(timespec='minutes')} ({self.get_kind_display()})"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:journalentry', args=[self.pk])
|
||||
|
||||
@@ -97,8 +97,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')
|
||||
|
||||
@@ -165,8 +173,8 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.file_root = ManagedFileRootPathChoices.SCRIPTS
|
||||
super().save(*args, **kwargs)
|
||||
self.sync_classes()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@receiver(post_save, sender=ScriptModule)
|
||||
|
||||
@@ -24,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
|
||||
from .utils import is_report
|
||||
@@ -33,6 +34,8 @@ __all__ = (
|
||||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'ChoiceVar',
|
||||
'DateVar',
|
||||
'DateTimeVar',
|
||||
'FileVar',
|
||||
'IntegerVar',
|
||||
'IPAddressVar',
|
||||
@@ -174,6 +177,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.
|
||||
|
||||
@@ -2,6 +2,18 @@ from netbox.search import SearchIndex, register_search
|
||||
from . import models
|
||||
|
||||
|
||||
@register_search
|
||||
class CustomFieldIndex(SearchIndex):
|
||||
model = models.CustomField
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 100),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
class JournalEntryIndex(SearchIndex):
|
||||
model = models.JournalEntry
|
||||
|
||||
@@ -78,7 +78,7 @@ class CustomFieldTable(NetBoxTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
|
||||
'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
'weight', 'choice_set', 'choices', 'created', 'last_updated',
|
||||
'weight', 'choice_set', 'choices', 'comments', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||
|
||||
@@ -419,23 +419,43 @@ 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',
|
||||
)
|
||||
|
||||
|
||||
class ObjectChangeTable(NetBoxTable):
|
||||
time = tables.DateTimeColumn(
|
||||
time = columns.DateTimeColumn(
|
||||
verbose_name=_('Time'),
|
||||
linkify=True,
|
||||
format=settings.SHORT_DATETIME_FORMAT
|
||||
timespec='minutes',
|
||||
linkify=True
|
||||
)
|
||||
user_name = tables.Column(
|
||||
verbose_name=_('Username')
|
||||
@@ -475,10 +495,10 @@ class ObjectChangeTable(NetBoxTable):
|
||||
|
||||
|
||||
class JournalEntryTable(NetBoxTable):
|
||||
created = tables.DateTimeColumn(
|
||||
created = columns.DateTimeColumn(
|
||||
verbose_name=_('Created'),
|
||||
linkify=True,
|
||||
format=settings.SHORT_DATETIME_FORMAT
|
||||
timespec='minutes',
|
||||
linkify=True
|
||||
)
|
||||
assigned_object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Type')
|
||||
|
||||
@@ -65,7 +65,7 @@ def custom_links(context, obj):
|
||||
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
|
||||
)
|
||||
except Exception as e:
|
||||
template_code += f'<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{e}">' \
|
||||
template_code += f'<a class="btn btn-sm btn-outline-secondary" disabled="disabled" title="{e}">' \
|
||||
f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
|
||||
|
||||
# Add grouped links to template
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ from core.choices import 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
|
||||
@@ -28,6 +29,7 @@ from utilities.request import copy_safe_request
|
||||
from utilities.rqworker import get_workers_for_queue
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
from .scripts import run_script
|
||||
@@ -627,7 +629,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,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('jobs')
|
||||
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
|
||||
'data_source', 'data_file', 'jobs'
|
||||
)
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'model': ScriptModule,
|
||||
|
||||
@@ -100,7 +100,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
|
||||
"""
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
prefix = serializers.CharField(read_only=True)
|
||||
vrf = VRFSerializer(nested=True, read_only=True)
|
||||
vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
if self.context.get('vrf'):
|
||||
@@ -183,7 +183,7 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
"""
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
address = serializers.CharField(read_only=True)
|
||||
vrf = VRFSerializer(nested=True, read_only=True)
|
||||
vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
|
||||
description = serializers.CharField(required=False)
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
||||
@@ -82,7 +82,7 @@ class AvailableVLANSerializer(serializers.Serializer):
|
||||
Representation of a VLAN which does not exist in the database.
|
||||
"""
|
||||
vid = serializers.IntegerField(read_only=True)
|
||||
group = VLANGroupSerializer(nested=True, read_only=True)
|
||||
group = VLANGroupSerializer(nested=True, read_only=True, allow_null=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {
|
||||
|
||||
@@ -533,6 +533,24 @@ class FHRPGroupAssignmentForm(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(
|
||||
|
||||
@@ -133,7 +133,7 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')],
|
||||
Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')],
|
||||
], strawberry.union("IPAddressAssignmentType")]:
|
||||
], strawberry.union("IPAddressAssignmentType")] | None:
|
||||
return self.assigned_object
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ class VLANGroupType(OrganizationalObjectType):
|
||||
Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
|
||||
], strawberry.union("VLANGroupScopeType")]:
|
||||
], strawberry.union("VLANGroupScopeType")] | None:
|
||||
return self.scope
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -14,6 +14,7 @@ from users.models import Group, ObjectPermission
|
||||
from utilities.permissions import (
|
||||
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_type,
|
||||
)
|
||||
from .misc import _mirror_groups
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
@@ -313,7 +314,7 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
|
||||
# Create a new instance of django-auth-ldap's LDAPBackend with our own ObjectPermissions
|
||||
try:
|
||||
from django_auth_ldap.backend import LDAPBackend as LDAPBackend_
|
||||
from django_auth_ldap.backend import _LDAPUser, LDAPBackend as LDAPBackend_
|
||||
|
||||
class NBLDAPBackend(ObjectPermissionMixin, LDAPBackend_):
|
||||
def get_permission_filter(self, user_obj):
|
||||
@@ -323,6 +324,10 @@ try:
|
||||
hasattr(user_obj.ldap_user, "group_names")):
|
||||
permission_filter = permission_filter | Q(groups__name__in=user_obj.ldap_user.group_names)
|
||||
return permission_filter
|
||||
|
||||
# Patch with our modified _mirror_groups() method to support our custom Group model
|
||||
_LDAPUser._mirror_groups = _mirror_groups
|
||||
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
67
netbox/netbox/authentication/misc.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Copyright (c) 2009, Peter Sagerson
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# - Redistributions of source code must retain the above copyright notice, this
|
||||
# list of conditions and the following disclaimer.
|
||||
#
|
||||
# - Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
from users.models import Group
|
||||
|
||||
|
||||
# Copied from django_auth_ldap.backend._LDAPUser and modified to support our
|
||||
# custom Group model.
|
||||
def _mirror_groups(self):
|
||||
"""
|
||||
Mirrors the user's LDAP groups in the Django database and updates the
|
||||
user's membership.
|
||||
"""
|
||||
target_group_names = frozenset(self._get_groups().get_group_names())
|
||||
current_group_names = frozenset(
|
||||
self._user.groups.values_list("name", flat=True).iterator()
|
||||
)
|
||||
|
||||
# These were normalized to sets above.
|
||||
MIRROR_GROUPS_EXCEPT = self.settings.MIRROR_GROUPS_EXCEPT
|
||||
MIRROR_GROUPS = self.settings.MIRROR_GROUPS
|
||||
|
||||
# If the settings are white- or black-listing groups, we'll update
|
||||
# target_group_names such that we won't modify the membership of groups
|
||||
# beyond our purview.
|
||||
if isinstance(MIRROR_GROUPS_EXCEPT, (set, frozenset)):
|
||||
target_group_names = (target_group_names - MIRROR_GROUPS_EXCEPT) | (
|
||||
current_group_names & MIRROR_GROUPS_EXCEPT
|
||||
)
|
||||
elif isinstance(MIRROR_GROUPS, (set, frozenset)):
|
||||
target_group_names = (target_group_names & MIRROR_GROUPS) | (
|
||||
current_group_names - MIRROR_GROUPS
|
||||
)
|
||||
|
||||
if target_group_names != current_group_names:
|
||||
existing_groups = list(
|
||||
Group.objects.filter(name__in=target_group_names).iterator()
|
||||
)
|
||||
existing_group_names = frozenset(group.name for group in existing_groups)
|
||||
|
||||
new_groups = [
|
||||
Group.objects.get_or_create(name=name)[0]
|
||||
for name in target_group_names
|
||||
if name not in existing_group_names
|
||||
]
|
||||
|
||||
self._user.groups.set(existing_groups + new_groups)
|
||||
@@ -131,9 +131,6 @@ EMAIL = {
|
||||
'FROM_EMAIL': '',
|
||||
}
|
||||
|
||||
# Localization
|
||||
ENABLE_LOCALIZATION = False
|
||||
|
||||
# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
|
||||
# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
|
||||
EXEMPT_VIEW_PERMISSIONS = [
|
||||
@@ -237,12 +234,3 @@ SESSION_FILE_PATH = None
|
||||
|
||||
# Time zone (default: UTC)
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
# Date/time formatting. See the following link for supported formats:
|
||||
# https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date
|
||||
DATE_FORMAT = 'N j, Y'
|
||||
SHORT_DATE_FORMAT = 'Y-m-d'
|
||||
TIME_FORMAT = 'g:i a'
|
||||
SHORT_TIME_FORMAT = 'H:i:s'
|
||||
DATETIME_FORMAT = 'N j, Y g:i a'
|
||||
SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from netbox.config import get_config
|
||||
from netbox.registry import registry
|
||||
from netbox.registry import registry as registry_
|
||||
|
||||
__all__ = (
|
||||
'config',
|
||||
'preferences',
|
||||
'registry',
|
||||
'settings',
|
||||
)
|
||||
|
||||
|
||||
def settings_and_registry(request):
|
||||
def config(request):
|
||||
"""
|
||||
Expose Django settings and NetBox registry stores in the template context. Example: {{ settings.DEBUG }}
|
||||
Adds NetBox configuration parameters to the template context. Example: {{ config.BANNER_LOGIN }}
|
||||
"""
|
||||
return {
|
||||
'config': get_config(),
|
||||
}
|
||||
|
||||
|
||||
def preferences(request):
|
||||
"""
|
||||
Adds preferences for the current user (if authenticated) to the template context.
|
||||
Example: {{ preferences|get_key:"pagination.placement" }}
|
||||
"""
|
||||
user_preferences = request.user.config if request.user.is_authenticated else {}
|
||||
return {
|
||||
'settings': django_settings,
|
||||
'config': get_config(),
|
||||
'registry': registry,
|
||||
'preferences': user_preferences,
|
||||
'htmx_navigation': user_preferences.get('ui.htmx_navigation', False) == 'true'
|
||||
}
|
||||
|
||||
|
||||
def registry(request):
|
||||
"""
|
||||
Adds NetBox registry items to the template context. Example: {{ registry.models.core }}
|
||||
"""
|
||||
return {
|
||||
'registry': registry_,
|
||||
}
|
||||
|
||||
|
||||
def settings(request):
|
||||
"""
|
||||
Adds Django settings to the template context. Example: {{ settings.DEBUG }}
|
||||
"""
|
||||
return {
|
||||
'settings': django_settings,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
@@ -35,7 +37,11 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms
|
||||
def _get_form_field(self, customfield):
|
||||
if self.instance.pk:
|
||||
form_field = customfield.to_form_field(set_initial=False)
|
||||
form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
|
||||
initial = self.instance.custom_field_data.get(customfield.name)
|
||||
if customfield.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
form_field.initial = json.dumps(initial)
|
||||
else:
|
||||
form_field.initial = initial
|
||||
return form_field
|
||||
|
||||
return customfield.to_form_field()
|
||||
@@ -74,17 +80,12 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
||||
"""
|
||||
Base form for creating a NetBox objects from CSV data. Used for bulk importing.
|
||||
"""
|
||||
id = forms.IntegerField(
|
||||
label=_('Id'),
|
||||
required=False,
|
||||
help_text='Numeric ID of an existing object to update (if not creating a new object)'
|
||||
)
|
||||
tags = CSVModelMultipleChoiceField(
|
||||
label=_('Tags'),
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
to_field_name='slug',
|
||||
help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")'
|
||||
help_text=_('Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")')
|
||||
)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
@@ -172,7 +173,7 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
|
||||
# Limit saved filters to those applicable to the form's model
|
||||
object_type = ObjectType.objects.get_for_model(self.model)
|
||||
self.fields['filter_id'].widget.add_query_params({
|
||||
'object_types_id': object_type.pk,
|
||||
'object_type_id': object_type.pk,
|
||||
})
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import List
|
||||
import django_filters
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from strawberry import auto
|
||||
from ipam.fields import ASNField
|
||||
from netbox.graphql.scalars import BigInt
|
||||
@@ -164,7 +165,11 @@ def autotype_decorator(filterset):
|
||||
should_create_function = False
|
||||
attr_type = auto
|
||||
if fieldname not in cls.__annotations__:
|
||||
field = model._meta.get_field(fieldname)
|
||||
try:
|
||||
field = model._meta.get_field(fieldname)
|
||||
except FieldDoesNotExist:
|
||||
continue
|
||||
|
||||
if isinstance(field, CounterCacheField):
|
||||
should_create_function = True
|
||||
attr_type = BigInt | None
|
||||
|
||||
@@ -101,7 +101,7 @@ CONNECTIONS_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Connections'),
|
||||
items=(
|
||||
get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
|
||||
get_model_item('dcim', 'cable', _('Cables')),
|
||||
get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
|
||||
MenuItem(
|
||||
link='dcim:interface_connections_list',
|
||||
@@ -368,12 +368,10 @@ ADMIN_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Authentication'),
|
||||
items=(
|
||||
# Proxy model for auth.User
|
||||
MenuItem(
|
||||
link=f'users:user_list',
|
||||
link_text=_('Users'),
|
||||
permissions=[f'auth.view_user'],
|
||||
staff_only=True,
|
||||
buttons=(
|
||||
MenuItemButton(
|
||||
link=f'users:user_add',
|
||||
@@ -389,12 +387,10 @@ ADMIN_MENU = Menu(
|
||||
)
|
||||
)
|
||||
),
|
||||
# Proxy model for auth.Group
|
||||
MenuItem(
|
||||
link=f'users:group_list',
|
||||
link_text=_('Groups'),
|
||||
permissions=[f'auth.view_group'],
|
||||
staff_only=True,
|
||||
buttons=(
|
||||
MenuItemButton(
|
||||
link=f'users:group_add',
|
||||
@@ -414,47 +410,31 @@ ADMIN_MENU = Menu(
|
||||
link=f'users:token_list',
|
||||
link_text=_('API Tokens'),
|
||||
permissions=[f'users.view_token'],
|
||||
staff_only=True,
|
||||
buttons=get_model_buttons('users', 'token')
|
||||
),
|
||||
MenuItem(
|
||||
link=f'users:objectpermission_list',
|
||||
link_text=_('Permissions'),
|
||||
permissions=[f'users.view_objectpermission'],
|
||||
staff_only=True,
|
||||
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
|
||||
),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('Configuration'),
|
||||
items=(
|
||||
MenuItem(
|
||||
link='core:config',
|
||||
link_text=_('Current Config'),
|
||||
permissions=['core.view_configrevision'],
|
||||
staff_only=True
|
||||
),
|
||||
MenuItem(
|
||||
link='core:configrevision_list',
|
||||
link_text=_('Config Revisions'),
|
||||
permissions=['core.view_configrevision'],
|
||||
staff_only=True
|
||||
),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('System'),
|
||||
items=(
|
||||
MenuItem(
|
||||
link='core:plugin_list',
|
||||
link_text=_('Plugins'),
|
||||
staff_only=True
|
||||
link='core:system',
|
||||
link_text=_('System')
|
||||
),
|
||||
MenuItem(
|
||||
link='core:configrevision_list',
|
||||
link_text=_('Configuration History'),
|
||||
permissions=['core.view_configrevision']
|
||||
),
|
||||
MenuItem(
|
||||
link='core:background_queue_list',
|
||||
link_text=_('Background Tasks'),
|
||||
staff_only=True
|
||||
link_text=_('Background Tasks')
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,28 +15,23 @@ def get_page_lengths():
|
||||
PREFERENCES = {
|
||||
|
||||
# User interface
|
||||
'ui.colormode': UserPreference(
|
||||
label=_('Color mode'),
|
||||
choices=(
|
||||
('light', _('Light')),
|
||||
('dark', _('Dark')),
|
||||
),
|
||||
default='light',
|
||||
),
|
||||
'ui.htmx_navigation': UserPreference(
|
||||
label=_('HTMX Navigation'),
|
||||
choices=(
|
||||
('', _('Disabled')),
|
||||
('true', _('Enabled')),
|
||||
),
|
||||
default=False
|
||||
description=_('Enable dynamic UI navigation'),
|
||||
default=False,
|
||||
experimental=True
|
||||
),
|
||||
'locale.language': UserPreference(
|
||||
label=_('Language'),
|
||||
choices=(
|
||||
('', _('Auto')),
|
||||
*settings.LANGUAGES,
|
||||
)
|
||||
),
|
||||
description=_('Forces UI translation to the specified language.')
|
||||
),
|
||||
'pagination.per_page': UserPreference(
|
||||
label=_('Page length'),
|
||||
@@ -51,8 +46,8 @@ PREFERENCES = {
|
||||
('top', _('Top')),
|
||||
('both', _('Both')),
|
||||
),
|
||||
description=_('Where the paginator controls will be displayed relative to a table'),
|
||||
default='bottom'
|
||||
default='bottom',
|
||||
description=_('Where the paginator controls will be displayed relative to a table')
|
||||
),
|
||||
|
||||
# Miscellaneous
|
||||
@@ -62,6 +57,7 @@ PREFERENCES = {
|
||||
('json', 'JSON'),
|
||||
('yaml', 'YAML'),
|
||||
),
|
||||
description=_('The preferred syntax for displaying generic data within the UI')
|
||||
),
|
||||
|
||||
}
|
||||
|
||||
@@ -80,9 +80,10 @@ class SearchIndex:
|
||||
@staticmethod
|
||||
def get_field_value(instance, field_name):
|
||||
"""
|
||||
Return the value of the specified model field as a string.
|
||||
Return the value of the specified model field as a string (or None).
|
||||
"""
|
||||
return str(getattr(instance, field_name))
|
||||
if value := getattr(instance, field_name):
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_category(cls):
|
||||
|
||||
@@ -20,11 +20,12 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
from netbox.plugins import PluginConfig
|
||||
from utilities.string import trailing_slash
|
||||
|
||||
|
||||
#
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '4.0-beta1'
|
||||
VERSION = '4.0.0'
|
||||
HOSTNAME = platform.node()
|
||||
# Set the base directory two levels up
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -73,8 +74,6 @@ CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
|
||||
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
DATABASE = getattr(configuration, 'DATABASE') # Required
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
DEBUG = getattr(configuration, 'DEBUG', False)
|
||||
DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None)
|
||||
DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
|
||||
@@ -93,7 +92,6 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False)
|
||||
DJANGO_ADMIN_ENABLED = getattr(configuration, 'DJANGO_ADMIN_ENABLED', False)
|
||||
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
ENABLE_LOCALIZATION = getattr(configuration, 'ENABLE_LOCALIZATION', False)
|
||||
EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', (
|
||||
'extras.events.process_event_queue',
|
||||
))
|
||||
@@ -142,6 +140,9 @@ RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
||||
SECRET_KEY = getattr(configuration, 'SECRET_KEY') # Required
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
|
||||
SECURE_HSTS_PRELOAD = getattr(configuration, 'SECURE_HSTS_PRELOAD', False)
|
||||
SECURE_HSTS_SECONDS = getattr(configuration, 'SECURE_HSTS_SECONDS', 0)
|
||||
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
|
||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
|
||||
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||
@@ -152,12 +153,8 @@ SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
|
||||
SESSION_COOKIE_PATH = CSRF_COOKIE_PATH
|
||||
SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False)
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
|
||||
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
|
||||
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
|
||||
TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
|
||||
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
|
||||
|
||||
# Load any dynamic configuration parameters which have been hard-coded in the configuration file
|
||||
@@ -391,8 +388,6 @@ MIDDLEWARE = [
|
||||
'netbox.middleware.MaintenanceModeMiddleware',
|
||||
'django_prometheus.middleware.PrometheusAfterMiddleware',
|
||||
]
|
||||
if not ENABLE_LOCALIZATION:
|
||||
MIDDLEWARE.remove('django.middleware.locale.LocaleMiddleware')
|
||||
|
||||
# URLs
|
||||
ROOT_URLCONF = 'netbox.urls'
|
||||
@@ -415,7 +410,10 @@ TEMPLATES = [
|
||||
'django.template.context_processors.media',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'netbox.context_processors.settings_and_registry',
|
||||
'netbox.context_processors.settings',
|
||||
'netbox.context_processors.config',
|
||||
'netbox.context_processors.registry',
|
||||
'netbox.context_processors.preferences',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -483,11 +481,11 @@ SERIALIZATION_MODULES = {
|
||||
# Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
|
||||
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
|
||||
EXEMPT_EXCLUDE_MODELS = (
|
||||
('auth', 'group'),
|
||||
('auth', 'user'),
|
||||
('extras', 'configrevision'),
|
||||
('users', 'group'),
|
||||
('users', 'objectpermission'),
|
||||
('users', 'token'),
|
||||
('users', 'user'),
|
||||
)
|
||||
|
||||
# All URLs starting with a string listed here are exempt from login enforcement
|
||||
@@ -717,15 +715,13 @@ LANGUAGES = (
|
||||
LOCALE_PATHS = (
|
||||
BASE_DIR + '/translations',
|
||||
)
|
||||
if not ENABLE_LOCALIZATION:
|
||||
USE_I18N = False
|
||||
USE_L10N = False
|
||||
|
||||
#
|
||||
# Strawberry (GraphQL)
|
||||
#
|
||||
STRAWBERRY_DJANGO = {
|
||||
"TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True,
|
||||
"USE_DEPRECATED_FILTERS": True,
|
||||
}
|
||||
|
||||
#
|
||||
|
||||