mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-11 03:17:42 +01:00
Compare commits
98 Commits
v4.3.0-bet
...
v4.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe14b76c0 | ||
|
|
3d1334a798 | ||
|
|
408550d3c7 | ||
|
|
6b9b5c4184 | ||
|
|
59dce87ba0 | ||
|
|
f6a85775d7 | ||
|
|
33887e7c69 | ||
|
|
b57ceca2fd | ||
|
|
8e13f2a9ec | ||
|
|
6af4f5d7ee | ||
|
|
6054f8197d | ||
|
|
fc98294812 | ||
|
|
4b58678823 | ||
|
|
abeed474f6 | ||
|
|
d1303f49e6 | ||
|
|
127452f4d5 | ||
|
|
2979067b65 | ||
|
|
6c07aeeded | ||
|
|
76aa255f07 | ||
|
|
0c04a8d301 | ||
|
|
6665810a6d | ||
|
|
8baf15771a | ||
|
|
045417c45c | ||
|
|
aac333a6d4 | ||
|
|
145ee11a3f | ||
|
|
94618a9dfb | ||
|
|
21e813cee2 | ||
|
|
2c014bade5 | ||
|
|
b17bfef7e5 | ||
|
|
88f7b6508c | ||
|
|
bd4f1e7d2f | ||
|
|
6e49cee718 | ||
|
|
4868818576 | ||
|
|
7cd5dc0c84 | ||
|
|
aea51df06c | ||
|
|
c95098c4f0 | ||
|
|
c32d1e637f | ||
|
|
29f0ca6e0d | ||
|
|
64b5867cb3 | ||
|
|
dd9000919a | ||
|
|
e5cdd8f2b0 | ||
|
|
6202ae1236 | ||
|
|
15c7a19fb7 | ||
|
|
1141ddb22a | ||
|
|
68e9da5cd9 | ||
|
|
22e8de48fc | ||
|
|
36d71ccdd1 | ||
|
|
fadc358329 | ||
|
|
5274b3d727 | ||
|
|
c0e6168d34 | ||
|
|
01da6186eb | ||
|
|
0466c8ef9b | ||
|
|
964ae56d34 | ||
|
|
e2e42acf42 | ||
|
|
32a4d743ee | ||
|
|
5342552054 | ||
|
|
732f50d8da | ||
|
|
48a367c409 | ||
|
|
ac3c2e2de3 | ||
|
|
e44ad8af45 | ||
|
|
81dfaf0d67 | ||
|
|
584fff90c7 | ||
|
|
e345ca2659 | ||
|
|
89fd071f37 | ||
|
|
a840eaf37d | ||
|
|
bdef00f3b0 | ||
|
|
f652dc7bda | ||
|
|
18ac29fdd0 | ||
|
|
fdf42860aa | ||
|
|
b31da39c4a | ||
|
|
0f4afbca2c | ||
|
|
a0006632a7 | ||
|
|
9173a113b7 | ||
|
|
afc7b35af0 | ||
|
|
37cfc50202 | ||
|
|
41f475a316 | ||
|
|
e8dd486132 | ||
|
|
8bd8f28cb9 | ||
|
|
e58815bb1a | ||
|
|
bee004fc0c | ||
|
|
0f7789c052 | ||
|
|
e1b2b4b536 | ||
|
|
f711e666c5 | ||
|
|
47da880547 | ||
|
|
02f51bc11b | ||
|
|
88dd7a16f8 | ||
|
|
d0c2e0e52b | ||
|
|
983e544376 | ||
|
|
125bce84e4 | ||
|
|
fbf926204e | ||
|
|
0ce307c7fd | ||
|
|
6c60a4360b | ||
|
|
2c3fe9700f | ||
|
|
deaff2dad8 | ||
|
|
cd3d91e7c7 | ||
|
|
79ece657ec | ||
|
|
717b9d5232 | ||
|
|
0fa98d3aef |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.8
|
||||
placeholder: v4.3.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.8
|
||||
placeholder: v4.3.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/workflows/lock-threads.yml
vendored
2
.github/workflows/lock-threads.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
|
||||
|
||||
- name: Commit changes
|
||||
uses: EndBug/add-and-commit@v9
|
||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||
with:
|
||||
add: 'netbox/translations/'
|
||||
default_author: github_actions
|
||||
|
||||
@@ -14,6 +14,12 @@ Administrators are encouraged to adhere to industry best practices concerning th
|
||||
* Prohibit access to your database from clients other than the NetBox application
|
||||
* Keep your deployment updated to the most recent stable release
|
||||
|
||||
## Compliance Reporting
|
||||
|
||||
Please note that security compliance reports (e.g. SOC 2) are provided by NetBox Labs only to customers using NetBox Cloud or NetBox Enterprise. They are not available to users of self-hosted NetBox Community Edition.
|
||||
|
||||
If you would like to consider upgrading to NetBox Cloud or Enterprise, please contact `sales@netboxlabs.com`.
|
||||
|
||||
## Reporting a Suspected Vulnerability
|
||||
|
||||
If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions:
|
||||
|
||||
@@ -329,6 +329,7 @@
|
||||
"100base-tx",
|
||||
"100base-t1",
|
||||
"1000base-t",
|
||||
"1000base-sx",
|
||||
"1000base-lx",
|
||||
"1000base-tx",
|
||||
"2.5gbase-t",
|
||||
|
||||
@@ -69,7 +69,7 @@ For a complete list of available preferences, log into NetBox and navigate to `/
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 50
|
||||
Default: `50`
|
||||
|
||||
The default maximum number of objects to display per page within each list of objects.
|
||||
|
||||
@@ -79,7 +79,7 @@ The default maximum number of objects to display per page within each list of ob
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 15
|
||||
Default: `15`
|
||||
|
||||
The default value for the `amperage` field when creating new power feeds.
|
||||
|
||||
@@ -89,7 +89,7 @@ The default value for the `amperage` field when creating new power feeds.
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 80
|
||||
Default: `80`
|
||||
|
||||
The default value (percentage) for the `max_utilization` field when creating new power feeds.
|
||||
|
||||
@@ -99,7 +99,7 @@ The default value (percentage) for the `max_utilization` field when creating new
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 120
|
||||
Default: `120`
|
||||
|
||||
The default value for the `voltage` field when creating new power feeds.
|
||||
|
||||
@@ -109,7 +109,7 @@ The default value for the `voltage` field when creating new power feeds.
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 22
|
||||
Default: `22`
|
||||
|
||||
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
|
||||
|
||||
@@ -119,6 +119,6 @@ Default height (in pixels) of a unit within a rack elevation. For best results,
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 220
|
||||
Default: `220`
|
||||
|
||||
Default width (in pixels) of a unit within a rack elevation.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## DEBUG
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
|
||||
clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
|
||||
@@ -16,6 +16,6 @@ interface.
|
||||
|
||||
## DEVELOPER
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## SENTRY_DSN
|
||||
|
||||
Default: None
|
||||
Default: `None`
|
||||
|
||||
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
|
||||
|
||||
@@ -14,7 +14,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
|
||||
## SENTRY_ENABLED
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
|
||||
|
||||
@@ -25,7 +25,7 @@ Set to True to enable automatic error reporting via [Sentry](https://sentry.io/)
|
||||
|
||||
## SENTRY_SAMPLE_RATE
|
||||
|
||||
Default: 1.0 (all)
|
||||
Default: `1.0` (all)
|
||||
|
||||
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
|
||||
|
||||
@@ -33,7 +33,7 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
|
||||
|
||||
## SENTRY_SEND_DEFAULT_PII
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
|
||||
|
||||
@@ -60,7 +60,7 @@ SENTRY_TAGS = {
|
||||
|
||||
## SENTRY_TRACES_SAMPLE_RATE
|
||||
|
||||
Default: 0 (disabled)
|
||||
Default: `0` (disabled)
|
||||
|
||||
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: True
|
||||
Default: `True`
|
||||
|
||||
Setting this to False will disable the GraphQL API.
|
||||
|
||||
@@ -12,6 +12,6 @@ Setting this to False will disable the GraphQL API.
|
||||
|
||||
## GRAPHQL_MAX_ALIASES
|
||||
|
||||
Default: 10
|
||||
Default: `10`
|
||||
|
||||
The maximum number of queries that a GraphQL API request may contain.
|
||||
|
||||
@@ -55,7 +55,7 @@ Sets content for the top banner in the user interface.
|
||||
|
||||
## CENSUS_REPORTING_ENABLED
|
||||
|
||||
Default: True
|
||||
Default: `True`
|
||||
|
||||
Enables anonymous census reporting. To opt out of census reporting, set this to False.
|
||||
|
||||
@@ -67,7 +67,7 @@ This data enables the project maintainers to estimate how many NetBox deployment
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 90
|
||||
Default: `90`
|
||||
|
||||
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
|
||||
changes in the database indefinitely.
|
||||
@@ -79,7 +79,7 @@ changes in the database indefinitely.
|
||||
|
||||
## CHANGELOG_SKIP_EMPTY_CHANGES
|
||||
|
||||
Default: True
|
||||
Default: `True`
|
||||
|
||||
If enabled, a change log record will not be created when an object is updated without any changes to its existing field values.
|
||||
|
||||
@@ -100,7 +100,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: True
|
||||
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.
|
||||
|
||||
@@ -128,7 +128,7 @@ The maximum amount (in bytes) of uploaded data that will be held in memory befor
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 90
|
||||
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.
|
||||
|
||||
@@ -141,7 +141,7 @@ The number of days to retain job results (scripts and reports). Set this to `0`
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
|
||||
|
||||
@@ -161,7 +161,7 @@ This specifies the URL to use when presenting a map of a physical location by st
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 1000
|
||||
Default: `1000`
|
||||
|
||||
A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
|
||||
|
||||
@@ -169,7 +169,7 @@ A web user or API consumer can request an arbitrary number of objects by appendi
|
||||
|
||||
## METRICS_ENABLED
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../integrations/prometheus-metrics.md) documentation for more details.
|
||||
|
||||
@@ -179,7 +179,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
|
||||
|
||||
@@ -203,7 +203,7 @@ If no queue is defined the queue named `default` will be used.
|
||||
|
||||
## RELEASE_CHECK_URL
|
||||
|
||||
Default: None (disabled)
|
||||
Default: `None` (disabled)
|
||||
|
||||
This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## PLUGINS
|
||||
|
||||
Default: Empty
|
||||
Default: `[]`
|
||||
|
||||
A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
|
||||
|
||||
@@ -13,7 +13,7 @@ A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins wil
|
||||
|
||||
## PLUGINS_CONFIG
|
||||
|
||||
Default: Empty
|
||||
Default: `[]`
|
||||
|
||||
This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below:
|
||||
|
||||
@@ -35,7 +35,7 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff
|
||||
|
||||
## PLUGINS_CATALOG_CONFIG
|
||||
|
||||
Default: Empty
|
||||
Default: `{}` (Empty)
|
||||
|
||||
This parameter controls how individual plugins are displayed in the plugins catalog under Admin > System > Plugins. Adding a plugin to the `hidden` list will omit that plugin from the catalog. Adding a plugin to the `static` list will display the plugin, but not link to the plugin details or upgrade instructions.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## ALLOW_TOKEN_RETRIEVAL
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
!!! note
|
||||
The default value of this parameter changed from true to false in NetBox v4.3.0.
|
||||
@@ -50,7 +50,7 @@ Although it is not recommended, the default validation rules can be disabled by
|
||||
|
||||
## CORS_ORIGIN_ALLOW_ALL
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
|
||||
|
||||
@@ -82,7 +82,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
|
||||
|
||||
## CSRF_COOKIE_SECURE
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
||||
|
||||
@@ -135,7 +135,7 @@ DEFAULT_PERMISSIONS = {
|
||||
|
||||
## EXEMPT_VIEW_PERMISSIONS
|
||||
|
||||
Default: Empty list
|
||||
Default: `[]` (Empty list)
|
||||
|
||||
A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous.
|
||||
|
||||
@@ -162,7 +162,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
|
||||
|
||||
## LOGIN_PERSISTENCE
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
|
||||
|
||||
@@ -172,7 +172,7 @@ Note that enabling this setting causes NetBox to update a user's session in the
|
||||
|
||||
## LOGIN_REQUIRED
|
||||
|
||||
Default: True
|
||||
Default: `True`
|
||||
|
||||
When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
|
||||
|
||||
@@ -183,7 +183,7 @@ When enabled, only authenticated users are permitted to access any part of NetBo
|
||||
|
||||
## LOGIN_TIMEOUT
|
||||
|
||||
Default: 1209600 seconds (14 days)
|
||||
Default: `1209600` seconds (14 days)
|
||||
|
||||
The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
|
||||
|
||||
@@ -191,7 +191,7 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
|
||||
|
||||
## LOGIN_FORM_HIDDEN
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
Option to hide the login form when only SSO authentication is in use.
|
||||
|
||||
@@ -210,7 +210,7 @@ The view name or URL to which a user is redirected after logging out.
|
||||
|
||||
## SECURE_HSTS_INCLUDE_SUBDOMAINS
|
||||
|
||||
Default: False
|
||||
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.
|
||||
|
||||
@@ -218,7 +218,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T
|
||||
|
||||
## SECURE_HSTS_PRELOAD
|
||||
|
||||
Default: False
|
||||
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.
|
||||
|
||||
@@ -226,7 +226,7 @@ If true, the `preload` directive will be included in the HTTP Strict Transport S
|
||||
|
||||
## SECURE_HSTS_SECONDS
|
||||
|
||||
Default: 0
|
||||
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.
|
||||
|
||||
@@ -234,7 +234,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict
|
||||
|
||||
## SECURE_SSL_REDIRECT
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
|
||||
|
||||
@@ -253,7 +253,7 @@ The name used for the session cookie. See the [Django documentation](https://doc
|
||||
|
||||
## SESSION_COOKIE_SECURE
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
||||
|
||||
@@ -261,6 +261,6 @@ If true, the cookie employed for session authentication will be marked as secure
|
||||
|
||||
## SESSION_FILE_PATH
|
||||
|
||||
Default: None
|
||||
Default: `None`
|
||||
|
||||
HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## BASE_PATH
|
||||
|
||||
Default: None
|
||||
Default: `None`
|
||||
|
||||
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
|
||||
|
||||
@@ -74,7 +74,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
||||
|
||||
## HTTP_PROXIES
|
||||
|
||||
Default: Empty
|
||||
Default: `None`
|
||||
|
||||
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
|
||||
|
||||
@@ -101,7 +101,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true).
|
||||
|
||||
## ISOLATED_DEPLOYMENT
|
||||
|
||||
Default: False
|
||||
Default: `False`
|
||||
|
||||
Set this configuration parameter to True for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
|
||||
|
||||
@@ -253,7 +253,7 @@ The specific configuration settings for each storage backend can be found in the
|
||||
|
||||
## TIME_ZONE
|
||||
|
||||
Default: UTC
|
||||
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).
|
||||
|
||||
@@ -261,6 +261,6 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
|
||||
|
||||
## TRANSLATION_ENABLED
|
||||
|
||||
Default: True
|
||||
Default: `True`
|
||||
|
||||
Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)
|
||||
|
||||
@@ -53,6 +53,7 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
|
||||
|
||||
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
||||
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
|
||||
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
|
||||
|
||||
### Manually Perform a New Install
|
||||
|
||||
|
||||
@@ -7,32 +7,10 @@ This section entails the installation and configuration of a local PostgreSQL da
|
||||
|
||||
## Installation
|
||||
|
||||
=== "Ubuntu"
|
||||
|
||||
```no-highlight
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y postgresql-server
|
||||
sudo postgresql-setup --initdb
|
||||
```
|
||||
|
||||
CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
|
||||
|
||||
```no-highlight
|
||||
host all all 127.0.0.1/32 md5
|
||||
host all all ::1/128 md5
|
||||
```
|
||||
|
||||
Once PostgreSQL has been installed, start the service and enable it to run at boot:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl enable --now postgresql
|
||||
```
|
||||
```no-highlight
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql
|
||||
```
|
||||
|
||||
Before continuing, verify that you have installed PostgreSQL 14 or later:
|
||||
|
||||
|
||||
@@ -4,18 +4,9 @@
|
||||
|
||||
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
|
||||
|
||||
=== "Ubuntu"
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y redis-server
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y redis
|
||||
sudo systemctl enable --now redis
|
||||
```
|
||||
```no-highlight
|
||||
sudo apt install -y redis-server
|
||||
```
|
||||
|
||||
Before continuing, verify that your installed version of Redis is at least v4.0:
|
||||
|
||||
|
||||
@@ -9,17 +9,11 @@ Begin by installing all system packages required by NetBox and its dependencies.
|
||||
!!! warning "Python 3.10 or later required"
|
||||
NetBox supports Python 3.10, 3.11, and 3.12.
|
||||
|
||||
=== "Ubuntu"
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
|
||||
```
|
||||
```no-highlight
|
||||
sudo apt install -y python3 python3-pip python3-venv python3-dev \
|
||||
build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev \
|
||||
libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
Before continuing, check that your installed Python version is at least 3.10:
|
||||
|
||||
@@ -55,17 +49,9 @@ cd /opt/netbox/
|
||||
|
||||
If `git` is not already installed, install it:
|
||||
|
||||
=== "Ubuntu"
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y git
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y git
|
||||
```
|
||||
```no-highlight
|
||||
sudo apt install -y git
|
||||
```
|
||||
|
||||
Next, clone the git repository:
|
||||
|
||||
@@ -97,24 +83,12 @@ Using this installation method enables easy upgrades in the future by simply che
|
||||
|
||||
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files.
|
||||
|
||||
=== "Ubuntu"
|
||||
|
||||
```
|
||||
sudo adduser --system --group netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/reports/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
```
|
||||
sudo groupadd --system netbox
|
||||
sudo adduser --system -g netbox netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/reports/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
|
||||
```
|
||||
```
|
||||
sudo adduser --system --group netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/reports/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -6,18 +6,10 @@ This guide explains how to implement LDAP authentication using an external serve
|
||||
|
||||
### Install System Packages
|
||||
|
||||
On Ubuntu:
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
|
||||
```
|
||||
|
||||
On CentOS:
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y openldap-devel python3-devel
|
||||
```
|
||||
|
||||
### Install django-auth-ldap
|
||||
|
||||
Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
# Installation
|
||||
|
||||
!!! info "NetBox Cloud"
|
||||
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
- :material-clock-fast:{ .lg .middle } __Eager to Get Started?__
|
||||
|
||||
---
|
||||
|
||||
Check out the [NetBox Cloud Free Plan](https://netboxlabs.com/free-netbox-cloud/)! Skip the installation process and grab your own NetBox Cloud instance, preconfigured and ready to go in minutes. Completely free!
|
||||
|
||||
[:octicons-arrow-right-24: Sign Up](https://signup.netboxlabs.com/)
|
||||
|
||||
</div>
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
|
||||
@@ -217,26 +217,34 @@ If we wanted to assign this IP address to a virtual machine interface instead, w
|
||||
|
||||
### Brief Format
|
||||
|
||||
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of an IP address looks like this:
|
||||
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
GET /api/ipam/prefixes/13980/
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 13980,
|
||||
"url": "http://netbox/api/ipam/prefixes/13980/",
|
||||
"display_url": "http://netbox/api/ipam/prefixes/13980/",
|
||||
"display": "192.0.2.0/24",
|
||||
"family": {
|
||||
"value": 4,
|
||||
"label": "IPv4"
|
||||
},
|
||||
"prefix": "192.0.2.0/24",
|
||||
"site": {
|
||||
"id": 3,
|
||||
"url": "http://netbox/api/dcim/sites/17/",
|
||||
"name": "Site 23A",
|
||||
"slug": "site-23a"
|
||||
},
|
||||
"vrf": null,
|
||||
"scope_type": "dcim.site",
|
||||
"scope_id": 3,
|
||||
"scope": {
|
||||
"id": 3,
|
||||
"url": "http://netbox/api/dcim/sites/3/",
|
||||
"display": "Site 23A",
|
||||
"name": "Site 23A",
|
||||
"slug": "site-23a",
|
||||
"description": ""
|
||||
},
|
||||
"tenant": null,
|
||||
"vlan": null,
|
||||
"status": {
|
||||
@@ -250,24 +258,36 @@ GET /api/ipam/prefixes/13980/
|
||||
"slug": "staging"
|
||||
},
|
||||
"is_pool": false,
|
||||
"mark_utilized": false,
|
||||
"description": "Example prefix",
|
||||
"comments": "",
|
||||
"tags": [],
|
||||
"custom_fields": {},
|
||||
"created": "2018-12-10",
|
||||
"last_updated": "2019-03-01T20:02:46.173540Z"
|
||||
"created": "2025-03-01T20:01:23.458302Z",
|
||||
"last_updated": "2025-03-01T20:02:46.173540Z",
|
||||
"children": 0,
|
||||
"_depth": 0
|
||||
}
|
||||
```
|
||||
|
||||
The brief format is much more terse:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
GET /api/ipam/prefixes/13980/?brief=1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 13980,
|
||||
"url": "http://netbox/api/ipam/prefixes/13980/",
|
||||
"family": 4,
|
||||
"prefix": "10.40.3.0/24"
|
||||
"display": "192.0.2.0/24",
|
||||
"family": {
|
||||
"value": 4,
|
||||
"label": "IPv4"
|
||||
},
|
||||
"prefix": "192.0.2.0/24",
|
||||
"description": "Example prefix",
|
||||
"_depth": 0
|
||||
}
|
||||
```
|
||||
|
||||
@@ -400,25 +420,31 @@ curl -s -X POST \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://netbox/api/ipam/prefixes/ \
|
||||
--data '{"prefix": "192.0.2.0/24", "site": 6}' | jq '.'
|
||||
--data '{"prefix": "192.0.2.0/24", "scope_type": "dcim.site", "scope_id": 6}' | jq '.'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 18691,
|
||||
"url": "http://netbox/api/ipam/prefixes/18691/",
|
||||
"display_url": "http://netbox/api/ipam/prefixes/18691/",
|
||||
"display": "192.0.2.0/24",
|
||||
"family": {
|
||||
"value": 4,
|
||||
"label": "IPv4"
|
||||
},
|
||||
"prefix": "192.0.2.0/24",
|
||||
"site": {
|
||||
"vrf": null,
|
||||
"scope_type": "dcim.site",
|
||||
"scope_id": 6,
|
||||
"scope": {
|
||||
"id": 6,
|
||||
"url": "http://netbox/api/dcim/sites/6/",
|
||||
"display": "US-East 4",
|
||||
"name": "US-East 4",
|
||||
"slug": "us-east-4"
|
||||
"slug": "us-east-4",
|
||||
"description": ""
|
||||
},
|
||||
"vrf": null,
|
||||
"tenant": null,
|
||||
"vlan": null,
|
||||
"status": {
|
||||
@@ -427,11 +453,15 @@ http://netbox/api/ipam/prefixes/ \
|
||||
},
|
||||
"role": null,
|
||||
"is_pool": false,
|
||||
"mark_utilized": false,
|
||||
"description": "",
|
||||
"comments": "",
|
||||
"tags": [],
|
||||
"custom_fields": {},
|
||||
"created": "2020-08-04",
|
||||
"last_updated": "2020-08-04T20:08:39.007125Z"
|
||||
"created": "2025-04-29T15:44:47.597092Z",
|
||||
"last_updated": "2025-04-29T15:44:47.597092Z",
|
||||
"children": 0,
|
||||
"_depth": 0
|
||||
}
|
||||
```
|
||||
|
||||
@@ -490,18 +520,24 @@ http://netbox/api/ipam/prefixes/18691/ \
|
||||
{
|
||||
"id": 18691,
|
||||
"url": "http://netbox/api/ipam/prefixes/18691/",
|
||||
"display_url": "http://netbox/api/ipam/prefixes/18691/",
|
||||
"display": "192.0.2.0/24",
|
||||
"family": {
|
||||
"value": 4,
|
||||
"label": "IPv4"
|
||||
},
|
||||
"prefix": "192.0.2.0/24",
|
||||
"site": {
|
||||
"vrf": null,
|
||||
"scope_type": "dcim.site",
|
||||
"scope_id": 6,
|
||||
"scope": {
|
||||
"id": 6,
|
||||
"url": "http://netbox/api/dcim/sites/6/",
|
||||
"display": "US-East 4",
|
||||
"name": "US-East 4",
|
||||
"slug": "us-east-4"
|
||||
"slug": "us-east-4",
|
||||
"description": ""
|
||||
},
|
||||
"vrf": null,
|
||||
"tenant": null,
|
||||
"vlan": null,
|
||||
"status": {
|
||||
@@ -510,11 +546,15 @@ http://netbox/api/ipam/prefixes/18691/ \
|
||||
},
|
||||
"role": null,
|
||||
"is_pool": false,
|
||||
"mark_utilized": false,
|
||||
"description": "",
|
||||
"comments": "",
|
||||
"tags": [],
|
||||
"custom_fields": {},
|
||||
"created": "2020-08-04",
|
||||
"last_updated": "2020-08-04T20:14:55.709430Z"
|
||||
"created": "2025-04-29T15:44:47.597092Z",
|
||||
"last_updated": "2025-04-29T15:49:40.689109Z",
|
||||
"children": 0,
|
||||
"_depth": 0
|
||||
}
|
||||
```
|
||||
|
||||
@@ -568,6 +608,23 @@ http://netbox/api/dcim/sites/ \
|
||||
!!! note
|
||||
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
|
||||
|
||||
## Uploading Files
|
||||
|
||||
As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.
|
||||
|
||||
For example, we can upload an image attachment using the `curl` command shown below. Note that the `@` signifies a local file on disk to be uploaded.
|
||||
|
||||
```no-highlight
|
||||
curl -X POST \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-F "object_type=dcim.site" \
|
||||
-F "object_id=2" \
|
||||
-F "name=attachment1.png" \
|
||||
-F "image=@local_file.png" \
|
||||
http://netbox/api/extras/image-attachments/
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
|
||||
@@ -653,6 +710,7 @@ Note that we are _not_ passing an existing REST API token with this request. If
|
||||
{
|
||||
"id": 6,
|
||||
"url": "https://netbox/api/users/tokens/6/",
|
||||
"display_url": "https://netbox/api/users/tokens/6/",
|
||||
"display": "**********************************3c9cb9",
|
||||
"user": {
|
||||
"id": 2,
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# NetBox v4.2
|
||||
|
||||
## v4.2.9 (2025-04-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17151](https://github.com/netbox-community/netbox/issues/17151) - Display circuit type with background color in circuits list
|
||||
* [#17319](https://github.com/netbox-community/netbox/issues/17319) - Improve layout of component template edit forms
|
||||
* [#17405](https://github.com/netbox-community/netbox/issues/17405) - Display plugin icons in plugins list
|
||||
* [#18215](https://github.com/netbox-community/netbox/issues/18215) - Link to script results list from script history
|
||||
* [#18334](https://github.com/netbox-community/netbox/issues/18334) - Add region, site group, site, location, and rack filters for modules
|
||||
* [#18982](https://github.com/netbox-community/netbox/issues/18982) - Reference rack as related object in changelog records for rack reservations
|
||||
* [#18989](https://github.com/netbox-community/netbox/issues/18989) - List virtual circuits under provider view
|
||||
* [#19110](https://github.com/netbox-community/netbox/issues/19110) - Enable filtering devices and virtual machines by primary IP address
|
||||
* [#19358](https://github.com/netbox-community/netbox/issues/19358) - Move release info from footer to the navigation menu
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#15739](https://github.com/netbox-community/netbox/issues/15739) - Account for parallel cables when calculating total path length
|
||||
* [#15971](https://github.com/netbox-community/netbox/issues/15971) - Preserve "none" selection in filter form fields
|
||||
* [#16238](https://github.com/netbox-community/netbox/issues/16238) - Fix styling for white, gray, and black custom link buttons
|
||||
* [#17613](https://github.com/netbox-community/netbox/issues/17613) - Fix layout of object view content on mobile
|
||||
* [#17676](https://github.com/netbox-community/netbox/issues/17676) - Fix support for module bay creation when bulk importing module types
|
||||
* [#18706](https://github.com/netbox-community/netbox/issues/18706) - Fix validation for VLANs assigned to both a group and a site
|
||||
* [#18717](https://github.com/netbox-community/netbox/issues/18717) - Ensure change logs populated for many-to-one changes
|
||||
* [#19117](https://github.com/netbox-community/netbox/issues/19117) - Avoid `AttributeError` exception when bulk import objects which have a multi-object custom field with a default value
|
||||
* [#19204](https://github.com/netbox-community/netbox/issues/19204) - Improve JSON serialization support for data returned by a custom script
|
||||
* [#19217](https://github.com/netbox-community/netbox/issues/19217) - Ensure static assets for the debug toolbar are installed even if `DEBUG` is false
|
||||
* [#19228](https://github.com/netbox-community/netbox/issues/19228) - Fix ordering of custom scripts to avoid `NoReverseMatch` exception
|
||||
* [#19229](https://github.com/netbox-community/netbox/issues/19229) - Fix `ValueError` exception when attempting to nullify interface mode when a VLAN is assigned
|
||||
* [#19275](https://github.com/netbox-community/netbox/issues/19275) - `type` field should not be required when bulk editing interfaces
|
||||
* [#19279](https://github.com/netbox-community/netbox/issues/19279) - `status` field should not be required when bulk editing inventory items
|
||||
* [#19281](https://github.com/netbox-community/netbox/issues/19281) - Fix form validation failure when attempting to create a service from a service template
|
||||
* [#19320](https://github.com/netbox-community/netbox/issues/19320) - Include Q-in-Q VLAN (if any) in VM interface details
|
||||
* [#19322](https://github.com/netbox-community/netbox/issues/19322) - Correct URL paths for bulk import views
|
||||
* [#19346](https://github.com/netbox-community/netbox/issues/19346) - Ensure all redirect URLs are validated before use
|
||||
|
||||
---
|
||||
|
||||
## v4.2.8 (2025-04-22)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
## v4.3.0-beta2 (2025-04-23)
|
||||
# NetBox v4.3
|
||||
|
||||
## v4.3.1 (2025-05-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17073](https://github.com/netbox-community/netbox/issues/17073) - Enable global search for tags
|
||||
* [#18419](https://github.com/netbox-community/netbox/issues/18419) - Enable specifying a queue name when calling `Job.enqueue()`
|
||||
* [#19416](https://github.com/netbox-community/netbox/issues/19416) - Add the 1000BASE-SX interface type
|
||||
* [#19434](https://github.com/netbox-community/netbox/issues/19434) - Add pre-populated interface speed choices for 2.5 and 5 Gbps
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17107](https://github.com/netbox-community/netbox/issues/17107) - Fix cosmetic issue in cable traces ending at a provider network
|
||||
* [#19309](https://github.com/netbox-community/netbox/issues/19309) - Improve REST API query performance for prefixes and IP addresses
|
||||
* [#19361](https://github.com/netbox-community/netbox/issues/19361) - Fix incorrect GraphQL object types
|
||||
* [#19375](https://github.com/netbox-community/netbox/issues/19375) - Fix table configuration after applying a saved table config
|
||||
* [#19376](https://github.com/netbox-community/netbox/issues/19376) - Fix `FieldDoesNotExist` exception when global search results include a contact
|
||||
* [#19380](https://github.com/netbox-community/netbox/issues/19380) - Fix column selections for child object tables
|
||||
* [#19381](https://github.com/netbox-community/netbox/issues/19381) - Fix syncing of custom scripts from a remote data source
|
||||
* [#19396](https://github.com/netbox-community/netbox/issues/19396) - Enable nullifying VLAN `qinq_role` via the REST API
|
||||
* [#19397](https://github.com/netbox-community/netbox/issues/19397) - Correct enum type for IPRangeFilter in GraphQL API
|
||||
* [#19432](https://github.com/netbox-community/netbox/issues/19432) - Update minimum required PostgreSQL version referenced by server error page
|
||||
* [#19440](https://github.com/netbox-community/netbox/issues/19440) - Ensure data migrations use the correct database connection
|
||||
* [#19444](https://github.com/netbox-community/netbox/issues/19444) - Fix change logging for contact group assignments
|
||||
* [#19463](https://github.com/netbox-community/netbox/issues/19463) - Hide button dropdown for tables which do not support saved configs
|
||||
* [#19464](https://github.com/netbox-community/netbox/issues/19464) - Fix bulk editing of inventory items from device view
|
||||
* [#19465](https://github.com/netbox-community/netbox/issues/19465) - Fix ability to clear assigned prefix scope in UI
|
||||
* [#19472](https://github.com/netbox-community/netbox/issues/19472) - Fix device column rendering in virtual device contexts table
|
||||
|
||||
---
|
||||
|
||||
## v4.3.0 (2025-05-01)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -7,6 +39,7 @@
|
||||
* The `ALLOW_TOKEN_RETRIEVAL` configuration parameter now defaults to False.
|
||||
* The `device` and `virtual_machine` foreign keys on the Service model have been replaced with a generic `parent` relationship to support the assignment of services to FHRP groups as well.
|
||||
* The `group` foreign key on the Contact model has been replaced with a many-to-many `groups` field.
|
||||
* `django-storages` is now a required dependency. (It will be installed automatically on upgrade.)
|
||||
* PluginTemplateExtension no longer supports registration via the singular `model` attribute (use `models` instead).
|
||||
* The legacy staged changes functionality has been removed.
|
||||
|
||||
@@ -58,6 +91,7 @@ User can now declare one or more proxy routers via the `PROXY_ROUTERS` configura
|
||||
* [#18780](https://github.com/netbox-community/netbox/issues/18780) - Introduce `DATABASES` and `DATABASE_ROUTERS` configuration parameters to enable defining connections to external databases (e.g. for plugins)
|
||||
* [#18783](https://github.com/netbox-community/netbox/issues/18783) - Enable filtering all applicable models by tag ID
|
||||
* [#18785](https://github.com/netbox-community/netbox/issues/18785) - Enable custom choices for rack, device, and module airflow
|
||||
* [#18896](https://github.com/netbox-community/netbox/issues/18896) - Enable the use of remote storage for custom scripts
|
||||
|
||||
### Plugins
|
||||
|
||||
@@ -67,15 +101,6 @@ User can now declare one or more proxy routers via the `PROXY_ROUTERS` configura
|
||||
* [#18305](https://github.com/netbox-community/netbox/issues/18305) - Introduce plugin support for ContactsMixin
|
||||
* [#19073](https://github.com/netbox-community/netbox/issues/19073) - Allow installed plugins to be omitted from the plugins list
|
||||
|
||||
### Bug Fixes (From beta1)
|
||||
|
||||
* [#19213](https://github.com/netbox-community/netbox/issues/19213) - Fix rendering of dropdown selection form fields
|
||||
* [#19224](https://github.com/netbox-community/netbox/issues/19224) - Fix GraphQL API support for custom field choices
|
||||
* [#19225](https://github.com/netbox-community/netbox/issues/19225) - Restore missing GraphQL API filters
|
||||
* [#19263](https://github.com/netbox-community/netbox/issues/19263) - Render action buttons only if the record model matches the table model
|
||||
* [#19264](https://github.com/netbox-community/netbox/issues/19264) - Support table configs on child object list views
|
||||
* [#19266](https://github.com/netbox-community/netbox/issues/19266) - Fix copy-to-clipboard button for IP addresses
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#18071](https://github.com/netbox-community/netbox/issues/18071) - Removed legacy staged changed functionality in favor of the [netbox-branching](https://github.com/netboxlabs/netbox-branching) plugin
|
||||
@@ -83,7 +108,7 @@ User can now declare one or more proxy routers via the `PROXY_ROUTERS` configura
|
||||
* [#18191](https://github.com/netbox-community/netbox/issues/18191) - Remove redundant PostgreSQL indexes
|
||||
* [#18236](https://github.com/netbox-community/netbox/issues/18236) - Upgrade the HTMX library to v2.0
|
||||
* [#18540](https://github.com/netbox-community/netbox/issues/18540) - Operational plugins are now recorded in the application registry
|
||||
* [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.0
|
||||
* [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.2
|
||||
* [#18743](https://github.com/netbox-community/netbox/issues/18743) - Upgrade Django to v5.2
|
||||
* [#18751](https://github.com/netbox-community/netbox/issues/18751) - Change the default value for `ALLOW_TOKEN_RETRIEVAL` to False
|
||||
* [#18808](https://github.com/netbox-community/netbox/issues/18808) - Squashed migration dependencies have been altered to rectify an issue with Django's `sqlmigrate` management command
|
||||
|
||||
@@ -49,6 +49,7 @@ markdown_extensions:
|
||||
- admonition
|
||||
- attr_list
|
||||
- footnotes
|
||||
- md_in_html
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.shortcuts import render, resolve_url
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.generic import View
|
||||
@@ -28,6 +28,7 @@ from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
from users import forms, tables
|
||||
from users.models import UserConfig
|
||||
from utilities.request import safe_for_redirect
|
||||
from utilities.string import remove_linebreaks
|
||||
from utilities.views import register_model_view
|
||||
|
||||
@@ -148,7 +149,7 @@ class LoginView(View):
|
||||
data = request.POST if request.method == "POST" else request.GET
|
||||
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
|
||||
if redirect_url and safe_for_redirect(redirect_url):
|
||||
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
|
||||
else:
|
||||
if redirect_url:
|
||||
@@ -196,6 +197,7 @@ class ProfileView(LoginRequiredMixin, View):
|
||||
'changed_object_type'
|
||||
)[:20]
|
||||
changelog_table = ObjectChangeTable(changelog)
|
||||
changelog_table.configure(request)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'changelog_table': changelog_table,
|
||||
|
||||
@@ -8,10 +8,11 @@ def set_null_values(apps, schema_editor):
|
||||
Circuit = apps.get_model('circuits', 'Circuit')
|
||||
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
|
||||
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
Circuit.objects.filter(distance_unit='').update(distance_unit=None)
|
||||
CircuitGroupAssignment.objects.filter(priority='').update(priority=None)
|
||||
CircuitTermination.objects.filter(cable_end='').update(cable_end=None)
|
||||
Circuit.objects.using(db_alias).filter(distance_unit='').update(distance_unit=None)
|
||||
CircuitGroupAssignment.objects.using(db_alias).filter(priority='').update(priority=None)
|
||||
CircuitTermination.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -8,14 +8,15 @@ def copy_site_assignments(apps, schema_editor):
|
||||
"""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
|
||||
ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork')
|
||||
Site = apps.get_model('dcim', 'Site')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
CircuitTermination.objects.filter(site__isnull=False).update(
|
||||
CircuitTermination.objects.using(db_alias).filter(site__isnull=False).update(
|
||||
termination_type=ContentType.objects.get_for_model(Site), termination_id=models.F('site_id')
|
||||
)
|
||||
|
||||
ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork')
|
||||
CircuitTermination.objects.filter(provider_network__isnull=False).update(
|
||||
CircuitTermination.objects.using(db_alias).filter(provider_network__isnull=False).update(
|
||||
termination_type=ContentType.objects.get_for_model(ProviderNetwork),
|
||||
termination_id=models.F('provider_network_id'),
|
||||
)
|
||||
|
||||
@@ -7,15 +7,20 @@ def populate_denormalized_fields(apps, schema_editor):
|
||||
Copy site ForeignKey values to the Termination GFK.
|
||||
"""
|
||||
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
terminations = CircuitTermination.objects.filter(site__isnull=False).prefetch_related('site')
|
||||
terminations = CircuitTermination.objects.using(db_alias).filter(site__isnull=False).prefetch_related('site')
|
||||
for termination in terminations:
|
||||
termination._region_id = termination.site.region_id
|
||||
termination._site_group_id = termination.site.group_id
|
||||
termination._site_id = termination.site_id
|
||||
# Note: Location cannot be set prior to migration
|
||||
|
||||
CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'], batch_size=100)
|
||||
CircuitTermination.objects.using(db_alias).bulk_update(
|
||||
terminations,
|
||||
['_region', '_site_group', '_site'],
|
||||
batch_size=100
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -9,8 +9,9 @@ def set_member_type(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Circuit = apps.get_model('circuits', 'Circuit')
|
||||
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
CircuitGroupAssignment.objects.update(
|
||||
CircuitGroupAssignment.objects.using(db_alias).update(
|
||||
member_type=ContentType.objects.get_for_model(Circuit)
|
||||
)
|
||||
|
||||
|
||||
@@ -61,9 +61,8 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Account')
|
||||
)
|
||||
type = tables.Column(
|
||||
type = columns.ColoredLabelColumn(
|
||||
verbose_name=_('Type'),
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
termination_a = columns.TemplateColumn(
|
||||
|
||||
@@ -35,7 +35,19 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
omit=(),
|
||||
extra=(
|
||||
(
|
||||
VirtualCircuit.objects.restrict(request.user, 'view').filter(
|
||||
provider_network__provider=instance
|
||||
),
|
||||
'provider_id',
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +63,7 @@ class ProviderDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Provider, 'bulk_import', detail=False)
|
||||
@register_model_view(Provider, 'bulk_import', path='import', detail=False)
|
||||
class ProviderBulkImportView(generic.BulkImportView):
|
||||
queryset = Provider.objects.all()
|
||||
model_form = forms.ProviderImportForm
|
||||
@@ -112,7 +124,7 @@ class ProviderAccountDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ProviderAccount.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ProviderAccount, 'bulk_import', detail=False)
|
||||
@register_model_view(ProviderAccount, 'bulk_import', path='import', detail=False)
|
||||
class ProviderAccountBulkImportView(generic.BulkImportView):
|
||||
queryset = ProviderAccount.objects.all()
|
||||
model_form = forms.ProviderAccountImportForm
|
||||
@@ -186,7 +198,7 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ProviderNetwork.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ProviderNetwork, 'bulk_import', detail=False)
|
||||
@register_model_view(ProviderNetwork, 'bulk_import', path='import', detail=False)
|
||||
class ProviderNetworkBulkImportView(generic.BulkImportView):
|
||||
queryset = ProviderNetwork.objects.all()
|
||||
model_form = forms.ProviderNetworkImportForm
|
||||
@@ -243,7 +255,7 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CircuitType.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CircuitType, 'bulk_import', detail=False)
|
||||
@register_model_view(CircuitType, 'bulk_import', path='import', detail=False)
|
||||
class CircuitTypeBulkImportView(generic.BulkImportView):
|
||||
queryset = CircuitType.objects.all()
|
||||
model_form = forms.CircuitTypeImportForm
|
||||
@@ -299,7 +311,7 @@ class CircuitDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Circuit, 'bulk_import', detail=False)
|
||||
@register_model_view(Circuit, 'bulk_import', path='import', detail=False)
|
||||
class CircuitBulkImportView(generic.BulkImportView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitImportForm
|
||||
@@ -439,7 +451,7 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CircuitTermination, 'bulk_import', detail=False)
|
||||
@register_model_view(CircuitTermination, 'bulk_import', path='import', detail=False)
|
||||
class CircuitTerminationBulkImportView(generic.BulkImportView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
model_form = forms.CircuitTerminationImportForm
|
||||
@@ -500,7 +512,7 @@ class CircuitGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CircuitGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CircuitGroup, 'bulk_import', detail=False)
|
||||
@register_model_view(CircuitGroup, 'bulk_import', path='import', detail=False)
|
||||
class CircuitGroupBulkImportView(generic.BulkImportView):
|
||||
queryset = CircuitGroup.objects.all()
|
||||
model_form = forms.CircuitGroupImportForm
|
||||
@@ -550,7 +562,7 @@ class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CircuitGroupAssignment.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CircuitGroupAssignment, 'bulk_import', detail=False)
|
||||
@register_model_view(CircuitGroupAssignment, 'bulk_import', path='import', detail=False)
|
||||
class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
|
||||
queryset = CircuitGroupAssignment.objects.all()
|
||||
model_form = forms.CircuitGroupAssignmentImportForm
|
||||
@@ -607,7 +619,7 @@ class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VirtualCircuitType.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuitType, 'bulk_import', detail=False)
|
||||
@register_model_view(VirtualCircuitType, 'bulk_import', path='import', detail=False)
|
||||
class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
|
||||
queryset = VirtualCircuitType.objects.all()
|
||||
model_form = forms.VirtualCircuitTypeImportForm
|
||||
|
||||
17
netbox/core/migrations/0013_job_data_encoder.py
Normal file
17
netbox/core/migrations/0013_job_data_encoder.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_job_object_type_optional'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='data',
|
||||
field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,12 +1,10 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-26 19:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_job_object_type_optional'),
|
||||
('core', '0013_job_data_encoder'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -1,12 +1,10 @@
|
||||
# Generated by Django 5.2b1 on 2025-04-03 18:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_datasource_sync_interval'),
|
||||
('core', '0014_datasource_sync_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -88,19 +88,11 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
def sync_data(self):
|
||||
if self.data_file:
|
||||
self.file_path = os.path.basename(self.data_path)
|
||||
self._write_to_disk(self.full_path, overwrite=True)
|
||||
|
||||
def _write_to_disk(self, path, overwrite=False):
|
||||
"""
|
||||
Write the object's data to disk at the specified path
|
||||
"""
|
||||
# Check whether file already exists
|
||||
storage = self.storage
|
||||
if storage.exists(path) and not overwrite:
|
||||
raise FileExistsError()
|
||||
storage = self.storage
|
||||
|
||||
with storage.open(path, 'wb+') as new_file:
|
||||
new_file.write(self.data)
|
||||
with storage.open(self.full_path, 'wb+') as new_file:
|
||||
new_file.write(self.data_file.data)
|
||||
|
||||
@cached_property
|
||||
def storage(self):
|
||||
|
||||
@@ -5,6 +5,7 @@ import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.urls import reverse
|
||||
@@ -90,8 +91,9 @@ class Job(models.Model):
|
||||
)
|
||||
data = models.JSONField(
|
||||
verbose_name=_('data'),
|
||||
encoder=DjangoJSONEncoder,
|
||||
null=True,
|
||||
blank=True
|
||||
blank=True,
|
||||
)
|
||||
error = models.TextField(
|
||||
verbose_name=_('error'),
|
||||
@@ -213,6 +215,7 @@ class Job(models.Model):
|
||||
schedule_at=None,
|
||||
interval=None,
|
||||
immediate=False,
|
||||
queue_name=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
@@ -236,7 +239,7 @@ class Job(models.Model):
|
||||
object_id = instance.pk
|
||||
else:
|
||||
object_type = object_id = None
|
||||
rq_queue_name = get_queue_for_model(object_type.model if object_type else None)
|
||||
rq_queue_name = queue_name if queue_name else get_queue_for_model(object_type.model if object_type else None)
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
|
||||
job = Job(
|
||||
|
||||
@@ -49,6 +49,7 @@ class Plugin:
|
||||
The representation of a NetBox plugin in the catalog API.
|
||||
"""
|
||||
id: str = ''
|
||||
icon_url: str = ''
|
||||
status: str = ''
|
||||
title_short: str = ''
|
||||
title_long: str = ''
|
||||
@@ -210,6 +211,7 @@ def get_catalog_plugins():
|
||||
# Populate plugin data
|
||||
plugins[data['config_name']] = Plugin(
|
||||
id=data['id'],
|
||||
icon_url=data['icon'],
|
||||
status=data['status'],
|
||||
title_short=data['title_short'],
|
||||
title_long=data['title_long'],
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -145,8 +145,10 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
|
||||
# the association. This triggers an m2m_changed signal with the `post_remove` action type
|
||||
# for the forward direction of the relationship, ensuring that the change is recorded.
|
||||
# Similarly, for many-to-one relationships, we set the value on the related object to None
|
||||
# and save it to trigger a change record on that object.
|
||||
for relation in instance._meta.related_objects:
|
||||
if type(relation) is not ManyToManyRel:
|
||||
if type(relation) not in [ManyToManyRel, ManyToOneRel]:
|
||||
continue
|
||||
related_model = relation.related_model
|
||||
related_field_name = relation.remote_field.name
|
||||
@@ -156,7 +158,11 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
continue
|
||||
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
|
||||
obj.snapshot() # Ensure the change record includes the "before" state
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
if type(relation) is ManyToManyRel:
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
elif type(relation) is ManyToOneRel and relation.field.null is True:
|
||||
setattr(obj, related_field_name, None)
|
||||
obj.save()
|
||||
|
||||
# Enqueue the object for event processing
|
||||
queue = events_queue.get()
|
||||
|
||||
@@ -12,6 +12,12 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
PLUGIN_NAME_TEMPLATE = """
|
||||
<img class="plugin-icon" src="{{ record.icon_url }}">
|
||||
<a href="{% url 'core:plugin' record.config_name %}">{{ record.title_long }}</a>
|
||||
"""
|
||||
|
||||
|
||||
class PluginVersionTable(BaseTable):
|
||||
version = tables.Column(
|
||||
verbose_name=_('Version')
|
||||
@@ -42,8 +48,9 @@ class PluginVersionTable(BaseTable):
|
||||
|
||||
|
||||
class CatalogPluginTable(BaseTable):
|
||||
title_long = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
title_long = columns.TemplateColumn(
|
||||
template_code=PLUGIN_NAME_TEMPLATE,
|
||||
verbose_name=_('Name')
|
||||
)
|
||||
author = tables.Column(
|
||||
accessor=tables.A('author__name'),
|
||||
|
||||
@@ -103,7 +103,7 @@ class DataSourceDeleteView(generic.ObjectDeleteView):
|
||||
queryset = DataSource.objects.all()
|
||||
|
||||
|
||||
@register_model_view(DataSource, 'bulk_import', detail=False)
|
||||
@register_model_view(DataSource, 'bulk_import', path='import', detail=False)
|
||||
class DataSourceBulkImportView(generic.BulkImportView):
|
||||
queryset = DataSource.objects.all()
|
||||
model_form = forms.DataSourceImportForm
|
||||
@@ -223,6 +223,7 @@ class ObjectChangeView(generic.ObjectView):
|
||||
data=related_changes[:50],
|
||||
orderable=False
|
||||
)
|
||||
related_changes_table.configure(request)
|
||||
|
||||
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||
changed_object_type=instance.changed_object_type,
|
||||
|
||||
@@ -461,6 +461,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
Interface.objects.select_related("device", "cable"),
|
||||
],
|
||||
),
|
||||
'virtual_circuit_termination',
|
||||
'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
|
||||
'ip_addresses', # Referenced by Interface.count_ipaddresses()
|
||||
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()
|
||||
|
||||
@@ -874,6 +874,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_100ME_T1 = '100base-t1'
|
||||
TYPE_100ME_SFP = '100base-x-sfp'
|
||||
TYPE_1GE_FIXED = '1000base-t'
|
||||
TYPE_1GE_SX_FIXED = '1000base-sx'
|
||||
TYPE_1GE_LX_FIXED = '1000base-lx'
|
||||
TYPE_1GE_TX_FIXED = '1000base-tx'
|
||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||
@@ -1038,6 +1039,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
||||
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
||||
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
||||
(TYPE_1GE_SX_FIXED, '1000BASE-SX (1GE)'),
|
||||
(TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'),
|
||||
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
|
||||
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
||||
@@ -1238,6 +1240,8 @@ class InterfaceSpeedChoices(ChoiceSet):
|
||||
(10000, '10 Mbps'),
|
||||
(100000, '100 Mbps'),
|
||||
(1000000, '1 Gbps'),
|
||||
(2500000, '2.5 Gbps'),
|
||||
(5000000, '5 Gbps'),
|
||||
(10000000, '10 Gbps'),
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
|
||||
@@ -1390,10 +1390,75 @@ class ModuleFilterSet(NetBoxModelFilterSet):
|
||||
lookup_expr='in',
|
||||
label=_('Module bay (ID)'),
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='device__site__region',
|
||||
lookup_expr='in',
|
||||
label=_('Region (ID)'),
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='device__site__region',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Region (slug)'),
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='device__site__group',
|
||||
lookup_expr='in',
|
||||
label=_('Site group (ID)'),
|
||||
)
|
||||
site_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='device__site__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Site group (slug)'),
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site',
|
||||
queryset=Site.objects.all(),
|
||||
label=_('Site (ID)'),
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Site name (slug)'),
|
||||
)
|
||||
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location',
|
||||
queryset=Location.objects.all(),
|
||||
label=_('Location (ID)'),
|
||||
)
|
||||
location = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location__slug',
|
||||
queryset=Location.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__rack',
|
||||
queryset=Rack.objects.all(),
|
||||
label=_('Rack (ID)'),
|
||||
)
|
||||
rack = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__rack__name',
|
||||
queryset=Rack.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Rack (name)'),
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Device (ID)'),
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Device (name)'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=ModuleStatusChoices,
|
||||
null_value=None
|
||||
|
||||
@@ -121,11 +121,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
|
||||
|
||||
class InventoryItemBulkCreateForm(
|
||||
form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
|
||||
form_from_model(InventoryItem, ['status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = InventoryItem
|
||||
field_order = (
|
||||
'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
|
||||
'name', 'label', 'status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
|
||||
'description', 'tags',
|
||||
)
|
||||
|
||||
@@ -1779,6 +1779,13 @@ class InventoryItemBulkEditForm(
|
||||
)
|
||||
nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Remove parent device passed as context to avoid conflicts with the actual device field
|
||||
# on this form (see bug #19464)
|
||||
self.initial.pop('device', None)
|
||||
|
||||
|
||||
#
|
||||
# Device component roles
|
||||
|
||||
@@ -41,7 +41,6 @@ class InterfaceCommonForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
||||
if 'tagged_vlans' in self.fields.keys():
|
||||
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
|
||||
@@ -61,6 +60,12 @@ class InterfaceCommonForm(forms.Form):
|
||||
"or they must be global"
|
||||
).format(vlans=', '.join(invalid_vlans))
|
||||
})
|
||||
# Validate mode change
|
||||
if self.instance.pk and (self.instance.mode != self.cleaned_data['mode']):
|
||||
if 'untagged_vlan' not in self.cleaned_data and self.instance.untagged_vlan is not None:
|
||||
self.instance.untagged_vlan = None
|
||||
if 'tagged_vlans' not in self.cleaned_data and self.instance.tagged_vlans is not None:
|
||||
self.instance.tagged_vlans.clear()
|
||||
|
||||
|
||||
class ModuleCommonForm(forms.Form):
|
||||
|
||||
@@ -959,8 +959,56 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
||||
model = Module
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
|
||||
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
'rack_id': '$rack_id',
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
},
|
||||
label=_('Location')
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
label=_('Rack'),
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
}
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -66,6 +66,10 @@ class ScopedForm(forms.Form):
|
||||
if self.instance and scope_type_id != self.instance.scope_type_id:
|
||||
self.initial['scope'] = None
|
||||
|
||||
else:
|
||||
# Clear the initial scope value if scope_type is not set
|
||||
self.initial['scope'] = None
|
||||
|
||||
|
||||
class ScopedBulkEditForm(forms.Form):
|
||||
scope_type = ContentTypeChoiceField(
|
||||
|
||||
@@ -993,7 +993,7 @@ class ComponentTemplateForm(forms.ModelForm):
|
||||
class ModularComponentTemplateForm(ComponentTemplateForm):
|
||||
device_type = DynamicModelChoiceField(
|
||||
label=_('Device type'),
|
||||
queryset=DeviceType.objects.all().all(),
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
context={
|
||||
'parent': 'manufacturer',
|
||||
@@ -1008,6 +1008,16 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'description'
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -1024,10 +1034,6 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
|
||||
|
||||
|
||||
class ConsolePortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = [
|
||||
@@ -1036,10 +1042,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = [
|
||||
@@ -1050,7 +1052,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
|
||||
class PowerPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1072,7 +1078,13 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1095,7 +1107,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
|
||||
),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('rf_role', name=_('Wireless')),
|
||||
@@ -1122,8 +1138,11 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
|
||||
'description',
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1137,7 +1156,13 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
|
||||
|
||||
class RearPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'color', 'positions', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1149,7 +1174,13 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
|
||||
|
||||
class ModuleBayTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'position', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.rendering import FieldSet, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect
|
||||
from . import model_forms
|
||||
|
||||
@@ -118,7 +118,13 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
|
||||
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
|
||||
fieldsets = (
|
||||
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'color', 'rear_port', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
class Meta(model_forms.FrontPortTemplateForm.Meta):
|
||||
|
||||
@@ -159,7 +159,7 @@ class ModuleBayTemplateImportForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ModuleBayTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'label', 'position', 'description',
|
||||
'device_type', 'module_type', 'name', 'label', 'position', 'description',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -541,10 +541,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
|
||||
class ManufacturerType(OrganizationalObjectType, ContactsMixin):
|
||||
|
||||
platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
|
||||
device_types: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
|
||||
device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
|
||||
inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
|
||||
module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
|
||||
module_types: List[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
@@ -617,11 +617,11 @@ class ModuleTypeType(NetBoxObjectType):
|
||||
frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
powerporttemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
powerporttemplates: List[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
instances: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
|
||||
consoleporttemplates: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
|
||||
instances: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
|
||||
consoleporttemplates: List[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
||||
@@ -26,49 +26,50 @@ def set_null_values(apps, schema_editor):
|
||||
RackType = apps.get_model('dcim', 'RackType')
|
||||
RearPort = apps.get_model('dcim', 'RearPort')
|
||||
Site = apps.get_model('dcim', 'Site')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
Cable.objects.filter(length_unit='').update(length_unit=None)
|
||||
Cable.objects.filter(type='').update(type=None)
|
||||
ConsolePort.objects.filter(cable_end='').update(cable_end=None)
|
||||
ConsolePort.objects.filter(type='').update(type=None)
|
||||
ConsolePortTemplate.objects.filter(type='').update(type=None)
|
||||
ConsoleServerPort.objects.filter(cable_end='').update(cable_end=None)
|
||||
ConsoleServerPort.objects.filter(type='').update(type=None)
|
||||
ConsoleServerPortTemplate.objects.filter(type='').update(type=None)
|
||||
Device.objects.filter(airflow='').update(airflow=None)
|
||||
Device.objects.filter(face='').update(face=None)
|
||||
DeviceType.objects.filter(airflow='').update(airflow=None)
|
||||
DeviceType.objects.filter(subdevice_role='').update(subdevice_role=None)
|
||||
DeviceType.objects.filter(weight_unit='').update(weight_unit=None)
|
||||
FrontPort.objects.filter(cable_end='').update(cable_end=None)
|
||||
Interface.objects.filter(cable_end='').update(cable_end=None)
|
||||
Interface.objects.filter(mode='').update(mode=None)
|
||||
Interface.objects.filter(poe_mode='').update(poe_mode=None)
|
||||
Interface.objects.filter(poe_type='').update(poe_type=None)
|
||||
Interface.objects.filter(rf_channel='').update(rf_channel=None)
|
||||
Interface.objects.filter(rf_role='').update(rf_role=None)
|
||||
InterfaceTemplate.objects.filter(poe_mode='').update(poe_mode=None)
|
||||
InterfaceTemplate.objects.filter(poe_type='').update(poe_type=None)
|
||||
InterfaceTemplate.objects.filter(rf_role='').update(rf_role=None)
|
||||
ModuleType.objects.filter(airflow='').update(airflow=None)
|
||||
ModuleType.objects.filter(weight_unit='').update(weight_unit=None)
|
||||
PowerFeed.objects.filter(cable_end='').update(cable_end=None)
|
||||
PowerOutlet.objects.filter(cable_end='').update(cable_end=None)
|
||||
PowerOutlet.objects.filter(feed_leg='').update(feed_leg=None)
|
||||
PowerOutlet.objects.filter(type='').update(type=None)
|
||||
PowerOutletTemplate.objects.filter(feed_leg='').update(feed_leg=None)
|
||||
PowerOutletTemplate.objects.filter(type='').update(type=None)
|
||||
PowerPort.objects.filter(cable_end='').update(cable_end=None)
|
||||
PowerPort.objects.filter(type='').update(type=None)
|
||||
PowerPortTemplate.objects.filter(type='').update(type=None)
|
||||
Rack.objects.filter(airflow='').update(airflow=None)
|
||||
Rack.objects.filter(form_factor='').update(form_factor=None)
|
||||
Rack.objects.filter(outer_unit='').update(outer_unit=None)
|
||||
Rack.objects.filter(weight_unit='').update(weight_unit=None)
|
||||
RackType.objects.filter(outer_unit='').update(outer_unit=None)
|
||||
RackType.objects.filter(weight_unit='').update(weight_unit=None)
|
||||
RearPort.objects.filter(cable_end='').update(cable_end=None)
|
||||
Site.objects.filter(time_zone='').update(time_zone=None)
|
||||
Cable.objects.using(db_alias).filter(length_unit='').update(length_unit=None)
|
||||
Cable.objects.using(db_alias).filter(type='').update(type=None)
|
||||
ConsolePort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
ConsolePort.objects.using(db_alias).filter(type='').update(type=None)
|
||||
ConsolePortTemplate.objects.using(db_alias).filter(type='').update(type=None)
|
||||
ConsoleServerPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
ConsoleServerPort.objects.using(db_alias).filter(type='').update(type=None)
|
||||
ConsoleServerPortTemplate.objects.using(db_alias).filter(type='').update(type=None)
|
||||
Device.objects.using(db_alias).filter(airflow='').update(airflow=None)
|
||||
Device.objects.using(db_alias).filter(face='').update(face=None)
|
||||
DeviceType.objects.using(db_alias).filter(airflow='').update(airflow=None)
|
||||
DeviceType.objects.using(db_alias).filter(subdevice_role='').update(subdevice_role=None)
|
||||
DeviceType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
|
||||
FrontPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
Interface.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
Interface.objects.using(db_alias).filter(mode='').update(mode=None)
|
||||
Interface.objects.using(db_alias).filter(poe_mode='').update(poe_mode=None)
|
||||
Interface.objects.using(db_alias).filter(poe_type='').update(poe_type=None)
|
||||
Interface.objects.using(db_alias).filter(rf_channel='').update(rf_channel=None)
|
||||
Interface.objects.using(db_alias).filter(rf_role='').update(rf_role=None)
|
||||
InterfaceTemplate.objects.using(db_alias).filter(poe_mode='').update(poe_mode=None)
|
||||
InterfaceTemplate.objects.using(db_alias).filter(poe_type='').update(poe_type=None)
|
||||
InterfaceTemplate.objects.using(db_alias).filter(rf_role='').update(rf_role=None)
|
||||
ModuleType.objects.using(db_alias).filter(airflow='').update(airflow=None)
|
||||
ModuleType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
|
||||
PowerFeed.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
PowerOutlet.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
PowerOutlet.objects.using(db_alias).filter(feed_leg='').update(feed_leg=None)
|
||||
PowerOutlet.objects.using(db_alias).filter(type='').update(type=None)
|
||||
PowerOutletTemplate.objects.using(db_alias).filter(feed_leg='').update(feed_leg=None)
|
||||
PowerOutletTemplate.objects.using(db_alias).filter(type='').update(type=None)
|
||||
PowerPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
PowerPort.objects.using(db_alias).filter(type='').update(type=None)
|
||||
PowerPortTemplate.objects.using(db_alias).filter(type='').update(type=None)
|
||||
Rack.objects.using(db_alias).filter(airflow='').update(airflow=None)
|
||||
Rack.objects.using(db_alias).filter(form_factor='').update(form_factor=None)
|
||||
Rack.objects.using(db_alias).filter(outer_unit='').update(outer_unit=None)
|
||||
Rack.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
|
||||
RackType.objects.using(db_alias).filter(outer_unit='').update(outer_unit=None)
|
||||
RackType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
|
||||
RearPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
|
||||
Site.objects.using(db_alias).filter(time_zone='').update(time_zone=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -6,19 +6,26 @@ def populate_mac_addresses(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Interface = apps.get_model('dcim', 'Interface')
|
||||
MACAddress = apps.get_model('dcim', 'MACAddress')
|
||||
db_alias = schema_editor.connection.alias
|
||||
interface_ct = ContentType.objects.get_for_model(Interface)
|
||||
|
||||
mac_addresses = [
|
||||
MACAddress(
|
||||
mac_address=interface.mac_address, assigned_object_type=interface_ct, assigned_object_id=interface.pk
|
||||
mac_address=interface.mac_address,
|
||||
assigned_object_type=interface_ct,
|
||||
assigned_object_id=interface.pk
|
||||
)
|
||||
for interface in Interface.objects.filter(mac_address__isnull=False)
|
||||
]
|
||||
MACAddress.objects.bulk_create(mac_addresses, batch_size=100)
|
||||
MACAddress.objects.using(db_alias).bulk_create(mac_addresses, batch_size=100)
|
||||
|
||||
# TODO: Optimize interface updates
|
||||
for mac_address in mac_addresses:
|
||||
Interface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address)
|
||||
Interface.objects.using(db_alias).filter(
|
||||
pk=mac_address.assigned_object_id
|
||||
).update(
|
||||
primary_mac_address=mac_address
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -11,6 +11,8 @@ def load_initial_data(apps, schema_editor):
|
||||
Load initial ModuleTypeProfile objects from file.
|
||||
"""
|
||||
ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
initial_profiles = (
|
||||
'cpu',
|
||||
'fan',
|
||||
@@ -25,7 +27,7 @@ def load_initial_data(apps, schema_editor):
|
||||
with file_path.open('r') as f:
|
||||
data = json.load(f)
|
||||
try:
|
||||
ModuleTypeProfile.objects.create(**data)
|
||||
ModuleTypeProfile.objects.using(db_alias).create(**data)
|
||||
except Exception as e:
|
||||
print(f"Error loading data from {file_path}")
|
||||
raise e
|
||||
|
||||
@@ -3,7 +3,6 @@ import itertools
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.dispatch import Signal
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -774,9 +773,28 @@ class CablePath(models.Model):
|
||||
Return a tuple containing the sum of the length of each cable in the path
|
||||
and a flag indicating whether the length is definitive.
|
||||
"""
|
||||
cable_ct = ObjectType.objects.get_for_model(Cable).pk
|
||||
|
||||
# Pre-cache cable lengths by ID
|
||||
cable_ids = self.get_cable_ids()
|
||||
cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
|
||||
total_length = cables.aggregate(total=Sum('_abs_length'))['total']
|
||||
cables = {
|
||||
cable['pk']: cable['_abs_length']
|
||||
for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
|
||||
}
|
||||
|
||||
# Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
|
||||
# length of the path.
|
||||
total_length = 0
|
||||
for node_set in self.path:
|
||||
hop_length = 0
|
||||
for node in node_set:
|
||||
ct, pk = decompile_path_node(node)
|
||||
if ct != cable_ct:
|
||||
break # Not a cable
|
||||
if pk in cables and cables[pk] > hop_length:
|
||||
hop_length = cables[pk]
|
||||
total_length += hop_length
|
||||
|
||||
is_definitive = len(cables) == len(cable_ids)
|
||||
|
||||
return total_length, is_definitive
|
||||
|
||||
@@ -732,3 +732,8 @@ class RackReservation(PrimaryModel):
|
||||
@property
|
||||
def unit_list(self):
|
||||
return array_to_string(self.units)
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
objectchange.related_object = self.rack
|
||||
return objectchange
|
||||
|
||||
@@ -329,11 +329,9 @@ class CableTraceSVG:
|
||||
|
||||
# Draw attachment (line)
|
||||
start = (OFFSET + self.center, OFFSET + self.cursor)
|
||||
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
|
||||
end = (start[0], start[1] + height)
|
||||
end = (start[0], start[1] + CABLE_HEIGHT)
|
||||
line = Line(start=start, end=end, class_='attachment')
|
||||
group.add(line)
|
||||
self.cursor += PADDING * 4
|
||||
|
||||
return group
|
||||
|
||||
@@ -358,10 +356,10 @@ class CableTraceSVG:
|
||||
# Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
|
||||
|
||||
near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
|
||||
self.cursor += CABLE_HEIGHT
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links and far_ends:
|
||||
self.cursor += CABLE_HEIGHT
|
||||
|
||||
obj_list = {end.parent_object for end in far_ends}
|
||||
parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)
|
||||
@@ -449,6 +447,7 @@ class CableTraceSVG:
|
||||
# Attachment
|
||||
attachment = self.draw_attachment()
|
||||
self.connectors.append(attachment)
|
||||
self.cursor += CABLE_HEIGHT
|
||||
|
||||
# Object
|
||||
parent_object_nodes = self.draw_parent_objects(far_ends)
|
||||
|
||||
@@ -1091,10 +1091,9 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
device = tables.TemplateColumn(
|
||||
device = tables.Column(
|
||||
verbose_name=_('Device'),
|
||||
order_by=('device___name',),
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
|
||||
@@ -2859,15 +2859,23 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'primary_ip4': [str(addresses[2].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'primary_ip6': [str(addresses[2].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_virtual_chassis_id(self):
|
||||
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
|
||||
@@ -2961,6 +2969,29 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
regions = (
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
Region(name='Region 2', slug='region-2'),
|
||||
Region(name='Region 3', slug='region-3'),
|
||||
)
|
||||
for region in regions:
|
||||
region.save()
|
||||
|
||||
groups = (
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
SiteGroup(name='Site Group 2', slug='site-group-2'),
|
||||
SiteGroup(name='Site Group 3', slug='site-group-3'),
|
||||
)
|
||||
for group in groups:
|
||||
group.save()
|
||||
|
||||
sites = Site.objects.bulk_create((
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturers = (
|
||||
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
|
||||
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
|
||||
@@ -2968,11 +2999,66 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
devices = (
|
||||
create_test_device('Test Device 1'),
|
||||
create_test_device('Test Device 2'),
|
||||
create_test_device('Test Device 3'),
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturers[1], model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturers[2], model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
for role in roles:
|
||||
role.save()
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', site=sites[0]),
|
||||
Rack(name='Rack 2', site=sites[1]),
|
||||
Rack(name='Rack 3', site=sites[2]),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Test Device 1',
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
location=locations[0],
|
||||
rack=racks[0],
|
||||
status='active',
|
||||
),
|
||||
Device(
|
||||
name='Test Device 2',
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
location=locations[1],
|
||||
rack=racks[1],
|
||||
status='planned',
|
||||
),
|
||||
Device(
|
||||
name='Test Device 3',
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
location=locations[2],
|
||||
rack=racks[2],
|
||||
status='offline',
|
||||
),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_types = (
|
||||
ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
|
||||
@@ -3120,6 +3206,41 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'asset_tag': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_site_group(self):
|
||||
site_groups = SiteGroup.objects.all()[:2]
|
||||
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_rack(self):
|
||||
racks = Rack.objects.all()[:2]
|
||||
params = {'rack_id': [racks[0].pk, racks[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'rack': [racks[0].name, racks[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
|
||||
class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = ConsolePort.objects.all()
|
||||
@@ -6722,15 +6843,23 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'primary_ip4': [str(addresses[2].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'primary_ip6': [str(addresses[2].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
|
||||
class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@@ -1217,6 +1217,13 @@ front-ports:
|
||||
- name: Front Port 3
|
||||
type: 8p8c
|
||||
rear_port: Rear Port 3
|
||||
module-bays:
|
||||
- name: Module Bay 1
|
||||
position: 1
|
||||
- name: Module Bay 2
|
||||
position: 2
|
||||
- name: Module Bay 3
|
||||
position: 3
|
||||
"""
|
||||
|
||||
# Create the manufacturer
|
||||
@@ -1234,6 +1241,7 @@ front-ports:
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
)
|
||||
|
||||
form_data = {
|
||||
@@ -1288,6 +1296,11 @@ front-ports:
|
||||
self.assertEqual(fp1.rear_port, rp1)
|
||||
self.assertEqual(fp1.rear_port_position, 1)
|
||||
|
||||
self.assertEqual(module_type.modulebaytemplates.count(), 3)
|
||||
mb1 = ModuleBayTemplate.objects.first()
|
||||
self.assertEqual(mb1.name, 'Module Bay 1')
|
||||
self.assertEqual(mb1.position, '1')
|
||||
|
||||
def test_export_objects(self):
|
||||
url = reverse('dcim:moduletype_list')
|
||||
self.add_permissions('dcim.view_moduletype')
|
||||
|
||||
@@ -22,6 +22,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import count_related
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.request import safe_for_redirect
|
||||
from utilities.views import (
|
||||
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||
)
|
||||
@@ -279,7 +280,7 @@ class RegionDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Region.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Region, 'bulk_import', detail=False)
|
||||
@register_model_view(Region, 'bulk_import', path='import', detail=False)
|
||||
class RegionBulkImportView(generic.BulkImportView):
|
||||
queryset = Region.objects.all()
|
||||
model_form = forms.RegionImportForm
|
||||
@@ -405,7 +406,7 @@ class SiteGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = SiteGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(SiteGroup, 'bulk_import', detail=False)
|
||||
@register_model_view(SiteGroup, 'bulk_import', path='import', detail=False)
|
||||
class SiteGroupBulkImportView(generic.BulkImportView):
|
||||
queryset = SiteGroup.objects.all()
|
||||
model_form = forms.SiteGroupImportForm
|
||||
@@ -496,7 +497,7 @@ class SiteDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Site.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Site, 'bulk_import', detail=False)
|
||||
@register_model_view(Site, 'bulk_import', path='import', detail=False)
|
||||
class SiteBulkImportView(generic.BulkImportView):
|
||||
queryset = Site.objects.all()
|
||||
model_form = forms.SiteImportForm
|
||||
@@ -594,7 +595,7 @@ class LocationDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Location.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Location, 'bulk_import', detail=False)
|
||||
@register_model_view(Location, 'bulk_import', path='import', detail=False)
|
||||
class LocationBulkImportView(generic.BulkImportView):
|
||||
queryset = Location.objects.all()
|
||||
model_form = forms.LocationImportForm
|
||||
@@ -663,7 +664,7 @@ class RackRoleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = RackRole.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RackRole, 'bulk_import', detail=False)
|
||||
@register_model_view(RackRole, 'bulk_import', path='import', detail=False)
|
||||
class RackRoleBulkImportView(generic.BulkImportView):
|
||||
queryset = RackRole.objects.all()
|
||||
model_form = forms.RackRoleImportForm
|
||||
@@ -724,7 +725,7 @@ class RackTypeDeleteView(generic.ObjectDeleteView):
|
||||
queryset = RackType.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RackType, 'bulk_import', detail=False)
|
||||
@register_model_view(RackType, 'bulk_import', path='import', detail=False)
|
||||
class RackTypeBulkImportView(generic.BulkImportView):
|
||||
queryset = RackType.objects.all()
|
||||
model_form = forms.RackTypeImportForm
|
||||
@@ -903,7 +904,7 @@ class RackDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Rack.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Rack, 'bulk_import', detail=False)
|
||||
@register_model_view(Rack, 'bulk_import', path='import', detail=False)
|
||||
class RackBulkImportView(generic.BulkImportView):
|
||||
queryset = Rack.objects.all()
|
||||
model_form = forms.RackImportForm
|
||||
@@ -960,7 +961,7 @@ class RackReservationDeleteView(generic.ObjectDeleteView):
|
||||
queryset = RackReservation.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RackReservation, 'bulk_import', detail=False)
|
||||
@register_model_view(RackReservation, 'bulk_import', path='import', detail=False)
|
||||
class RackReservationImportView(generic.BulkImportView):
|
||||
queryset = RackReservation.objects.all()
|
||||
model_form = forms.RackReservationImportForm
|
||||
@@ -1031,7 +1032,7 @@ class ManufacturerDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Manufacturer.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Manufacturer, 'bulk_import', detail=False)
|
||||
@register_model_view(Manufacturer, 'bulk_import', path='import', detail=False)
|
||||
class ManufacturerBulkImportView(generic.BulkImportView):
|
||||
queryset = Manufacturer.objects.all()
|
||||
model_form = forms.ManufacturerImportForm
|
||||
@@ -1252,7 +1253,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(DeviceType, 'bulk_import', detail=False)
|
||||
@register_model_view(DeviceType, 'bulk_import', path='import', detail=False)
|
||||
class DeviceTypeImportView(generic.BulkImportView):
|
||||
additional_permissions = [
|
||||
'dcim.add_devicetype',
|
||||
@@ -1522,7 +1523,7 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView):
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ModuleType, 'bulk_import', detail=False)
|
||||
@register_model_view(ModuleType, 'bulk_import', path='import', detail=False)
|
||||
class ModuleTypeImportView(generic.BulkImportView):
|
||||
additional_permissions = [
|
||||
'dcim.add_moduletype',
|
||||
@@ -1533,6 +1534,7 @@ class ModuleTypeImportView(generic.BulkImportView):
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
]
|
||||
queryset = ModuleType.objects.all()
|
||||
model_form = forms.ModuleTypeImportForm
|
||||
@@ -1544,6 +1546,7 @@ class ModuleTypeImportView(generic.BulkImportView):
|
||||
'interfaces': forms.InterfaceTemplateImportForm,
|
||||
'rear-ports': forms.RearPortTemplateImportForm,
|
||||
'front-ports': forms.FrontPortTemplateImportForm,
|
||||
'module-bays': forms.ModuleBayTemplateImportForm,
|
||||
}
|
||||
|
||||
def prep_related_object_data(self, parent, data):
|
||||
@@ -2018,7 +2021,7 @@ class DeviceRoleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
|
||||
|
||||
@register_model_view(DeviceRole, 'bulk_import', detail=False)
|
||||
@register_model_view(DeviceRole, 'bulk_import', path='import', detail=False)
|
||||
class DeviceRoleBulkImportView(generic.BulkImportView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
model_form = forms.DeviceRoleImportForm
|
||||
@@ -2082,7 +2085,7 @@ class PlatformDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Platform.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Platform, 'bulk_import', detail=False)
|
||||
@register_model_view(Platform, 'bulk_import', path='import', detail=False)
|
||||
class PlatformBulkImportView(generic.BulkImportView):
|
||||
queryset = Platform.objects.all()
|
||||
model_form = forms.PlatformImportForm
|
||||
@@ -2365,7 +2368,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
|
||||
return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent)
|
||||
|
||||
|
||||
@register_model_view(Device, 'bulk_import', detail=False)
|
||||
@register_model_view(Device, 'bulk_import', path='import', detail=False)
|
||||
class DeviceBulkImportView(generic.BulkImportView):
|
||||
queryset = Device.objects.all()
|
||||
model_form = forms.DeviceImportForm
|
||||
@@ -2438,7 +2441,7 @@ class ModuleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Module.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Module, 'bulk_import', detail=False)
|
||||
@register_model_view(Module, 'bulk_import', path='import', detail=False)
|
||||
class ModuleBulkImportView(generic.BulkImportView):
|
||||
queryset = Module.objects.all()
|
||||
model_form = forms.ModuleImportForm
|
||||
@@ -2499,7 +2502,7 @@ class ConsolePortDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConsolePort, 'bulk_import', detail=False)
|
||||
@register_model_view(ConsolePort, 'bulk_import', path='import', detail=False)
|
||||
class ConsolePortBulkImportView(generic.BulkImportView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
model_form = forms.ConsolePortImportForm
|
||||
@@ -2574,7 +2577,7 @@ class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConsoleServerPort, 'bulk_import', detail=False)
|
||||
@register_model_view(ConsoleServerPort, 'bulk_import', path='import', detail=False)
|
||||
class ConsoleServerPortBulkImportView(generic.BulkImportView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
model_form = forms.ConsoleServerPortImportForm
|
||||
@@ -2649,7 +2652,7 @@ class PowerPortDeleteView(generic.ObjectDeleteView):
|
||||
queryset = PowerPort.objects.all()
|
||||
|
||||
|
||||
@register_model_view(PowerPort, 'bulk_import', detail=False)
|
||||
@register_model_view(PowerPort, 'bulk_import', path='import', detail=False)
|
||||
class PowerPortBulkImportView(generic.BulkImportView):
|
||||
queryset = PowerPort.objects.all()
|
||||
model_form = forms.PowerPortImportForm
|
||||
@@ -2724,7 +2727,7 @@ class PowerOutletDeleteView(generic.ObjectDeleteView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
|
||||
|
||||
@register_model_view(PowerOutlet, 'bulk_import', detail=False)
|
||||
@register_model_view(PowerOutlet, 'bulk_import', path='import', detail=False)
|
||||
class PowerOutletBulkImportView(generic.BulkImportView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
model_form = forms.PowerOutletImportForm
|
||||
@@ -2790,6 +2793,7 @@ class InterfaceView(generic.ObjectView):
|
||||
),
|
||||
orderable=False
|
||||
)
|
||||
vdc_table.configure(request)
|
||||
|
||||
# Get bridge interfaces
|
||||
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
|
||||
@@ -2798,6 +2802,7 @@ class InterfaceView(generic.ObjectView):
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
)
|
||||
bridge_interfaces_table.configure(request)
|
||||
|
||||
# Get child interfaces
|
||||
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
|
||||
@@ -2806,6 +2811,7 @@ class InterfaceView(generic.ObjectView):
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
)
|
||||
child_interfaces_table.configure(request)
|
||||
|
||||
# Get assigned VLANs and annotate whether each is tagged or untagged
|
||||
vlans = []
|
||||
@@ -2820,6 +2826,7 @@ class InterfaceView(generic.ObjectView):
|
||||
data=vlans,
|
||||
orderable=False
|
||||
)
|
||||
vlan_table.configure(request)
|
||||
|
||||
# Get VLAN translation rules
|
||||
vlan_translation_table = None
|
||||
@@ -2828,6 +2835,7 @@ class InterfaceView(generic.ObjectView):
|
||||
data=instance.vlan_translation_policy.rules.all(),
|
||||
orderable=False
|
||||
)
|
||||
vlan_translation_table.configure(request)
|
||||
|
||||
return {
|
||||
'vdc_table': vdc_table,
|
||||
@@ -2856,7 +2864,7 @@ class InterfaceDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Interface.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Interface, 'bulk_import', detail=False)
|
||||
@register_model_view(Interface, 'bulk_import', path='import', detail=False)
|
||||
class InterfaceBulkImportView(generic.BulkImportView):
|
||||
queryset = Interface.objects.all()
|
||||
model_form = forms.InterfaceImportForm
|
||||
@@ -2942,7 +2950,7 @@ class FrontPortDeleteView(generic.ObjectDeleteView):
|
||||
queryset = FrontPort.objects.all()
|
||||
|
||||
|
||||
@register_model_view(FrontPort, 'bulk_import', detail=False)
|
||||
@register_model_view(FrontPort, 'bulk_import', path='import', detail=False)
|
||||
class FrontPortBulkImportView(generic.BulkImportView):
|
||||
queryset = FrontPort.objects.all()
|
||||
model_form = forms.FrontPortImportForm
|
||||
@@ -3017,7 +3025,7 @@ class RearPortDeleteView(generic.ObjectDeleteView):
|
||||
queryset = RearPort.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RearPort, 'bulk_import', detail=False)
|
||||
@register_model_view(RearPort, 'bulk_import', path='import', detail=False)
|
||||
class RearPortBulkImportView(generic.BulkImportView):
|
||||
queryset = RearPort.objects.all()
|
||||
model_form = forms.RearPortImportForm
|
||||
@@ -3092,7 +3100,7 @@ class ModuleBayDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ModuleBay.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ModuleBay, 'bulk_import', detail=False)
|
||||
@register_model_view(ModuleBay, 'bulk_import', path='import', detail=False)
|
||||
class ModuleBayBulkImportView(generic.BulkImportView):
|
||||
queryset = ModuleBay.objects.all()
|
||||
model_form = forms.ModuleBayImportForm
|
||||
@@ -3239,7 +3247,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(DeviceBay, 'bulk_import', detail=False)
|
||||
@register_model_view(DeviceBay, 'bulk_import', path='import', detail=False)
|
||||
class DeviceBayBulkImportView(generic.BulkImportView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
model_form = forms.DeviceBayImportForm
|
||||
@@ -3305,7 +3313,7 @@ class InventoryItemDeleteView(generic.ObjectDeleteView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
|
||||
|
||||
@register_model_view(InventoryItem, 'bulk_import', detail=False)
|
||||
@register_model_view(InventoryItem, 'bulk_import', path='import', detail=False)
|
||||
class InventoryItemBulkImportView(generic.BulkImportView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
model_form = forms.InventoryItemImportForm
|
||||
@@ -3386,7 +3394,7 @@ class InventoryItemRoleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = InventoryItemRole.objects.all()
|
||||
|
||||
|
||||
@register_model_view(InventoryItemRole, 'bulk_import', detail=False)
|
||||
@register_model_view(InventoryItemRole, 'bulk_import', path='import', detail=False)
|
||||
class InventoryItemRoleBulkImportView(generic.BulkImportView):
|
||||
queryset = InventoryItemRole.objects.all()
|
||||
model_form = forms.InventoryItemRoleImportForm
|
||||
@@ -3582,7 +3590,7 @@ class CableDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Cable.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Cable, 'bulk_import', detail=False)
|
||||
@register_model_view(Cable, 'bulk_import', path='import', detail=False)
|
||||
class CableBulkImportView(generic.BulkImportView):
|
||||
queryset = Cable.objects.all()
|
||||
model_form = forms.CableImportForm
|
||||
@@ -3811,7 +3819,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
||||
)
|
||||
))
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
return redirect(self.get_return_url(request, device))
|
||||
@@ -3883,7 +3891,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(VirtualChassis, 'bulk_import', detail=False)
|
||||
@register_model_view(VirtualChassis, 'bulk_import', path='import', detail=False)
|
||||
class VirtualChassisBulkImportView(generic.BulkImportView):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
model_form = forms.VirtualChassisImportForm
|
||||
@@ -3940,7 +3948,7 @@ class PowerPanelDeleteView(generic.ObjectDeleteView):
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
||||
|
||||
@register_model_view(PowerPanel, 'bulk_import', detail=False)
|
||||
@register_model_view(PowerPanel, 'bulk_import', path='import', detail=False)
|
||||
class PowerPanelBulkImportView(generic.BulkImportView):
|
||||
queryset = PowerPanel.objects.all()
|
||||
model_form = forms.PowerPanelImportForm
|
||||
@@ -3992,7 +4000,7 @@ class PowerFeedDeleteView(generic.ObjectDeleteView):
|
||||
queryset = PowerFeed.objects.all()
|
||||
|
||||
|
||||
@register_model_view(PowerFeed, 'bulk_import', detail=False)
|
||||
@register_model_view(PowerFeed, 'bulk_import', path='import', detail=False)
|
||||
class PowerFeedBulkImportView(generic.BulkImportView):
|
||||
queryset = PowerFeed.objects.all()
|
||||
model_form = forms.PowerFeedImportForm
|
||||
@@ -4064,7 +4072,7 @@ class VirtualDeviceContextDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext, 'bulk_import', detail=False)
|
||||
@register_model_view(VirtualDeviceContext, 'bulk_import', path='import', detail=False)
|
||||
class VirtualDeviceContextBulkImportView(generic.BulkImportView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
model_form = forms.VirtualDeviceContextImportForm
|
||||
@@ -4114,7 +4122,7 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
|
||||
queryset = MACAddress.objects.all()
|
||||
|
||||
|
||||
@register_model_view(MACAddress, 'bulk_import', detail=False)
|
||||
@register_model_view(MACAddress, 'bulk_import', path='import', detail=False)
|
||||
class MACAddressBulkImportView(generic.BulkImportView):
|
||||
queryset = MACAddress.objects.all()
|
||||
model_form = forms.MACAddressImportForm
|
||||
|
||||
@@ -4,11 +4,12 @@ from django.db import migrations
|
||||
def convert_reportmodule_jobs(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Job = apps.get_model('core', 'Job')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
# Convert all ReportModule jobs to ScriptModule jobs
|
||||
if reportmodule_ct := ContentType.objects.filter(app_label='extras', model='reportmodule').first():
|
||||
scriptmodule_ct = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
Job.objects.filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id)
|
||||
if reportmodule_ct := ContentType.objects.using(db_alias).filter(app_label='extras', model='reportmodule').first():
|
||||
scriptmodule_ct = ContentType.objects.using(db_alias).get(app_label='extras', model='scriptmodule')
|
||||
Job.objects.using(db_alias).filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -88,24 +88,33 @@ def update_scripts(apps, schema_editor):
|
||||
ScriptModule = apps.get_model('extras', 'ScriptModule')
|
||||
ReportModule = apps.get_model('extras', 'ReportModule')
|
||||
Job = apps.get_model('core', 'Job')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
|
||||
scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule, for_concrete_model=False)
|
||||
reportmodule_ct = ContentType.objects.get_for_model(ReportModule, for_concrete_model=False)
|
||||
|
||||
for module in ScriptModule.objects.all():
|
||||
for module in ScriptModule.objects.using(db_alias).all():
|
||||
for script_name in get_module_scripts(module):
|
||||
script = Script.objects.create(
|
||||
script = Script.objects.using(db_alias).create(
|
||||
name=script_name,
|
||||
module=module,
|
||||
)
|
||||
|
||||
# Update all Jobs associated with this ScriptModule & script name to point to the new Script object
|
||||
Job.objects.filter(object_type_id=scriptmodule_ct.id, object_id=module.pk, name=script_name).update(
|
||||
Job.objects.using(db_alias).filter(
|
||||
object_type_id=scriptmodule_ct.id,
|
||||
object_id=module.pk,
|
||||
name=script_name
|
||||
).update(
|
||||
object_type_id=script_ct.id, object_id=script.pk
|
||||
)
|
||||
# Update all Jobs associated with this ScriptModule & script name to point to the new Script object
|
||||
Job.objects.filter(object_type_id=reportmodule_ct.id, object_id=module.pk, name=script_name).update(
|
||||
Job.objects.using(db_alias).filter(
|
||||
object_type_id=reportmodule_ct.id,
|
||||
object_id=module.pk,
|
||||
name=script_name
|
||||
).update(
|
||||
object_type_id=script_ct.id, object_id=script.pk
|
||||
)
|
||||
|
||||
@@ -119,16 +128,22 @@ def update_event_rules(apps, schema_editor):
|
||||
Script = apps.get_model('extras', 'Script')
|
||||
ScriptModule = apps.get_model('extras', 'ScriptModule')
|
||||
EventRule = apps.get_model('extras', 'EventRule')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
script_ct = ContentType.objects.get_for_model(Script)
|
||||
scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
|
||||
|
||||
for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct):
|
||||
for eventrule in EventRule.objects.using(db_alias).filter(action_object_type=scriptmodule_ct):
|
||||
name = eventrule.action_parameters.get('script_name')
|
||||
obj, __ = Script.objects.get_or_create(
|
||||
module_id=eventrule.action_object_id, name=name, defaults={'is_executable': False}
|
||||
obj, __ = Script.objects.using(db_alias).get_or_create(
|
||||
module_id=eventrule.action_object_id,
|
||||
name=name,
|
||||
defaults={'is_executable': False}
|
||||
)
|
||||
EventRule.objects.using(db_alias).filter(pk=eventrule.pk).update(
|
||||
action_object_type=script_ct,
|
||||
action_object_id=obj.id
|
||||
)
|
||||
EventRule.objects.filter(pk=eventrule.pk).update(action_object_type=script_ct, action_object_id=obj.id)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# 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')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for dashboard in Dashboard.objects.all():
|
||||
for dashboard in Dashboard.objects.using(db_alias).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))
|
||||
|
||||
@@ -3,7 +3,9 @@ from django.db import migrations, models
|
||||
|
||||
def update_link_buttons(apps, schema_editor):
|
||||
CustomLink = apps.get_model('extras', 'CustomLink')
|
||||
CustomLink.objects.filter(button_class='outline-dark').update(button_class='default')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
CustomLink.objects.using(db_alias).filter(button_class='outline-dark').update(button_class='default')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -3,19 +3,21 @@ from django.db import migrations
|
||||
|
||||
def update_content_types(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
# Delete the new ContentTypes effected by the new model in the core app
|
||||
ContentType.objects.filter(app_label='core', model='objectchange').delete()
|
||||
ContentType.objects.using(db_alias).filter(app_label='core', model='objectchange').delete()
|
||||
|
||||
# Update the app labels of the original ContentTypes for extras.ObjectChange to ensure that any
|
||||
# foreign key references are preserved
|
||||
ContentType.objects.filter(app_label='extras', model='objectchange').update(app_label='core')
|
||||
ContentType.objects.using(db_alias).filter(app_label='extras', model='objectchange').update(app_label='core')
|
||||
|
||||
|
||||
def update_dashboard_widgets(apps, schema_editor):
|
||||
Dashboard = apps.get_model('extras', 'Dashboard')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for dashboard in Dashboard.objects.all():
|
||||
for dashboard in Dashboard.objects.using(db_alias).all():
|
||||
for key, widget in dashboard.config.items():
|
||||
if widget['config'].get('model') == 'extras.objectchange':
|
||||
widget['config']['model'] = 'core.objectchange'
|
||||
|
||||
@@ -6,8 +6,9 @@ from core.events import *
|
||||
|
||||
def set_event_types(apps, schema_editor):
|
||||
EventRule = apps.get_model('extras', 'EventRule')
|
||||
event_rules = EventRule.objects.all()
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
event_rules = EventRule.objects.using(db_alias).all()
|
||||
for event_rule in event_rules:
|
||||
event_rule.event_types = []
|
||||
if event_rule.type_create:
|
||||
|
||||
@@ -6,8 +6,9 @@ def set_null_values(apps, schema_editor):
|
||||
Replace empty strings with null values.
|
||||
"""
|
||||
CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
CustomFieldChoiceSet.objects.filter(base_choices='').update(base_choices=None)
|
||||
CustomFieldChoiceSet.objects.using(db_alias).filter(base_choices='').update(base_choices=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -8,7 +8,9 @@ def set_kind_default(apps, schema_editor):
|
||||
Set kind to "info" on any entries with no kind assigned.
|
||||
"""
|
||||
JournalEntry = apps.get_model('extras', 'JournalEntry')
|
||||
JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
JournalEntry.objects.using(db_alias).filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0014_remove_redundant_indexes'),
|
||||
('core', '0015_remove_redundant_indexes'),
|
||||
('extras', '0127_configtemplate_as_attachment_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
@@ -123,7 +123,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
ordered = [
|
||||
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
|
||||
]
|
||||
ordered.extend(script_objects.items())
|
||||
ordered.extend(script_objects.values())
|
||||
return ordered
|
||||
|
||||
@property
|
||||
|
||||
@@ -22,8 +22,6 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects
|
||||
"""
|
||||
|
||||
role = obj.role
|
||||
|
||||
# Device type and location assignment is relevant only for Devices
|
||||
device_type = getattr(obj, 'device_type', None)
|
||||
location = getattr(obj, 'location', None)
|
||||
@@ -44,13 +42,16 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
sitegroup = getattr(obj.site, 'group', None)
|
||||
sitegroups = sitegroup.get_ancestors(include_self=True) if sitegroup else []
|
||||
|
||||
# Match against the directly assigned role as well as any parent roles.
|
||||
device_roles = obj.role.get_ancestors(include_self=True) if obj.role else []
|
||||
|
||||
queryset = self.filter(
|
||||
Q(regions__in=regions) | Q(regions=None),
|
||||
Q(site_groups__in=sitegroups) | Q(site_groups=None),
|
||||
Q(sites=obj.site) | Q(sites=None),
|
||||
Q(locations=location) | Q(locations=None),
|
||||
Q(device_types=device_type) | Q(device_types=None),
|
||||
Q(roles=role) | Q(roles=None),
|
||||
Q(roles__in=device_roles) | Q(roles=None),
|
||||
Q(platforms=obj.platform) | Q(platforms=None),
|
||||
Q(cluster_types=cluster_type) | Q(cluster_types=None),
|
||||
Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
|
||||
@@ -107,6 +108,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
|
||||
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
|
||||
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
|
||||
Q(sites=OuterRef('site')) | Q(sites=None),
|
||||
Q(
|
||||
tags__pk__in=Subquery(
|
||||
TaggedItem.objects.filter(
|
||||
@@ -128,9 +130,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
base_query.add(Q(locations=None), Q.AND)
|
||||
base_query.add(Q(device_types=None), Q.AND)
|
||||
|
||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
|
||||
|
||||
# MPTT-based filters
|
||||
base_query.add(
|
||||
(Q(
|
||||
regions__tree_id=OuterRef('site__region__tree_id'),
|
||||
@@ -140,7 +140,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
) | Q(regions=None)),
|
||||
Q.AND
|
||||
)
|
||||
|
||||
base_query.add(
|
||||
(Q(
|
||||
site_groups__tree_id=OuterRef('site__group__tree_id'),
|
||||
@@ -150,6 +149,15 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
) | Q(site_groups=None)),
|
||||
Q.AND
|
||||
)
|
||||
base_query.add(
|
||||
(Q(
|
||||
roles__tree_id=OuterRef('role__tree_id'),
|
||||
roles__level__lte=OuterRef('role__level'),
|
||||
roles__lft__lte=OuterRef('role__lft'),
|
||||
roles__rght__gte=OuterRef('role__rght'),
|
||||
) | Q(roles=None)),
|
||||
Q.AND
|
||||
)
|
||||
|
||||
return base_query
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ class JournalEntryIndex(SearchIndex):
|
||||
display_attrs = ('kind', 'created_by')
|
||||
|
||||
|
||||
@register_search
|
||||
class TagIndex(SearchIndex):
|
||||
model = models.Tag
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
class WebhookEntryIndex(SearchIndex):
|
||||
model = models.Webhook
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.models import *
|
||||
from core.tables import JobTable
|
||||
from core.models import Job
|
||||
from netbox.constants import EMPTY_TABLE_TEXT
|
||||
from netbox.events import get_event_text
|
||||
from netbox.tables import BaseTable, NetBoxTable, columns
|
||||
@@ -26,6 +28,7 @@ __all__ = (
|
||||
'SavedFilterTable',
|
||||
'ReportResultsTable',
|
||||
'ScriptResultsTable',
|
||||
'ScriptJobTable',
|
||||
'SubscriptionTable',
|
||||
'TableConfigTable',
|
||||
'TaggedItemTable',
|
||||
@@ -693,6 +696,23 @@ class ScriptResultsTable(BaseTable):
|
||||
return format_html("<a href='{}'>{}</a>", value, value)
|
||||
|
||||
|
||||
class ScriptJobTable(JobTable):
|
||||
id = tables.TemplateColumn(
|
||||
template_code="""<a href="{% url 'extras:script_result' job_pk=record.pk %}">{{ record.id }}</a>""",
|
||||
verbose_name=_('ID'),
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Job
|
||||
fields = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
||||
'completed', 'user', 'error', 'job_id',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||
)
|
||||
|
||||
|
||||
class ReportResultsTable(BaseTable):
|
||||
index = tables.Column(
|
||||
verbose_name=_('Line')
|
||||
|
||||
@@ -14,7 +14,6 @@ from jinja2.exceptions import TemplateError
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from dcim.models import Device, DeviceRole, Platform
|
||||
from extras.choices import LogLevelChoices
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
@@ -36,7 +35,7 @@ from virtualization.models import VirtualMachine
|
||||
from . import filtersets, forms, tables
|
||||
from .constants import LOG_LEVEL_RANK
|
||||
from .models import *
|
||||
from .tables import ReportResultsTable, ScriptResultsTable
|
||||
from .tables import ReportResultsTable, ScriptResultsTable, ScriptJobTable
|
||||
|
||||
|
||||
#
|
||||
@@ -83,7 +82,7 @@ class CustomFieldDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
|
||||
|
||||
@register_model_view(CustomField, 'bulk_import', detail=False)
|
||||
@register_model_view(CustomField, 'bulk_import', path='import', detail=False)
|
||||
class CustomFieldBulkImportView(generic.BulkImportView):
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
model_form = forms.CustomFieldImportForm
|
||||
@@ -152,7 +151,7 @@ class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CustomFieldChoiceSet, 'bulk_import', detail=False)
|
||||
@register_model_view(CustomFieldChoiceSet, 'bulk_import', path='import', detail=False)
|
||||
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
model_form = forms.CustomFieldChoiceSetImportForm
|
||||
@@ -202,7 +201,7 @@ class CustomLinkDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CustomLink.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CustomLink, 'bulk_import', detail=False)
|
||||
@register_model_view(CustomLink, 'bulk_import', path='import', detail=False)
|
||||
class CustomLinkBulkImportView(generic.BulkImportView):
|
||||
queryset = CustomLink.objects.all()
|
||||
model_form = forms.CustomLinkImportForm
|
||||
@@ -257,7 +256,7 @@ class ExportTemplateDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ExportTemplate, 'bulk_import', detail=False)
|
||||
@register_model_view(ExportTemplate, 'bulk_import', path='import', detail=False)
|
||||
class ExportTemplateBulkImportView(generic.BulkImportView):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
model_form = forms.ExportTemplateImportForm
|
||||
@@ -317,7 +316,7 @@ class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
|
||||
queryset = SavedFilter.objects.all()
|
||||
|
||||
|
||||
@register_model_view(SavedFilter, 'bulk_import', detail=False)
|
||||
@register_model_view(SavedFilter, 'bulk_import', path='import', detail=False)
|
||||
class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView):
|
||||
queryset = SavedFilter.objects.all()
|
||||
model_form = forms.SavedFilterImportForm
|
||||
@@ -457,7 +456,7 @@ class NotificationGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'bulk_import', detail=False)
|
||||
@register_model_view(NotificationGroup, 'bulk_import', path='import', detail=False)
|
||||
class NotificationGroupBulkImportView(generic.BulkImportView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
model_form = forms.NotificationGroupImportForm
|
||||
@@ -603,7 +602,7 @@ class WebhookDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Webhook.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Webhook, 'bulk_import', detail=False)
|
||||
@register_model_view(Webhook, 'bulk_import', path='import', detail=False)
|
||||
class WebhookBulkImportView(generic.BulkImportView):
|
||||
queryset = Webhook.objects.all()
|
||||
model_form = forms.WebhookImportForm
|
||||
@@ -653,7 +652,7 @@ class EventRuleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = EventRule.objects.all()
|
||||
|
||||
|
||||
@register_model_view(EventRule, 'bulk_import', detail=False)
|
||||
@register_model_view(EventRule, 'bulk_import', path='import', detail=False)
|
||||
class EventRuleBulkImportView(generic.BulkImportView):
|
||||
queryset = EventRule.objects.all()
|
||||
model_form = forms.EventRuleImportForm
|
||||
@@ -726,7 +725,7 @@ class TagDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Tag.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Tag, 'bulk_import', detail=False)
|
||||
@register_model_view(Tag, 'bulk_import', path='import', detail=False)
|
||||
class TagBulkImportView(generic.BulkImportView):
|
||||
queryset = Tag.objects.all()
|
||||
model_form = forms.TagImportForm
|
||||
@@ -902,7 +901,7 @@ class ConfigTemplateDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate, 'bulk_import', detail=False)
|
||||
@register_model_view(ConfigTemplate, 'bulk_import', path='import', detail=False)
|
||||
class ConfigTemplateBulkImportView(generic.BulkImportView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
model_form = forms.ConfigTemplateImportForm
|
||||
@@ -1081,7 +1080,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
|
||||
return reverse(viewname, kwargs={'pk': obj.pk})
|
||||
|
||||
|
||||
@register_model_view(JournalEntry, 'bulk_import', detail=False)
|
||||
@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False)
|
||||
class JournalEntryBulkImportView(generic.BulkImportView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
model_form = forms.JournalEntryImportForm
|
||||
@@ -1393,7 +1392,7 @@ class ScriptJobsView(BaseScriptView):
|
||||
def get(self, request, **kwargs):
|
||||
script = self.get_object(**kwargs)
|
||||
|
||||
jobs_table = JobTable(
|
||||
jobs_table = ScriptJobTable(
|
||||
data=script.jobs.all(),
|
||||
orderable=False,
|
||||
user=request.user
|
||||
|
||||
@@ -147,8 +147,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant',
|
||||
'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
'mark_populated', 'mark_utilized',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class VLANSerializer(NetBoxModelSerializer):
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
status = ChoiceField(choices=VLANStatusChoices, required=False)
|
||||
role = RoleSerializer(nested=True, required=False, allow_null=True)
|
||||
qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False)
|
||||
qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False, allow_null=True)
|
||||
qinq_svlan = NestedVLANSerializer(required=False, allow_null=True, default=None)
|
||||
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib.contenttypes.prefetch import GenericPrefetch
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -13,6 +14,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from dcim.models import Interface
|
||||
from ipam import filtersets
|
||||
from ipam.models import *
|
||||
from ipam.utils import get_next_available_prefix
|
||||
@@ -21,6 +23,7 @@ from netbox.api.viewsets.mixins import ObjectValidationMixin
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.models import VMInterface
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -79,7 +82,7 @@ class RoleViewSet(NetBoxModelViewSet):
|
||||
|
||||
|
||||
class PrefixViewSet(NetBoxModelViewSet):
|
||||
queryset = Prefix.objects.all()
|
||||
queryset = Prefix.objects.prefetch_related("scope")
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filtersets.PrefixFilterSet
|
||||
|
||||
@@ -100,7 +103,17 @@ class IPRangeViewSet(NetBoxModelViewSet):
|
||||
|
||||
|
||||
class IPAddressViewSet(NetBoxModelViewSet):
|
||||
queryset = IPAddress.objects.all()
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
GenericPrefetch(
|
||||
"assigned_object",
|
||||
[
|
||||
# serializers are taken according to IPADDRESS_ASSIGNMENT_MODELS
|
||||
FHRPGroup.objects.all(),
|
||||
Interface.objects.select_related("cable", "device"),
|
||||
VMInterface.objects.select_related("virtual_machine"),
|
||||
],
|
||||
),
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filtersets.IPAddressFilterSet
|
||||
|
||||
|
||||
@@ -1256,8 +1256,20 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip4 = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4__address',
|
||||
queryset=IPAddress.objects.all(),
|
||||
to_field_name='address',
|
||||
label=_('Primary IPv4 (address)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
primary_ip6 = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6__address',
|
||||
queryset=IPAddress.objects.all(),
|
||||
to_field_name='address',
|
||||
label=_('Primary IPv6 (address)'),
|
||||
)
|
||||
|
||||
@@ -864,6 +864,7 @@ class ServiceCreateForm(ServiceForm):
|
||||
# Fields which may be populated from a ServiceTemplate are not required
|
||||
for field in ('name', 'protocol', 'ports'):
|
||||
self.fields[field].required = False
|
||||
self.fields[field].widget.is_required = False
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -168,9 +168,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
|
||||
status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
|
||||
@@ -11,7 +11,9 @@ def set_vid_ranges(apps, schema_editor):
|
||||
Convert the min_vid & max_vid fields to a range in the new vid_ranges ArrayField.
|
||||
"""
|
||||
VLANGroup = apps.get_model('ipam', 'VLANGroup')
|
||||
for group in VLANGroup.objects.all():
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for group in VLANGroup.objects.using(db_alias).all():
|
||||
group.vid_ranges = [NumericRange(group.min_vid, group.max_vid, bounds='[]')]
|
||||
group._total_vlan_ids = group.max_vid - group.min_vid + 1
|
||||
group.save()
|
||||
|
||||
@@ -9,9 +9,11 @@ def copy_site_assignments(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Prefix = apps.get_model('ipam', 'Prefix')
|
||||
Site = apps.get_model('dcim', 'Site')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
Prefix.objects.filter(site__isnull=False).update(
|
||||
scope_type=ContentType.objects.get_for_model(Site), scope_id=models.F('site_id')
|
||||
Prefix.objects.using(db_alias).filter(site__isnull=False).update(
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
scope_id=models.F('site_id')
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,15 +7,16 @@ def populate_denormalized_fields(apps, schema_editor):
|
||||
Copy site ForeignKey values to the scope GFK.
|
||||
"""
|
||||
Prefix = apps.get_model('ipam', 'Prefix')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
|
||||
prefixes = Prefix.objects.using(db_alias).filter(site__isnull=False).prefetch_related('site')
|
||||
for prefix in prefixes:
|
||||
prefix._region_id = prefix.site.region_id
|
||||
prefix._site_group_id = prefix.site.group_id
|
||||
prefix._site_id = prefix.site_id
|
||||
# Note: Location cannot be set prior to migration
|
||||
|
||||
Prefix.objects.bulk_update(prefixes, ['_region', '_site_group', '_site'], batch_size=100)
|
||||
Prefix.objects.using(db_alias).bulk_update(prefixes, ['_region', '_site_group', '_site'], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -7,9 +7,10 @@ def set_null_values(apps, schema_editor):
|
||||
"""
|
||||
FHRPGroup = apps.get_model('ipam', 'FHRPGroup')
|
||||
IPAddress = apps.get_model('ipam', 'IPAddress')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
FHRPGroup.objects.filter(auth_type='').update(auth_type=None)
|
||||
IPAddress.objects.filter(role='').update(role=None)
|
||||
FHRPGroup.objects.using(db_alias).filter(auth_type='').update(auth_type=None)
|
||||
IPAddress.objects.using(db_alias).filter(role='').update(role=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,36 +2,38 @@ from django.db import migrations
|
||||
from django.db.models import F
|
||||
|
||||
|
||||
def populate_service_parent_gfk(apps, schema_config):
|
||||
def populate_service_parent_gfk(apps, schema_editor):
|
||||
Service = apps.get_model('ipam', 'Service')
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Device = apps.get_model('dcim', 'device')
|
||||
VirtualMachine = apps.get_model('virtualization', 'virtualmachine')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
Service.objects.filter(device_id__isnull=False).update(
|
||||
Service.objects.using(db_alias).filter(device_id__isnull=False).update(
|
||||
parent_object_type=ContentType.objects.get_for_model(Device),
|
||||
parent_object_id=F('device_id'),
|
||||
)
|
||||
|
||||
Service.objects.filter(virtual_machine_id__isnull=False).update(
|
||||
Service.objects.using(db_alias).filter(virtual_machine_id__isnull=False).update(
|
||||
parent_object_type=ContentType.objects.get_for_model(VirtualMachine),
|
||||
parent_object_id=F('virtual_machine_id'),
|
||||
)
|
||||
|
||||
|
||||
def repopulate_device_and_virtualmachine_relations(apps, schemaconfig):
|
||||
def repopulate_device_and_virtualmachine_relations(apps, schema_editor):
|
||||
Service = apps.get_model('ipam', 'Service')
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Device = apps.get_model('dcim', 'device')
|
||||
VirtualMachine = apps.get_model('virtualization', 'virtualmachine')
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
Service.objects.filter(
|
||||
Service.objects.using(db_alias).filter(
|
||||
parent_object_type=ContentType.objects.get_for_model(Device),
|
||||
).update(
|
||||
device_id=F('parent_object_id')
|
||||
)
|
||||
|
||||
Service.objects.filter(
|
||||
Service.objects.using(db_alias).filter(
|
||||
parent_object_type=ContentType.objects.get_for_model(VirtualMachine),
|
||||
).update(
|
||||
virtual_machine_id=F('parent_object_id')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@@ -6,7 +7,7 @@ from django.db import models
|
||||
from django.db.backends.postgresql.psycopg_any import NumericRange
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Interface
|
||||
from dcim.models import Interface, Site, SiteGroup
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
|
||||
@@ -285,12 +286,20 @@ class VLAN(PrimaryModel):
|
||||
super().clean()
|
||||
|
||||
# Validate VLAN group (if assigned)
|
||||
if self.group and self.site and self.group.scope != self.site:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
|
||||
).format(group=self.group, scope=self.group.scope, site=self.site)
|
||||
)
|
||||
if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(Site):
|
||||
if self.site != self.group.scope:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
|
||||
).format(group=self.group, scope=self.group.scope, site=self.site)
|
||||
)
|
||||
if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(SiteGroup):
|
||||
if self.site not in self.group.scope.sites.all():
|
||||
raise ValidationError(
|
||||
_(
|
||||
"The assigned site {site} is not a member of the assigned group {group} (scope: {scope})."
|
||||
).format(group=self.group, scope=self.group.scope, site=self.site)
|
||||
)
|
||||
|
||||
# Check that the VLAN ID is permitted in the assigned group (if any)
|
||||
if self.group:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
from netaddr import IPNetwork, IPSet
|
||||
from utilities.data import string_to_ranges
|
||||
|
||||
from dcim.models import Site, SiteGroup
|
||||
from ipam.choices import *
|
||||
from ipam.models import *
|
||||
|
||||
@@ -679,3 +681,54 @@ class TestVLAN(TestCase):
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
vlan.full_clean()
|
||||
|
||||
def test_vlan_group_site_validation(self):
|
||||
sitegroup = SiteGroup.objects.create(
|
||||
name='Site Group 1',
|
||||
slug='site-group-1',
|
||||
)
|
||||
sites = Site.objects.bulk_create((
|
||||
Site(
|
||||
name='Site 1',
|
||||
slug='site-1',
|
||||
),
|
||||
Site(
|
||||
name='Site 2',
|
||||
slug='site-2',
|
||||
),
|
||||
))
|
||||
sitegroup.sites.add(sites[0])
|
||||
vlangroups = VLANGroup.objects.bulk_create((
|
||||
VLANGroup(
|
||||
name='VLAN Group 1',
|
||||
slug='vlan-group-1',
|
||||
scope=sitegroup,
|
||||
scope_type=ContentType.objects.get_for_model(SiteGroup),
|
||||
),
|
||||
VLANGroup(
|
||||
name='VLAN Group 2',
|
||||
slug='vlan-group-2',
|
||||
scope=sites[0],
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
),
|
||||
VLANGroup(
|
||||
name='VLAN Group 2',
|
||||
slug='vlan-group-2',
|
||||
scope=sites[1],
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
),
|
||||
))
|
||||
vlan = VLAN(
|
||||
name='VLAN 1',
|
||||
vid=1,
|
||||
group=vlangroups[0],
|
||||
site=sites[0],
|
||||
)
|
||||
|
||||
# VLAN Group 1 and 2 should be valid
|
||||
vlan.full_clean()
|
||||
vlan.group = vlangroups[1]
|
||||
vlan.full_clean()
|
||||
vlan.group = vlangroups[2]
|
||||
with self.assertRaises(ValidationError):
|
||||
vlan.full_clean()
|
||||
|
||||
@@ -45,10 +45,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
instance.import_targets.all(),
|
||||
orderable=False
|
||||
)
|
||||
import_targets_table.configure(request)
|
||||
|
||||
export_targets_table = tables.RouteTargetTable(
|
||||
instance.export_targets.all(),
|
||||
orderable=False
|
||||
)
|
||||
export_targets_table.configure(request)
|
||||
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
|
||||
@@ -69,7 +72,7 @@ class VRFDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VRF.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VRF, 'bulk_import', detail=False)
|
||||
@register_model_view(VRF, 'bulk_import', path='import', detail=False)
|
||||
class VRFBulkImportView(generic.BulkImportView):
|
||||
queryset = VRF.objects.all()
|
||||
model_form = forms.VRFImportForm
|
||||
@@ -119,7 +122,7 @@ class RouteTargetDeleteView(generic.ObjectDeleteView):
|
||||
queryset = RouteTarget.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RouteTarget, 'bulk_import', detail=False)
|
||||
@register_model_view(RouteTarget, 'bulk_import', path='import', detail=False)
|
||||
class RouteTargetBulkImportView(generic.BulkImportView):
|
||||
queryset = RouteTarget.objects.all()
|
||||
model_form = forms.RouteTargetImportForm
|
||||
@@ -176,7 +179,7 @@ class RIRDeleteView(generic.ObjectDeleteView):
|
||||
queryset = RIR.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RIR, 'bulk_import', detail=False)
|
||||
@register_model_view(RIR, 'bulk_import', path='import', detail=False)
|
||||
class RIRBulkImportView(generic.BulkImportView):
|
||||
queryset = RIR.objects.all()
|
||||
model_form = forms.RIRImportForm
|
||||
@@ -251,7 +254,7 @@ class ASNRangeDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ASNRange.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ASNRange, 'bulk_import', detail=False)
|
||||
@register_model_view(ASNRange, 'bulk_import', path='import', detail=False)
|
||||
class ASNRangeBulkImportView(generic.BulkImportView):
|
||||
queryset = ASNRange.objects.all()
|
||||
model_form = forms.ASNRangeImportForm
|
||||
@@ -316,7 +319,7 @@ class ASNDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ASN.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ASN, 'bulk_import', detail=False)
|
||||
@register_model_view(ASN, 'bulk_import', path='import', detail=False)
|
||||
class ASNBulkImportView(generic.BulkImportView):
|
||||
queryset = ASN.objects.all()
|
||||
model_form = forms.ASNImportForm
|
||||
@@ -408,7 +411,7 @@ class AggregateDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Aggregate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Aggregate, 'bulk_import', detail=False)
|
||||
@register_model_view(Aggregate, 'bulk_import', path='import', detail=False)
|
||||
class AggregateBulkImportView(generic.BulkImportView):
|
||||
queryset = Aggregate.objects.all()
|
||||
model_form = forms.AggregateImportForm
|
||||
@@ -471,7 +474,7 @@ class RoleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Role.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Role, 'bulk_import', detail=False)
|
||||
@register_model_view(Role, 'bulk_import', path='import', detail=False)
|
||||
class RoleBulkImportView(generic.BulkImportView):
|
||||
queryset = Role.objects.all()
|
||||
model_form = forms.RoleImportForm
|
||||
@@ -530,6 +533,7 @@ class PrefixView(generic.ObjectView):
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
parent_prefix_table.configure(request)
|
||||
|
||||
# Duplicate prefixes table
|
||||
duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
@@ -544,6 +548,7 @@ class PrefixView(generic.ObjectView):
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
duplicate_prefix_table.configure(request)
|
||||
|
||||
return {
|
||||
'aggregate': aggregate,
|
||||
@@ -657,7 +662,7 @@ class PrefixDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Prefix.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Prefix, 'bulk_import', detail=False)
|
||||
@register_model_view(Prefix, 'bulk_import', path='import', detail=False)
|
||||
class PrefixBulkImportView(generic.BulkImportView):
|
||||
queryset = Prefix.objects.all()
|
||||
model_form = forms.PrefixImportForm
|
||||
@@ -709,6 +714,7 @@ class IPRangeView(generic.ObjectView):
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
parent_prefixes_table.configure(request)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
@@ -746,7 +752,7 @@ class IPRangeDeleteView(generic.ObjectDeleteView):
|
||||
queryset = IPRange.objects.all()
|
||||
|
||||
|
||||
@register_model_view(IPRange, 'bulk_import', detail=False)
|
||||
@register_model_view(IPRange, 'bulk_import', path='import', detail=False)
|
||||
class IPRangeBulkImportView(generic.BulkImportView):
|
||||
queryset = IPRange.objects.all()
|
||||
model_form = forms.IPRangeImportForm
|
||||
@@ -796,6 +802,7 @@ class IPAddressView(generic.ObjectView):
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
parent_prefixes_table.configure(request)
|
||||
|
||||
# Duplicate IPs table
|
||||
duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
|
||||
@@ -811,6 +818,7 @@ class IPAddressView(generic.ObjectView):
|
||||
duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
|
||||
# Limit to a maximum of 10 duplicates displayed here
|
||||
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
|
||||
duplicate_ips_table.configure(request)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
@@ -888,6 +896,7 @@ class IPAddressAssignView(generic.ObjectView):
|
||||
# Limit to 100 results
|
||||
addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
|
||||
table = tables.IPAddressAssignTable(addresses)
|
||||
table.configure(request)
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'form': form,
|
||||
@@ -910,7 +919,7 @@ class IPAddressBulkCreateView(generic.BulkCreateView):
|
||||
template_name = 'ipam/ipaddress_bulk_add.html'
|
||||
|
||||
|
||||
@register_model_view(IPAddress, 'bulk_import', detail=False)
|
||||
@register_model_view(IPAddress, 'bulk_import', path='import', detail=False)
|
||||
class IPAddressBulkImportView(generic.BulkImportView):
|
||||
queryset = IPAddress.objects.all()
|
||||
model_form = forms.IPAddressImportForm
|
||||
@@ -983,7 +992,7 @@ class VLANGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VLANGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VLANGroup, 'bulk_import', detail=False)
|
||||
@register_model_view(VLANGroup, 'bulk_import', path='import', detail=False)
|
||||
class VLANGroupBulkImportView(generic.BulkImportView):
|
||||
queryset = VLANGroup.objects.all()
|
||||
model_form = forms.VLANGroupImportForm
|
||||
@@ -1053,6 +1062,8 @@ class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
data=instance.rules.all(),
|
||||
orderable=False
|
||||
)
|
||||
vlan_translation_table.configure(request)
|
||||
|
||||
return {
|
||||
'vlan_translation_table': vlan_translation_table,
|
||||
}
|
||||
@@ -1070,7 +1081,7 @@ class VLANTranslationPolicyDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VLANTranslationPolicy.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationPolicy, 'bulk_import', detail=False)
|
||||
@register_model_view(VLANTranslationPolicy, 'bulk_import', path='import', detail=False)
|
||||
class VLANTranslationPolicyBulkImportView(generic.BulkImportView):
|
||||
queryset = VLANTranslationPolicy.objects.all()
|
||||
model_form = forms.VLANTranslationPolicyImportForm
|
||||
@@ -1125,7 +1136,7 @@ class VLANTranslationRuleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VLANTranslationRule.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationRule, 'bulk_import', detail=False)
|
||||
@register_model_view(VLANTranslationRule, 'bulk_import', path='import', detail=False)
|
||||
class VLANTranslationRuleBulkImportView(generic.BulkImportView):
|
||||
queryset = VLANTranslationRule.objects.all()
|
||||
model_form = forms.VLANTranslationRuleImportForm
|
||||
@@ -1170,6 +1181,7 @@ class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance),
|
||||
orderable=False
|
||||
)
|
||||
members_table.configure(request)
|
||||
members_table.columns.hide('group')
|
||||
|
||||
return {
|
||||
@@ -1218,7 +1230,7 @@ class FHRPGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(FHRPGroup, 'bulk_import', detail=False)
|
||||
@register_model_view(FHRPGroup, 'bulk_import', path='import', detail=False)
|
||||
class FHRPGroupBulkImportView(generic.BulkImportView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
model_form = forms.FHRPGroupImportForm
|
||||
@@ -1289,6 +1301,7 @@ class VLANView(generic.ObjectView):
|
||||
'vrf', 'scope', 'role', 'tenant'
|
||||
)
|
||||
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
|
||||
prefix_table.configure(request)
|
||||
|
||||
return {
|
||||
'prefix_table': prefix_table,
|
||||
@@ -1344,7 +1357,7 @@ class VLANDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VLAN.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VLAN, 'bulk_import', detail=False)
|
||||
@register_model_view(VLAN, 'bulk_import', path='import', detail=False)
|
||||
class VLANBulkImportView(generic.BulkImportView):
|
||||
queryset = VLAN.objects.all()
|
||||
model_form = forms.VLANImportForm
|
||||
@@ -1394,7 +1407,7 @@ class ServiceTemplateDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ServiceTemplate, 'bulk_import', detail=False)
|
||||
@register_model_view(ServiceTemplate, 'bulk_import', path='import', detail=False)
|
||||
class ServiceTemplateBulkImportView(generic.BulkImportView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
model_form = forms.ServiceTemplateImportForm
|
||||
@@ -1461,7 +1474,7 @@ class ServiceDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Service.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Service, 'bulk_import', detail=False)
|
||||
@register_model_view(Service, 'bulk_import', path='import', detail=False)
|
||||
class ServiceBulkImportView(generic.BulkImportView):
|
||||
queryset = Service.objects.all()
|
||||
model_form = forms.ServiceImportForm
|
||||
|
||||
@@ -14,9 +14,9 @@ ORGANIZATION_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Sites'),
|
||||
items=(
|
||||
get_model_item('dcim', 'site', _('Sites')),
|
||||
get_model_item('dcim', 'region', _('Regions')),
|
||||
get_model_item('dcim', 'sitegroup', _('Site Groups')),
|
||||
get_model_item('dcim', 'site', _('Sites')),
|
||||
get_model_item('dcim', 'location', _('Locations')),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -442,7 +442,7 @@ INSTALLED_APPS = [
|
||||
'drf_spectacular',
|
||||
'drf_spectacular_sidecar',
|
||||
]
|
||||
if not DEBUG:
|
||||
if not DEBUG and 'collectstatic' not in sys.argv:
|
||||
INSTALLED_APPS.remove('debug_toolbar')
|
||||
|
||||
# Middleware
|
||||
|
||||
@@ -165,11 +165,10 @@ class BaseTable(tables.Table):
|
||||
ordering = userconfig.get(f"tables.{self.name}.ordering")
|
||||
|
||||
# Fall back to the default columns & ordering
|
||||
if columns is None and hasattr(settings, 'DEFAULT_USER_PREFERENCES'):
|
||||
columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns')
|
||||
if columns is None:
|
||||
if hasattr(settings, 'DEFAULT_USER_PREFERENCES'):
|
||||
columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns')
|
||||
else:
|
||||
columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
|
||||
columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
|
||||
|
||||
self._set_columns(columns)
|
||||
if ordering is not None:
|
||||
|
||||
@@ -29,6 +29,7 @@ from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import reapply_model_ordering
|
||||
from utilities.request import safe_for_redirect
|
||||
from utilities.tables import get_table_configs
|
||||
from utilities.views import GetReturnURLMixin, get_viewname
|
||||
from .base import BaseMultiObjectView
|
||||
@@ -121,7 +122,10 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
# Strip the `export` param and redirect user to the filtered objects list
|
||||
query_params = request.GET.copy()
|
||||
query_params.pop('export')
|
||||
return redirect(f'{request.path}?{query_params.urlencode()}')
|
||||
redirect_url = f'{request.path}?{query_params.urlencode()}'
|
||||
if safe_for_redirect(redirect_url):
|
||||
return redirect(redirect_url)
|
||||
return redirect(get_viewname(self.queryset.model, 'list'))
|
||||
|
||||
#
|
||||
# Request handlers
|
||||
@@ -286,7 +290,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
logger.info(msg)
|
||||
messages.success(request, msg)
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
if '_addanother' in request.POST and safe_for_redirect(request.path):
|
||||
return redirect(request.path)
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from utilities.forms import ConfirmationForm, restrict_form_fields
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.querydict import normalize_querydict, prepare_cloned_fields
|
||||
from utilities.request import safe_for_redirect
|
||||
from utilities.tables import get_table_configs
|
||||
from utilities.views import GetReturnURLMixin, get_viewname
|
||||
from .base import BaseObjectView
|
||||
@@ -317,6 +318,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
if 'return_url' in request.GET:
|
||||
params['return_url'] = request.GET.get('return_url')
|
||||
redirect_url += f"?{params.urlencode()}"
|
||||
if not safe_for_redirect(redirect_url):
|
||||
redirect_url = reverse('home')
|
||||
|
||||
return redirect(redirect_url)
|
||||
|
||||
@@ -583,7 +586,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
))
|
||||
|
||||
# Redirect user on success
|
||||
if '_addanother' in request.POST:
|
||||
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
|
||||
return redirect(request.get_full_path())
|
||||
else:
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
12
netbox/project-static/dist/netbox.js
vendored
12
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -23,14 +23,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
"@tabler/core": "1.0.0",
|
||||
"bootstrap": "5.3.5",
|
||||
"@tabler/core": "1.2.0",
|
||||
"bootstrap": "5.3.6",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "12.0.0",
|
||||
"gridstack": "12.1.2",
|
||||
"htmx.org": "2.0.4",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.87.0",
|
||||
"query-string": "9.1.2",
|
||||
"sass": "1.88.0",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/node": "^22.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"esbuild": "^0.25.3",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"eslint": "<9.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user