Merge branch 'main' into feature

This commit is contained in:
Jeremy Stretch 2025-05-01 09:45:38 -04:00
commit 64b5867cb3
191 changed files with 10296 additions and 9101 deletions

View File

@ -15,7 +15,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.2.8 placeholder: v4.2.9
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -27,7 +27,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.2.8 placeholder: v4.2.9
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -16,7 +16,7 @@ jobs:
if: github.repository == 'netbox-community/netbox' if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v5 - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with: with:
issue-inactive-days: 90 issue-inactive-days: 90
pr-inactive-days: 30 pr-inactive-days: 30

View File

@ -48,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }} run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes - name: Commit changes
uses: EndBug/add-and-commit@v9 uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with: with:
add: 'netbox/translations/' add: 'netbox/translations/'
default_author: github_actions default_author: github_actions

View File

@ -69,7 +69,7 @@ For a complete list of available preferences, log into NetBox and navigate to `/
!!! tip "Dynamic Configuration Parameter" !!! tip "Dynamic Configuration Parameter"
Default: 50 Default: `50`
The default maximum number of objects to display per page within each list of objects. 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" !!! tip "Dynamic Configuration Parameter"
Default: 15 Default: `15`
The default value for the `amperage` field when creating new power feeds. 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" !!! tip "Dynamic Configuration Parameter"
Default: 80 Default: `80`
The default value (percentage) for the `max_utilization` field when creating new power feeds. 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" !!! tip "Dynamic Configuration Parameter"
Default: 120 Default: `120`
The default value for the `voltage` field when creating new power feeds. 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" !!! 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`. 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" !!! tip "Dynamic Configuration Parameter"
Default: 220 Default: `220`
Default width (in pixels) of a unit within a rack elevation. Default width (in pixels) of a unit within a rack elevation.

View File

@ -2,7 +2,7 @@
## DEBUG ## DEBUG
Default: False Default: `False`
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only 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 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 ## 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. 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.

View File

@ -2,7 +2,7 @@
## SENTRY_DSN ## 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: 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 ## SENTRY_ENABLED
Default: False Default: `False`
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). 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 ## 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). 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 ## 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. 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 ## 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). The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).

View File

@ -4,7 +4,7 @@
!!! tip "Dynamic Configuration Parameter" !!! tip "Dynamic Configuration Parameter"
Default: True Default: `True`
Setting this to False will disable the GraphQL API. 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 ## GRAPHQL_MAX_ALIASES
Default: 10 Default: `10`
The maximum number of queries that a GraphQL API request may contain. The maximum number of queries that a GraphQL API request may contain.

View File

@ -55,7 +55,7 @@ Sets content for the top banner in the user interface.
## CENSUS_REPORTING_ENABLED ## CENSUS_REPORTING_ENABLED
Default: True Default: `True`
Enables anonymous census reporting. To opt out of census reporting, set this to False. 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" !!! 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 The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
changes in the database indefinitely. changes in the database indefinitely.
@ -79,7 +79,7 @@ changes in the database indefinitely.
## CHANGELOG_SKIP_EMPTY_CHANGES ## 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. 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" !!! 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. 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" !!! 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. 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" !!! 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. 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" !!! 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`. 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 ## 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. 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" !!! 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. 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 ## 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. 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.

View File

@ -2,7 +2,7 @@
## PLUGINS ## 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. 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 ## 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: 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:

View File

@ -2,7 +2,7 @@
## ALLOW_TOKEN_RETRIEVAL ## ALLOW_TOKEN_RETRIEVAL
Default: False Default: `False`
!!! note !!! note
The default value of this parameter changed from true to false in NetBox v4.3.0. 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 ## 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). 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 ## 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. 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.
@ -162,7 +162,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
## LOGIN_PERSISTENCE ## 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. 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 ## 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). 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 ## 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. The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
@ -210,7 +210,7 @@ The view name or URL to which a user is redirected after logging out.
## SECURE_HSTS_INCLUDE_SUBDOMAINS ## 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. 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 ## 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. 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 ## 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. 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 ## SECURE_SSL_REDIRECT
Default: False Default: `False`
If true, all non-HTTPS requests will be automatically redirected to use HTTPS. 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 ## 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. 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 ## 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. 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.

View File

@ -2,7 +2,7 @@
## BASE_PATH ## 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: 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 ## 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: 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 ## 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. 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 ## 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). 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 ## 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.) 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.)

View File

@ -217,26 +217,34 @@ If we wanted to assign this IP address to a virtual machine interface instead, w
### Brief Format ### 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/ GET /api/ipam/prefixes/13980/
```
```json
{ {
"id": 13980, "id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/", "url": "http://netbox/api/ipam/prefixes/13980/",
"display_url": "http://netbox/api/ipam/prefixes/13980/",
"display": "192.0.2.0/24",
"family": { "family": {
"value": 4, "value": 4,
"label": "IPv4" "label": "IPv4"
}, },
"prefix": "192.0.2.0/24", "prefix": "192.0.2.0/24",
"site": {
"id": 3,
"url": "http://netbox/api/dcim/sites/17/",
"name": "Site 23A",
"slug": "site-23a"
},
"vrf": null, "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, "tenant": null,
"vlan": null, "vlan": null,
"status": { "status": {
@ -250,24 +258,36 @@ GET /api/ipam/prefixes/13980/
"slug": "staging" "slug": "staging"
}, },
"is_pool": false, "is_pool": false,
"mark_utilized": false,
"description": "Example prefix", "description": "Example prefix",
"comments": "",
"tags": [], "tags": [],
"custom_fields": {}, "custom_fields": {},
"created": "2018-12-10", "created": "2025-03-01T20:01:23.458302Z",
"last_updated": "2019-03-01T20:02:46.173540Z" "last_updated": "2025-03-01T20:02:46.173540Z",
"children": 0,
"_depth": 0
} }
``` ```
The brief format is much more terse: The brief format is much more terse:
``` ```no-highlight
GET /api/ipam/prefixes/13980/?brief=1 GET /api/ipam/prefixes/13980/?brief=1
```
```json
{ {
"id": 13980, "id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/", "url": "http://netbox/api/ipam/prefixes/13980/",
"family": 4, "display": "192.0.2.0/24",
"prefix": "10.40.3.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 "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
http://netbox/api/ipam/prefixes/ \ 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 ```json
{ {
"id": 18691, "id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/", "url": "http://netbox/api/ipam/prefixes/18691/",
"display_url": "http://netbox/api/ipam/prefixes/18691/",
"display": "192.0.2.0/24",
"family": { "family": {
"value": 4, "value": 4,
"label": "IPv4" "label": "IPv4"
}, },
"prefix": "192.0.2.0/24", "prefix": "192.0.2.0/24",
"site": { "vrf": null,
"scope_type": "dcim.site",
"scope_id": 6,
"scope": {
"id": 6, "id": 6,
"url": "http://netbox/api/dcim/sites/6/", "url": "http://netbox/api/dcim/sites/6/",
"display": "US-East 4",
"name": "US-East 4", "name": "US-East 4",
"slug": "us-east-4" "slug": "us-east-4",
"description": ""
}, },
"vrf": null,
"tenant": null, "tenant": null,
"vlan": null, "vlan": null,
"status": { "status": {
@ -427,11 +453,15 @@ http://netbox/api/ipam/prefixes/ \
}, },
"role": null, "role": null,
"is_pool": false, "is_pool": false,
"mark_utilized": false,
"description": "", "description": "",
"comments": "",
"tags": [], "tags": [],
"custom_fields": {}, "custom_fields": {},
"created": "2020-08-04", "created": "2025-04-29T15:44:47.597092Z",
"last_updated": "2020-08-04T20:08:39.007125Z" "last_updated": "2025-04-29T15:44:47.597092Z",
"children": 0,
"_depth": 0
} }
``` ```
@ -490,18 +520,24 @@ http://netbox/api/ipam/prefixes/18691/ \
{ {
"id": 18691, "id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/", "url": "http://netbox/api/ipam/prefixes/18691/",
"display_url": "http://netbox/api/ipam/prefixes/18691/",
"display": "192.0.2.0/24",
"family": { "family": {
"value": 4, "value": 4,
"label": "IPv4" "label": "IPv4"
}, },
"prefix": "192.0.2.0/24", "prefix": "192.0.2.0/24",
"site": { "vrf": null,
"scope_type": "dcim.site",
"scope_id": 6,
"scope": {
"id": 6, "id": 6,
"url": "http://netbox/api/dcim/sites/6/", "url": "http://netbox/api/dcim/sites/6/",
"display": "US-East 4",
"name": "US-East 4", "name": "US-East 4",
"slug": "us-east-4" "slug": "us-east-4",
"description": ""
}, },
"vrf": null,
"tenant": null, "tenant": null,
"vlan": null, "vlan": null,
"status": { "status": {
@ -510,11 +546,15 @@ http://netbox/api/ipam/prefixes/18691/ \
}, },
"role": null, "role": null,
"is_pool": false, "is_pool": false,
"mark_utilized": false,
"description": "", "description": "",
"comments": "",
"tags": [], "tags": [],
"custom_fields": {}, "custom_fields": {},
"created": "2020-08-04", "created": "2025-04-29T15:44:47.597092Z",
"last_updated": "2020-08-04T20:14:55.709430Z" "last_updated": "2025-04-29T15:49:40.689109Z",
"children": 0,
"_depth": 0
} }
``` ```
@ -568,6 +608,23 @@ http://netbox/api/dcim/sites/ \
!!! note !!! 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. 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 ## Authentication
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. 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, "id": 6,
"url": "https://netbox/api/users/tokens/6/", "url": "https://netbox/api/users/tokens/6/",
"display_url": "https://netbox/api/users/tokens/6/",
"display": "**********************************3c9cb9", "display": "**********************************3c9cb9",
"user": { "user": {
"id": 2, "id": 2,

View File

@ -1,5 +1,42 @@
# NetBox v4.2 # 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) ## v4.2.8 (2025-04-22)
### Enhancements ### Enhancements

View File

@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url from django.shortcuts import render, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator 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.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View from django.views.generic import View
@ -28,6 +28,7 @@ from netbox.config import get_config
from netbox.views import generic from netbox.views import generic
from users import forms, tables from users import forms, tables
from users.models import UserConfig from users.models import UserConfig
from utilities.request import safe_for_redirect
from utilities.string import remove_linebreaks from utilities.string import remove_linebreaks
from utilities.views import register_model_view from utilities.views import register_model_view
@ -148,7 +149,7 @@ class LoginView(View):
data = request.POST if request.method == "POST" else request.GET data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL) 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)}") logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
else: else:
if redirect_url: if redirect_url:

View File

@ -61,9 +61,8 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True, linkify=True,
verbose_name=_('Account') verbose_name=_('Account')
) )
type = tables.Column( type = columns.ColoredLabelColumn(
verbose_name=_('Type'), verbose_name=_('Type'),
linkify=True
) )
status = columns.ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
termination_a = columns.TemplateColumn( termination_a = columns.TemplateColumn(

View File

@ -35,7 +35,19 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { 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() 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): class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
model_form = forms.ProviderImportForm model_form = forms.ProviderImportForm
@ -112,7 +124,7 @@ class ProviderAccountDeleteView(generic.ObjectDeleteView):
queryset = ProviderAccount.objects.all() 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): class ProviderAccountBulkImportView(generic.BulkImportView):
queryset = ProviderAccount.objects.all() queryset = ProviderAccount.objects.all()
model_form = forms.ProviderAccountImportForm model_form = forms.ProviderAccountImportForm
@ -186,7 +198,7 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView):
queryset = ProviderNetwork.objects.all() 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): class ProviderNetworkBulkImportView(generic.BulkImportView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
model_form = forms.ProviderNetworkImportForm model_form = forms.ProviderNetworkImportForm
@ -243,7 +255,7 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all() 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): class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeImportForm model_form = forms.CircuitTypeImportForm
@ -299,7 +311,7 @@ class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all() 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): class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
model_form = forms.CircuitImportForm model_form = forms.CircuitImportForm
@ -439,7 +451,7 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all() 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): class CircuitTerminationBulkImportView(generic.BulkImportView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationImportForm model_form = forms.CircuitTerminationImportForm
@ -500,7 +512,7 @@ class CircuitGroupDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroup.objects.all() 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): class CircuitGroupBulkImportView(generic.BulkImportView):
queryset = CircuitGroup.objects.all() queryset = CircuitGroup.objects.all()
model_form = forms.CircuitGroupImportForm model_form = forms.CircuitGroupImportForm
@ -550,7 +562,7 @@ class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroupAssignment.objects.all() 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): class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
queryset = CircuitGroupAssignment.objects.all() queryset = CircuitGroupAssignment.objects.all()
model_form = forms.CircuitGroupAssignmentImportForm model_form = forms.CircuitGroupAssignmentImportForm
@ -607,7 +619,7 @@ class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuitType.objects.all() 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): class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
queryset = VirtualCircuitType.objects.all() queryset = VirtualCircuitType.objects.all()
model_form = forms.VirtualCircuitTypeImportForm model_form = forms.VirtualCircuitTypeImportForm

View 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),
),
]

View File

@ -1,12 +1,10 @@
# Generated by Django 5.1.6 on 2025-02-26 19:45
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0012_job_object_type_optional'), ('core', '0013_job_data_encoder'),
] ]
operations = [ operations = [

View File

@ -1,12 +1,10 @@
# Generated by Django 5.2b1 on 2025-04-03 18:32
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0013_datasource_sync_interval'), ('core', '0014_datasource_sync_interval'),
] ]
operations = [ operations = [

View File

@ -5,6 +5,7 @@ import django_rq
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.urls import reverse from django.urls import reverse
@ -90,8 +91,9 @@ class Job(models.Model):
) )
data = models.JSONField( data = models.JSONField(
verbose_name=_('data'), verbose_name=_('data'),
encoder=DjangoJSONEncoder,
null=True, null=True,
blank=True blank=True,
) )
error = models.TextField( error = models.TextField(
verbose_name=_('error'), verbose_name=_('error'),

View File

@ -49,6 +49,7 @@ class Plugin:
The representation of a NetBox plugin in the catalog API. The representation of a NetBox plugin in the catalog API.
""" """
id: str = '' id: str = ''
icon_url: str = ''
status: str = '' status: str = ''
title_short: str = '' title_short: str = ''
title_long: str = '' title_long: str = ''
@ -210,6 +211,7 @@ def get_catalog_plugins():
# Populate plugin data # Populate plugin data
plugins[data['config_name']] = Plugin( plugins[data['config_name']] = Plugin(
id=data['id'], id=data['id'],
icon_url=data['icon'],
status=data['status'], status=data['status'],
title_short=data['title_short'], title_short=data['title_short'],
title_long=data['title_long'], title_long=data['title_long'],

View File

@ -2,7 +2,7 @@ import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError 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.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _ 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 # 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 # the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded. # for 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: for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel: if type(relation) not in [ManyToManyRel, ManyToOneRel]:
continue continue
related_model = relation.related_model related_model = relation.related_model
related_field_name = relation.remote_field.name related_field_name = relation.remote_field.name
@ -156,7 +158,11 @@ def handle_deleted_object(sender, instance, **kwargs):
continue continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}): for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance) 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 # Enqueue the object for event processing
queue = events_queue.get() queue = events_queue.get()

View File

@ -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): class PluginVersionTable(BaseTable):
version = tables.Column( version = tables.Column(
verbose_name=_('Version') verbose_name=_('Version')
@ -42,8 +48,9 @@ class PluginVersionTable(BaseTable):
class CatalogPluginTable(BaseTable): class CatalogPluginTable(BaseTable):
title_long = tables.Column( title_long = columns.TemplateColumn(
verbose_name=_('Name'), template_code=PLUGIN_NAME_TEMPLATE,
verbose_name=_('Name')
) )
author = tables.Column( author = tables.Column(
accessor=tables.A('author__name'), accessor=tables.A('author__name'),

View File

@ -103,7 +103,7 @@ class DataSourceDeleteView(generic.ObjectDeleteView):
queryset = DataSource.objects.all() 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): class DataSourceBulkImportView(generic.BulkImportView):
queryset = DataSource.objects.all() queryset = DataSource.objects.all()
model_form = forms.DataSourceImportForm model_form = forms.DataSourceImportForm

View File

@ -1390,10 +1390,75 @@ class ModuleFilterSet(NetBoxModelFilterSet):
lookup_expr='in', lookup_expr='in',
label=_('Module bay (ID)'), 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( device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label=_('Device (ID)'), 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( status = django_filters.MultipleChoiceFilter(
choices=ModuleStatusChoices, choices=ModuleStatusChoices,
null_value=None null_value=None

View File

@ -121,11 +121,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
class InventoryItemBulkCreateForm( 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 DeviceBulkAddComponentForm
): ):
model = InventoryItem model = InventoryItem
field_order = ( 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', 'description', 'tags',
) )

View File

@ -41,7 +41,6 @@ class InterfaceCommonForm(forms.Form):
def clean(self): def clean(self):
super().clean() super().clean()
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
if 'tagged_vlans' in self.fields.keys(): if 'tagged_vlans' in self.fields.keys():
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \ 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" "or they must be global"
).format(vlans=', '.join(invalid_vlans)) ).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): class ModuleCommonForm(forms.Form):

View File

@ -959,8 +959,56 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module model = Module
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), 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')), 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( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False, required=False,

View File

@ -993,7 +993,7 @@ class ComponentTemplateForm(forms.ModelForm):
class ModularComponentTemplateForm(ComponentTemplateForm): class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'), label=_('Device type'),
queryset=DeviceType.objects.all().all(), queryset=DeviceType.objects.all(),
required=False, required=False,
context={ context={
'parent': 'manufacturer', '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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -1024,10 +1034,6 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
class ConsolePortTemplateForm(ModularComponentTemplateForm): class ConsolePortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
)
class Meta: class Meta:
model = ConsolePortTemplate model = ConsolePortTemplate
fields = [ fields = [
@ -1036,10 +1042,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
)
class Meta: class Meta:
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
fields = [ fields = [
@ -1050,7 +1052,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
class PowerPortTemplateForm(ModularComponentTemplateForm): class PowerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = ( fieldsets = (
FieldSet( 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 = ( 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: class Meta:
@ -1095,7 +1107,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = ( fieldsets = (
FieldSet( 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('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')), FieldSet('rf_role', name=_('Wireless')),
@ -1122,8 +1138,11 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', TabbedGroups(
'description', 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): class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = ( 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: class Meta:
@ -1149,7 +1174,13 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
class ModuleBayTemplateForm(ModularComponentTemplateForm): class ModuleBayTemplateForm(ModularComponentTemplateForm):
fieldsets = ( 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: class Meta:

View File

@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import * from dcim.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField 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 utilities.forms.widgets import APISelect
from . import model_forms from . import model_forms
@ -118,7 +118,13 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position # Override fieldsets from FrontPortTemplateForm to omit rear_port_position
fieldsets = ( 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): class Meta(model_forms.FrontPortTemplateForm.Meta):

View File

@ -159,7 +159,7 @@ class ModuleBayTemplateImportForm(forms.ModelForm):
class Meta: class Meta:
model = ModuleBayTemplate model = ModuleBayTemplate
fields = [ fields = [
'device_type', 'name', 'label', 'position', 'description', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
] ]

View File

@ -3,7 +3,6 @@ import itertools
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Sum
from django.dispatch import Signal from django.dispatch import Signal
from django.utils.translation import gettext_lazy as _ 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 Return a tuple containing the sum of the length of each cable in the path
and a flag indicating whether the length is definitive. 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() cable_ids = self.get_cable_ids()
cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False) cables = {
total_length = cables.aggregate(total=Sum('_abs_length'))['total'] 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) is_definitive = len(cables) == len(cable_ids)
return total_length, is_definitive return total_length, is_definitive

View File

@ -732,3 +732,8 @@ class RackReservation(PrimaryModel):
@property @property
def unit_list(self): def unit_list(self):
return array_to_string(self.units) return array_to_string(self.units)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.rack
return objectchange

View File

@ -2859,15 +2859,23 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4) addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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]} params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) 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): def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6) addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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]} params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) 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): def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]} params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
@ -2961,6 +2969,29 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): 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 = ( manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
@ -2968,11 +2999,66 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
devices = ( device_types = (
create_test_device('Test Device 1'), DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
create_test_device('Test Device 2'), DeviceType(manufacturer=manufacturers[1], model='Device Type 2', slug='device-type-2'),
create_test_device('Test Device 3'), 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 = ( module_types = (
ModuleType(manufacturer=manufacturers[0], model='Module Type 1'), ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
@ -3120,6 +3206,41 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'asset_tag': ['A', 'B']} params = {'asset_tag': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
@ -6722,15 +6843,23 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4) addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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]} params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) 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): def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6) addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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]} params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) 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): class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@ -1217,6 +1217,13 @@ front-ports:
- name: Front Port 3 - name: Front Port 3
type: 8p8c type: 8p8c
rear_port: Rear Port 3 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 # Create the manufacturer
@ -1234,6 +1241,7 @@ front-ports:
'dcim.add_interfacetemplate', 'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate', 'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate', 'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
) )
form_data = { form_data = {
@ -1288,6 +1296,11 @@ front-ports:
self.assertEqual(fp1.rear_port, rp1) self.assertEqual(fp1.rear_port, rp1)
self.assertEqual(fp1.rear_port_position, 1) 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): def test_export_objects(self):
url = reverse('dcim:moduletype_list') url = reverse('dcim:moduletype_list')
self.add_permissions('dcim.view_moduletype') self.add_permissions('dcim.view_moduletype')

View File

@ -22,6 +22,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.query import count_related from utilities.query import count_related
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.request import safe_for_redirect
from utilities.views import ( from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
) )
@ -279,7 +280,7 @@ class RegionDeleteView(generic.ObjectDeleteView):
queryset = Region.objects.all() 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): class RegionBulkImportView(generic.BulkImportView):
queryset = Region.objects.all() queryset = Region.objects.all()
model_form = forms.RegionImportForm model_form = forms.RegionImportForm
@ -405,7 +406,7 @@ class SiteGroupDeleteView(generic.ObjectDeleteView):
queryset = SiteGroup.objects.all() 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): class SiteGroupBulkImportView(generic.BulkImportView):
queryset = SiteGroup.objects.all() queryset = SiteGroup.objects.all()
model_form = forms.SiteGroupImportForm model_form = forms.SiteGroupImportForm
@ -496,7 +497,7 @@ class SiteDeleteView(generic.ObjectDeleteView):
queryset = Site.objects.all() 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): class SiteBulkImportView(generic.BulkImportView):
queryset = Site.objects.all() queryset = Site.objects.all()
model_form = forms.SiteImportForm model_form = forms.SiteImportForm
@ -594,7 +595,7 @@ class LocationDeleteView(generic.ObjectDeleteView):
queryset = Location.objects.all() 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): class LocationBulkImportView(generic.BulkImportView):
queryset = Location.objects.all() queryset = Location.objects.all()
model_form = forms.LocationImportForm model_form = forms.LocationImportForm
@ -663,7 +664,7 @@ class RackRoleDeleteView(generic.ObjectDeleteView):
queryset = RackRole.objects.all() 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): class RackRoleBulkImportView(generic.BulkImportView):
queryset = RackRole.objects.all() queryset = RackRole.objects.all()
model_form = forms.RackRoleImportForm model_form = forms.RackRoleImportForm
@ -724,7 +725,7 @@ class RackTypeDeleteView(generic.ObjectDeleteView):
queryset = RackType.objects.all() 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): class RackTypeBulkImportView(generic.BulkImportView):
queryset = RackType.objects.all() queryset = RackType.objects.all()
model_form = forms.RackTypeImportForm model_form = forms.RackTypeImportForm
@ -903,7 +904,7 @@ class RackDeleteView(generic.ObjectDeleteView):
queryset = Rack.objects.all() 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): class RackBulkImportView(generic.BulkImportView):
queryset = Rack.objects.all() queryset = Rack.objects.all()
model_form = forms.RackImportForm model_form = forms.RackImportForm
@ -960,7 +961,7 @@ class RackReservationDeleteView(generic.ObjectDeleteView):
queryset = RackReservation.objects.all() 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): class RackReservationImportView(generic.BulkImportView):
queryset = RackReservation.objects.all() queryset = RackReservation.objects.all()
model_form = forms.RackReservationImportForm model_form = forms.RackReservationImportForm
@ -1031,7 +1032,7 @@ class ManufacturerDeleteView(generic.ObjectDeleteView):
queryset = Manufacturer.objects.all() 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): class ManufacturerBulkImportView(generic.BulkImportView):
queryset = Manufacturer.objects.all() queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerImportForm 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): class DeviceTypeImportView(generic.BulkImportView):
additional_permissions = [ additional_permissions = [
'dcim.add_devicetype', '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): class ModuleTypeImportView(generic.BulkImportView):
additional_permissions = [ additional_permissions = [
'dcim.add_moduletype', 'dcim.add_moduletype',
@ -1533,6 +1534,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'dcim.add_interfacetemplate', 'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate', 'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate', 'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
] ]
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
model_form = forms.ModuleTypeImportForm model_form = forms.ModuleTypeImportForm
@ -1544,6 +1546,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'interfaces': forms.InterfaceTemplateImportForm, 'interfaces': forms.InterfaceTemplateImportForm,
'rear-ports': forms.RearPortTemplateImportForm, 'rear-ports': forms.RearPortTemplateImportForm,
'front-ports': forms.FrontPortTemplateImportForm, 'front-ports': forms.FrontPortTemplateImportForm,
'module-bays': forms.ModuleBayTemplateImportForm,
} }
def prep_related_object_data(self, parent, data): def prep_related_object_data(self, parent, data):
@ -2018,7 +2021,7 @@ class DeviceRoleDeleteView(generic.ObjectDeleteView):
queryset = DeviceRole.objects.all() 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): class DeviceRoleBulkImportView(generic.BulkImportView):
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleImportForm model_form = forms.DeviceRoleImportForm
@ -2082,7 +2085,7 @@ class PlatformDeleteView(generic.ObjectDeleteView):
queryset = Platform.objects.all() 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): class PlatformBulkImportView(generic.BulkImportView):
queryset = Platform.objects.all() queryset = Platform.objects.all()
model_form = forms.PlatformImportForm 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) 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): class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all() queryset = Device.objects.all()
model_form = forms.DeviceImportForm model_form = forms.DeviceImportForm
@ -2438,7 +2441,7 @@ class ModuleDeleteView(generic.ObjectDeleteView):
queryset = Module.objects.all() 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): class ModuleBulkImportView(generic.BulkImportView):
queryset = Module.objects.all() queryset = Module.objects.all()
model_form = forms.ModuleImportForm model_form = forms.ModuleImportForm
@ -2499,7 +2502,7 @@ class ConsolePortDeleteView(generic.ObjectDeleteView):
queryset = ConsolePort.objects.all() 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): class ConsolePortBulkImportView(generic.BulkImportView):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortImportForm model_form = forms.ConsolePortImportForm
@ -2574,7 +2577,7 @@ class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
queryset = ConsoleServerPort.objects.all() 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): class ConsoleServerPortBulkImportView(generic.BulkImportView):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortImportForm model_form = forms.ConsoleServerPortImportForm
@ -2649,7 +2652,7 @@ class PowerPortDeleteView(generic.ObjectDeleteView):
queryset = PowerPort.objects.all() 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): class PowerPortBulkImportView(generic.BulkImportView):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
model_form = forms.PowerPortImportForm model_form = forms.PowerPortImportForm
@ -2724,7 +2727,7 @@ class PowerOutletDeleteView(generic.ObjectDeleteView):
queryset = PowerOutlet.objects.all() 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): class PowerOutletBulkImportView(generic.BulkImportView):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletImportForm model_form = forms.PowerOutletImportForm
@ -2856,7 +2859,7 @@ class InterfaceDeleteView(generic.ObjectDeleteView):
queryset = Interface.objects.all() 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): class InterfaceBulkImportView(generic.BulkImportView):
queryset = Interface.objects.all() queryset = Interface.objects.all()
model_form = forms.InterfaceImportForm model_form = forms.InterfaceImportForm
@ -2942,7 +2945,7 @@ class FrontPortDeleteView(generic.ObjectDeleteView):
queryset = FrontPort.objects.all() 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): class FrontPortBulkImportView(generic.BulkImportView):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
model_form = forms.FrontPortImportForm model_form = forms.FrontPortImportForm
@ -3017,7 +3020,7 @@ class RearPortDeleteView(generic.ObjectDeleteView):
queryset = RearPort.objects.all() 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): class RearPortBulkImportView(generic.BulkImportView):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
model_form = forms.RearPortImportForm model_form = forms.RearPortImportForm
@ -3092,7 +3095,7 @@ class ModuleBayDeleteView(generic.ObjectDeleteView):
queryset = ModuleBay.objects.all() 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): class ModuleBayBulkImportView(generic.BulkImportView):
queryset = ModuleBay.objects.all() queryset = ModuleBay.objects.all()
model_form = forms.ModuleBayImportForm model_form = forms.ModuleBayImportForm
@ -3239,7 +3242,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): class DeviceBayBulkImportView(generic.BulkImportView):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayImportForm model_form = forms.DeviceBayImportForm
@ -3305,7 +3308,7 @@ class InventoryItemDeleteView(generic.ObjectDeleteView):
queryset = InventoryItem.objects.all() 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): class InventoryItemBulkImportView(generic.BulkImportView):
queryset = InventoryItem.objects.all() queryset = InventoryItem.objects.all()
model_form = forms.InventoryItemImportForm model_form = forms.InventoryItemImportForm
@ -3386,7 +3389,7 @@ class InventoryItemRoleDeleteView(generic.ObjectDeleteView):
queryset = InventoryItemRole.objects.all() 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): class InventoryItemRoleBulkImportView(generic.BulkImportView):
queryset = InventoryItemRole.objects.all() queryset = InventoryItemRole.objects.all()
model_form = forms.InventoryItemRoleImportForm model_form = forms.InventoryItemRoleImportForm
@ -3582,7 +3585,7 @@ class CableDeleteView(generic.ObjectDeleteView):
queryset = Cable.objects.all() 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): class CableBulkImportView(generic.BulkImportView):
queryset = Cable.objects.all() queryset = Cable.objects.all()
model_form = forms.CableImportForm model_form = forms.CableImportForm
@ -3811,7 +3814,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(request.get_full_path())
return redirect(self.get_return_url(request, device)) return redirect(self.get_return_url(request, device))
@ -3883,7 +3886,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): class VirtualChassisBulkImportView(generic.BulkImportView):
queryset = VirtualChassis.objects.all() queryset = VirtualChassis.objects.all()
model_form = forms.VirtualChassisImportForm model_form = forms.VirtualChassisImportForm
@ -3940,7 +3943,7 @@ class PowerPanelDeleteView(generic.ObjectDeleteView):
queryset = PowerPanel.objects.all() 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): class PowerPanelBulkImportView(generic.BulkImportView):
queryset = PowerPanel.objects.all() queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelImportForm model_form = forms.PowerPanelImportForm
@ -3992,7 +3995,7 @@ class PowerFeedDeleteView(generic.ObjectDeleteView):
queryset = PowerFeed.objects.all() 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): class PowerFeedBulkImportView(generic.BulkImportView):
queryset = PowerFeed.objects.all() queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedImportForm model_form = forms.PowerFeedImportForm
@ -4064,7 +4067,7 @@ class VirtualDeviceContextDeleteView(generic.ObjectDeleteView):
queryset = VirtualDeviceContext.objects.all() 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): class VirtualDeviceContextBulkImportView(generic.BulkImportView):
queryset = VirtualDeviceContext.objects.all() queryset = VirtualDeviceContext.objects.all()
model_form = forms.VirtualDeviceContextImportForm model_form = forms.VirtualDeviceContextImportForm
@ -4114,7 +4117,7 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
queryset = MACAddress.objects.all() 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): class MACAddressBulkImportView(generic.BulkImportView):
queryset = MACAddress.objects.all() queryset = MACAddress.objects.all()
model_form = forms.MACAddressImportForm model_form = forms.MACAddressImportForm

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0014_remove_redundant_indexes'), ('core', '0015_remove_redundant_indexes'),
('extras', '0127_configtemplate_as_attachment_and_more'), ('extras', '0127_configtemplate_as_attachment_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]

View File

@ -123,7 +123,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
ordered = [ ordered = [
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects 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 return ordered
@property @property

View File

@ -5,6 +5,8 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.models import * from extras.models import *
from core.tables import JobTable
from core.models import Job
from netbox.constants import EMPTY_TABLE_TEXT from netbox.constants import EMPTY_TABLE_TEXT
from netbox.events import get_event_text from netbox.events import get_event_text
from netbox.tables import BaseTable, NetBoxTable, columns from netbox.tables import BaseTable, NetBoxTable, columns
@ -26,6 +28,7 @@ __all__ = (
'SavedFilterTable', 'SavedFilterTable',
'ReportResultsTable', 'ReportResultsTable',
'ScriptResultsTable', 'ScriptResultsTable',
'ScriptJobTable',
'SubscriptionTable', 'SubscriptionTable',
'TableConfigTable', 'TableConfigTable',
'TaggedItemTable', 'TaggedItemTable',
@ -693,6 +696,23 @@ class ScriptResultsTable(BaseTable):
return format_html("<a href='{}'>{}</a>", value, value) 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): class ReportResultsTable(BaseTable):
index = tables.Column( index = tables.Column(
verbose_name=_('Line') verbose_name=_('Line')

View File

@ -14,7 +14,6 @@ from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices
from core.models import Job from core.models import Job
from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
@ -36,7 +35,7 @@ from virtualization.models import VirtualMachine
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK from .constants import LOG_LEVEL_RANK
from .models import * 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') 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): class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.select_related('choice_set') queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm model_form = forms.CustomFieldImportForm
@ -152,7 +151,7 @@ class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
queryset = CustomFieldChoiceSet.objects.all() 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): class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
queryset = CustomFieldChoiceSet.objects.all() queryset = CustomFieldChoiceSet.objects.all()
model_form = forms.CustomFieldChoiceSetImportForm model_form = forms.CustomFieldChoiceSetImportForm
@ -202,7 +201,7 @@ class CustomLinkDeleteView(generic.ObjectDeleteView):
queryset = CustomLink.objects.all() 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): class CustomLinkBulkImportView(generic.BulkImportView):
queryset = CustomLink.objects.all() queryset = CustomLink.objects.all()
model_form = forms.CustomLinkImportForm model_form = forms.CustomLinkImportForm
@ -257,7 +256,7 @@ class ExportTemplateDeleteView(generic.ObjectDeleteView):
queryset = ExportTemplate.objects.all() 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): class ExportTemplateBulkImportView(generic.BulkImportView):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
model_form = forms.ExportTemplateImportForm model_form = forms.ExportTemplateImportForm
@ -317,7 +316,7 @@ class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
queryset = SavedFilter.objects.all() 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): class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterImportForm model_form = forms.SavedFilterImportForm
@ -457,7 +456,7 @@ class NotificationGroupDeleteView(generic.ObjectDeleteView):
queryset = NotificationGroup.objects.all() 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): class NotificationGroupBulkImportView(generic.BulkImportView):
queryset = NotificationGroup.objects.all() queryset = NotificationGroup.objects.all()
model_form = forms.NotificationGroupImportForm model_form = forms.NotificationGroupImportForm
@ -603,7 +602,7 @@ class WebhookDeleteView(generic.ObjectDeleteView):
queryset = Webhook.objects.all() 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): class WebhookBulkImportView(generic.BulkImportView):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()
model_form = forms.WebhookImportForm model_form = forms.WebhookImportForm
@ -653,7 +652,7 @@ class EventRuleDeleteView(generic.ObjectDeleteView):
queryset = EventRule.objects.all() 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): class EventRuleBulkImportView(generic.BulkImportView):
queryset = EventRule.objects.all() queryset = EventRule.objects.all()
model_form = forms.EventRuleImportForm model_form = forms.EventRuleImportForm
@ -726,7 +725,7 @@ class TagDeleteView(generic.ObjectDeleteView):
queryset = Tag.objects.all() 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): class TagBulkImportView(generic.BulkImportView):
queryset = Tag.objects.all() queryset = Tag.objects.all()
model_form = forms.TagImportForm model_form = forms.TagImportForm
@ -902,7 +901,7 @@ class ConfigTemplateDeleteView(generic.ObjectDeleteView):
queryset = ConfigTemplate.objects.all() 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): class ConfigTemplateBulkImportView(generic.BulkImportView):
queryset = ConfigTemplate.objects.all() queryset = ConfigTemplate.objects.all()
model_form = forms.ConfigTemplateImportForm model_form = forms.ConfigTemplateImportForm
@ -1081,7 +1080,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
return reverse(viewname, kwargs={'pk': obj.pk}) 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): class JournalEntryBulkImportView(generic.BulkImportView):
queryset = JournalEntry.objects.all() queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryImportForm model_form = forms.JournalEntryImportForm
@ -1393,7 +1392,7 @@ class ScriptJobsView(BaseScriptView):
def get(self, request, **kwargs): def get(self, request, **kwargs):
script = self.get_object(**kwargs) script = self.get_object(**kwargs)
jobs_table = JobTable( jobs_table = ScriptJobTable(
data=script.jobs.all(), data=script.jobs.all(),
orderable=False, orderable=False,
user=request.user user=request.user

View File

@ -1256,8 +1256,20 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'), 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( primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6', field_name='primary_ip6',
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'), 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)'),
)

View File

@ -864,6 +864,7 @@ class ServiceCreateForm(ServiceForm):
# Fields which may be populated from a ServiceTemplate are not required # Fields which may be populated from a ServiceTemplate are not required
for field in ('name', 'protocol', 'ports'): for field in ('name', 'protocol', 'ports'):
self.fields[field].required = False self.fields[field].required = False
self.fields[field].widget.is_required = False
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 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.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator 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.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext_lazy as _ 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.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
@ -285,12 +286,20 @@ class VLAN(PrimaryModel):
super().clean() super().clean()
# Validate VLAN group (if assigned) # Validate VLAN group (if assigned)
if self.group and self.site and self.group.scope != 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( raise ValidationError(
_( _(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}." "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) ).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) # Check that the VLAN ID is permitted in the assigned group (if any)
if self.group: if self.group:

View File

@ -1,8 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet from netaddr import IPNetwork, IPSet
from utilities.data import string_to_ranges from utilities.data import string_to_ranges
from dcim.models import Site, SiteGroup
from ipam.choices import * from ipam.choices import *
from ipam.models import * from ipam.models import *
@ -679,3 +681,54 @@ class TestVLAN(TestCase):
) )
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
vlan.full_clean() 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()

View File

@ -69,7 +69,7 @@ class VRFDeleteView(generic.ObjectDeleteView):
queryset = VRF.objects.all() 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): class VRFBulkImportView(generic.BulkImportView):
queryset = VRF.objects.all() queryset = VRF.objects.all()
model_form = forms.VRFImportForm model_form = forms.VRFImportForm
@ -119,7 +119,7 @@ class RouteTargetDeleteView(generic.ObjectDeleteView):
queryset = RouteTarget.objects.all() 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): class RouteTargetBulkImportView(generic.BulkImportView):
queryset = RouteTarget.objects.all() queryset = RouteTarget.objects.all()
model_form = forms.RouteTargetImportForm model_form = forms.RouteTargetImportForm
@ -176,7 +176,7 @@ class RIRDeleteView(generic.ObjectDeleteView):
queryset = RIR.objects.all() 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): class RIRBulkImportView(generic.BulkImportView):
queryset = RIR.objects.all() queryset = RIR.objects.all()
model_form = forms.RIRImportForm model_form = forms.RIRImportForm
@ -251,7 +251,7 @@ class ASNRangeDeleteView(generic.ObjectDeleteView):
queryset = ASNRange.objects.all() 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): class ASNRangeBulkImportView(generic.BulkImportView):
queryset = ASNRange.objects.all() queryset = ASNRange.objects.all()
model_form = forms.ASNRangeImportForm model_form = forms.ASNRangeImportForm
@ -316,7 +316,7 @@ class ASNDeleteView(generic.ObjectDeleteView):
queryset = ASN.objects.all() 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): class ASNBulkImportView(generic.BulkImportView):
queryset = ASN.objects.all() queryset = ASN.objects.all()
model_form = forms.ASNImportForm model_form = forms.ASNImportForm
@ -408,7 +408,7 @@ class AggregateDeleteView(generic.ObjectDeleteView):
queryset = Aggregate.objects.all() 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): class AggregateBulkImportView(generic.BulkImportView):
queryset = Aggregate.objects.all() queryset = Aggregate.objects.all()
model_form = forms.AggregateImportForm model_form = forms.AggregateImportForm
@ -471,7 +471,7 @@ class RoleDeleteView(generic.ObjectDeleteView):
queryset = Role.objects.all() 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): class RoleBulkImportView(generic.BulkImportView):
queryset = Role.objects.all() queryset = Role.objects.all()
model_form = forms.RoleImportForm model_form = forms.RoleImportForm
@ -657,7 +657,7 @@ class PrefixDeleteView(generic.ObjectDeleteView):
queryset = Prefix.objects.all() 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): class PrefixBulkImportView(generic.BulkImportView):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
model_form = forms.PrefixImportForm model_form = forms.PrefixImportForm
@ -746,7 +746,7 @@ class IPRangeDeleteView(generic.ObjectDeleteView):
queryset = IPRange.objects.all() 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): class IPRangeBulkImportView(generic.BulkImportView):
queryset = IPRange.objects.all() queryset = IPRange.objects.all()
model_form = forms.IPRangeImportForm model_form = forms.IPRangeImportForm
@ -910,7 +910,7 @@ class IPAddressBulkCreateView(generic.BulkCreateView):
template_name = 'ipam/ipaddress_bulk_add.html' 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): class IPAddressBulkImportView(generic.BulkImportView):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
model_form = forms.IPAddressImportForm model_form = forms.IPAddressImportForm
@ -983,7 +983,7 @@ class VLANGroupDeleteView(generic.ObjectDeleteView):
queryset = VLANGroup.objects.all() 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): class VLANGroupBulkImportView(generic.BulkImportView):
queryset = VLANGroup.objects.all() queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupImportForm model_form = forms.VLANGroupImportForm
@ -1070,7 +1070,7 @@ class VLANTranslationPolicyDeleteView(generic.ObjectDeleteView):
queryset = VLANTranslationPolicy.objects.all() 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): class VLANTranslationPolicyBulkImportView(generic.BulkImportView):
queryset = VLANTranslationPolicy.objects.all() queryset = VLANTranslationPolicy.objects.all()
model_form = forms.VLANTranslationPolicyImportForm model_form = forms.VLANTranslationPolicyImportForm
@ -1125,7 +1125,7 @@ class VLANTranslationRuleDeleteView(generic.ObjectDeleteView):
queryset = VLANTranslationRule.objects.all() 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): class VLANTranslationRuleBulkImportView(generic.BulkImportView):
queryset = VLANTranslationRule.objects.all() queryset = VLANTranslationRule.objects.all()
model_form = forms.VLANTranslationRuleImportForm model_form = forms.VLANTranslationRuleImportForm
@ -1218,7 +1218,7 @@ class FHRPGroupDeleteView(generic.ObjectDeleteView):
queryset = FHRPGroup.objects.all() 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): class FHRPGroupBulkImportView(generic.BulkImportView):
queryset = FHRPGroup.objects.all() queryset = FHRPGroup.objects.all()
model_form = forms.FHRPGroupImportForm model_form = forms.FHRPGroupImportForm
@ -1344,7 +1344,7 @@ class VLANDeleteView(generic.ObjectDeleteView):
queryset = VLAN.objects.all() 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): class VLANBulkImportView(generic.BulkImportView):
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
model_form = forms.VLANImportForm model_form = forms.VLANImportForm
@ -1394,7 +1394,7 @@ class ServiceTemplateDeleteView(generic.ObjectDeleteView):
queryset = ServiceTemplate.objects.all() 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): class ServiceTemplateBulkImportView(generic.BulkImportView):
queryset = ServiceTemplate.objects.all() queryset = ServiceTemplate.objects.all()
model_form = forms.ServiceTemplateImportForm model_form = forms.ServiceTemplateImportForm
@ -1461,7 +1461,7 @@ class ServiceDeleteView(generic.ObjectDeleteView):
queryset = Service.objects.all() 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): class ServiceBulkImportView(generic.BulkImportView):
queryset = Service.objects.all() queryset = Service.objects.all()
model_form = forms.ServiceImportForm model_form = forms.ServiceImportForm

View File

@ -442,7 +442,7 @@ INSTALLED_APPS = [
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
] ]
if not DEBUG: if not DEBUG and 'collectstatic' not in sys.argv:
INSTALLED_APPS.remove('debug_toolbar') INSTALLED_APPS.remove('debug_toolbar')
# Middleware # Middleware

View File

@ -29,6 +29,7 @@ from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering from utilities.query import reapply_model_ordering
from utilities.request import safe_for_redirect
from utilities.tables import get_table_configs from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseMultiObjectView 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 # Strip the `export` param and redirect user to the filtered objects list
query_params = request.GET.copy() query_params = request.GET.copy()
query_params.pop('export') 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 # Request handlers
@ -286,7 +290,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
logger.info(msg) logger.info(msg)
messages.success(request, 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(request.path)
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))

View File

@ -20,6 +20,7 @@ from utilities.forms import ConfirmationForm, restrict_form_fields
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.querydict import normalize_querydict, prepare_cloned_fields 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.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseObjectView from .base import BaseObjectView
@ -317,6 +318,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
if 'return_url' in request.GET: if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url') params['return_url'] = request.GET.get('return_url')
redirect_url += f"?{params.urlencode()}" redirect_url += f"?{params.urlencode()}"
if not safe_for_redirect(redirect_url):
redirect_url = reverse('home')
return redirect(redirect_url) return redirect(redirect_url)
@ -583,7 +586,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
)) ))
# Redirect user on success # 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()) return redirect(request.get_full_path())
else: else:
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))

Binary file not shown.

View File

@ -63,3 +63,27 @@ span.color-label {
.sso-icon { .sso-icon {
height: 24px; height: 24px;
} }
.btn-white {
@extend .btn-light;
}
.btn-black {
@extend .btn-dark;
}
.btn-grey, .btn-gray {
@extend .btn-secondary;
}
img.plugin-icon {
max-width: 1.4285em;
height: auto;
}
body[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {
filter: grayscale(100%) invert(100%) brightness(80%);
}
}

View File

@ -1,11 +1,6 @@
// Navbar and light theme styling // Navbar and light theme styling
.navbar-vertical.navbar-expand-lg { .navbar-vertical.navbar-expand-lg {
// Adds spacing to the bottom of the side navigation to avoid hidden nav items
@include media-breakpoint-up(lg) {
padding-bottom: 2rem;
}
// Adjust hover color & style for menu items // Adjust hover color & style for menu items
.navbar-collapse { .navbar-collapse {
.nav-link-icon, .nav-link-title { .nav-link-icon, .nav-link-title {

View File

@ -21,7 +21,7 @@ Blocks:
{# Sidebar #} {# Sidebar #}
<aside class="navbar navbar-vertical navbar-expand-lg d-print-none"> <aside class="navbar navbar-vertical navbar-expand-lg d-print-none">
{% if 'commercial' in settings.RELEASE.features %} {% if settings.RELEASE.features.commercial %}
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}"> <img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">
{% endif %} {% endif %}
@ -51,9 +51,20 @@ Blocks:
{# Navigation menu #} {# Navigation menu #}
<div class="collapse navbar-collapse" id="sidebar-menu"> <div class="collapse navbar-collapse" id="sidebar-menu">
{% nav %} {% nav %}
{# Release info #}
<div class="text-muted text-center fs-5 my-3">
{{ settings.RELEASE.name }}
{% if not settings.RELEASE.features.commercial %}
<div>
<a href="https://netboxlabs.com/netbox-cloud/" class="text-muted">{% trans "Get" %} Cloud</a> |
<a href="https://netboxlabs.com/netbox-enterprise/" class="text-muted">{% trans "Get" %} Enterprise</a>
</div>
{% endif %}
</div> </div>
</div> </div>
</div>
</aside> </aside>
{# Top menu #} {# Top menu #}
@ -210,7 +221,6 @@ Blocks:
<ul class="list-inline list-inline-dots fs-5 mb-0" id="footer-stamp" hx-swap-oob="true"> <ul class="list-inline list-inline-dots fs-5 mb-0" id="footer-stamp" hx-swap-oob="true">
<li class="list-inline-item">{% now 'Y-m-d H:i:s T' %}</li> <li class="list-inline-item">{% now 'Y-m-d H:i:s T' %}</li>
<li class="list-inline-item">{{ settings.HOSTNAME }}</li> <li class="list-inline-item">{{ settings.HOSTNAME }}</li>
<li class="list-inline-item">{{ settings.RELEASE.name }}</li>
</ul> </ul>
{# /Footer text #} {# /Footer text #}

View File

@ -10,7 +10,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Circuit" %}</h2> <h2 class="card-header">{% trans "Circuit" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -89,7 +89,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}

View File

@ -20,7 +20,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Circuit Group" %}</h2> <h2 class="card-header">{% trans "Circuit Group" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -46,7 +46,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}

View File

@ -14,7 +14,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Circuit Group Assignment" %}</h2> <h2 class="card-header">{% trans "Circuit Group Assignment" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -39,7 +39,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -10,7 +10,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
{% if object %} {% if object %}
@ -37,7 +37,7 @@
</div> </div>
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -14,7 +14,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Circuit Type" %}</h2> <h2 class="card-header">{% trans "Circuit Type" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -41,7 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -15,7 +15,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Provider" %}</h2> <h2 class="card-header">{% trans "Provider" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -39,7 +39,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Provider Account" %}</h2> <h2 class="card-header">{% trans "Provider Account" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -33,7 +33,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Provider Network" %}</h2> <h2 class="card-header">{% trans "Provider Network" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -37,7 +37,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}

View File

@ -15,7 +15,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Virtual circuit" %}</h2> <h2 class="card-header">{% trans "Virtual circuit" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -61,7 +61,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
<div class="card"> <div class="card">

View File

@ -18,7 +18,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Virtual Circuit Termination" %}</h2> <h2 class="card-header">{% trans "Virtual Circuit Termination" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -48,7 +48,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Interface" %}</h2> <h2 class="card-header">{% trans "Interface" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">

View File

@ -14,7 +14,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Virtual Circuit Type" %}</h2> <h2 class="card-header">{% trans "Virtual Circuit Type" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -41,7 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -26,7 +26,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Data Source" %}</h2> <h2 class="card-header">{% trans "Data Source" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -83,7 +83,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Backend" %}</h2> <h2 class="card-header">{% trans "Backend" %}</h2>
{% with backend=object.backend_class %} {% with backend=object.backend_class %}

View File

@ -30,7 +30,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Job" %}</h2> <h2 class="card-header">{% trans "Job" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -61,7 +61,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Scheduling" %}</h2> <h2 class="card-header">{% trans "Scheduling" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">

View File

@ -24,7 +24,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-5"> <div class="col col-12 col-md-5">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Change" %}</h2> <h2 class="card-header">{% trans "Change" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -73,7 +73,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="col col-md-7"> <div class="col col-12 col-md-7">
<div class="card"> <div class="card">
<h2 class="card-header d-flex justify-content-between"> <h2 class="card-header d-flex justify-content-between">
{% trans "Difference" %} {% trans "Difference" %}
@ -106,7 +106,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Pre-Change Data" %}</h2> <h2 class="card-header">{% trans "Pre-Change Data" %}</h2>
<div class="card-body"> <div class="card-body">
@ -126,7 +126,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Post-Change Data" %}</h2> <h2 class="card-header">{% trans "Post-Change Data" %}</h2>
<div class="card-body"> <div class="card-body">
@ -146,10 +146,10 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -7,7 +7,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Cable" %}</h2> <h2 class="card-header">{% trans "Cable" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -63,7 +63,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Termination" %} A</h2> <h2 class="card-header">{% trans "Termination" %} A</h2>
{% include 'dcim/inc/cable_termination.html' with terminations=object.a_terminations %} {% include 'dcim/inc/cable_termination.html' with terminations=object.a_terminations %}

View File

@ -63,7 +63,7 @@
<td> <td>
{% if total_length %} {% if total_length %}
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} / {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
{{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %} {{ total_length|meters_to_feet|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Feet" %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
{% endif %} {% endif %}

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Console Port" %}</h2> <h2 class="card-header">{% trans "Console Port" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -50,7 +50,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2> <h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %} {% if object.mark_connected %}

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Console Server Port" %}</h2> <h2 class="card-header">{% trans "Console Server Port" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -50,7 +50,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2> <h2 class="card-header">{% trans "Connection" %}</h2>
<div class="card-body"> <div class="card-body">

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Device Bay" %}</h2> <h2 class="card-header">{% trans "Device Bay" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -38,7 +38,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Installed Device" %}</h2> <h2 class="card-header">{% trans "Installed Device" %}</h2>
{% if object.installed_device %} {% if object.installed_device %}

View File

@ -18,7 +18,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Device Role" %}</h2> <h2 class="card-header">{% trans "Device Role" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -53,7 +53,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}

View File

@ -6,7 +6,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Chassis" %}</h2> <h2 class="card-header">{% trans "Chassis" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -96,7 +96,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Front Port" %}</h2> <h2 class="card-header">{% trans "Front Port" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -64,7 +64,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2> <h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %} {% if object.mark_connected %}

View File

@ -22,7 +22,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Interface" %}</h2> <h2 class="card-header">{% trans "Interface" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -122,7 +122,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %} {% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Addressing" %}</h2> <h2 class="card-header">{% trans "Addressing" %}</h2>

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Inventory Item" %}</h2> <h2 class="card-header">{% trans "Inventory Item" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -70,7 +70,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -10,7 +10,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Inventory Item Role" %}</h2> <h2 class="card-header">{% trans "Inventory Item Role" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -39,7 +39,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -21,7 +21,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Location" %}</h2> <h2 class="card-header">{% trans "Location" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -65,7 +65,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -6,7 +6,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "MAC Address" %}</h2> <h2 class="card-header">{% trans "MAC Address" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -42,7 +42,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -28,7 +28,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Manufacturer" %}</h2> <h2 class="card-header">{% trans "Manufacturer" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -45,7 +45,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -49,7 +49,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Module" %}</h2> <h2 class="card-header">{% trans "Module" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -87,7 +87,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2> <h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Module Bay" %}</h2> <h2 class="card-header">{% trans "Module Bay" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -47,7 +47,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Installed Module" %}</h2> <h2 class="card-header">{% trans "Installed Module" %}</h2>

View File

@ -19,7 +19,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2> <h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -63,7 +63,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Attributes" %}</h2> <h2 class="card-header">{% trans "Attributes" %}</h2>
{% if not object.profile %} {% if not object.profile %}

View File

@ -21,7 +21,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Platform" %}</h2> <h2 class="card-header">{% trans "Platform" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -46,7 +46,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -16,7 +16,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Power Feed" %}</h2> <h2 class="card-header">{% trans "Power Feed" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -105,7 +105,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2> <h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %} {% if object.mark_connected %}

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Power Outlet" %}</h2> <h2 class="card-header">{% trans "Power Outlet" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -68,7 +68,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2> <h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %} {% if object.mark_connected %}

View File

@ -14,7 +14,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Power Panel" %}</h2> <h2 class="card-header">{% trans "Power Panel" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -36,7 +36,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Power Port" %}</h2> <h2 class="card-header">{% trans "Power Port" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -54,7 +54,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2> <h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %} {% if object.mark_connected %}

View File

@ -14,7 +14,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Rack Role" %}</h2> <h2 class="card-header">{% trans "Rack Role" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -37,7 +37,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -8,7 +8,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Rack Type" %}</h2> <h2 class="card-header">{% trans "Rack Type" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -35,7 +35,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-6"> <div class="col col-12 col-md-6">
{% include 'dcim/inc/panels/racktype_numbering.html' %} {% include 'dcim/inc/panels/racktype_numbering.html' %}
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Weight" %}</h2> <h2 class="card-header">{% trans "Weight" %}</h2>

View File

@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Rear Port" %}</h2> <h2 class="card-header">{% trans "Rear Port" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -60,7 +60,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2> <h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %} {% if object.mark_connected %}

View File

@ -21,7 +21,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Region" %}</h2> <h2 class="card-header">{% trans "Region" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -44,7 +44,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -23,7 +23,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Site" %}</h2> <h2 class="card-header">{% trans "Site" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -114,7 +114,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %} {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -21,7 +21,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Site Group" %}</h2> <h2 class="card-header">{% trans "Site Group" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -44,7 +44,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -15,7 +15,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-4"> <div class="col col-12 col-md-4">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Virtual Chassis" %}</h2> <h2 class="card-header">{% trans "Virtual Chassis" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -47,7 +47,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-8"> <div class="col col-12 col-md-8">
<div class="card"> <div class="card">
<h2 class="card-header"> <h2 class="card-header">
{% trans "Members" %} {% trans "Members" %}

View File

@ -10,7 +10,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Virtual Device Context" %}</h2> <h2 class="card-header">{% trans "Virtual Device Context" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -68,7 +68,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-5"> <div class="col col-12 col-md-5">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Config Context" %}</h2> <h2 class="card-header">{% trans "Config Context" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -76,7 +76,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="col col-md-7"> <div class="col col-12 col-md-7">
{% include 'inc/sync_warning.html' %} {% include 'inc/sync_warning.html' %}
<div class="card"> <div class="card">
{% include 'extras/inc/configcontext_data.html' with title="Data" data=object.data format=format copyid="data" %} {% include 'extras/inc/configcontext_data.html' with title="Data" data=object.data format=format copyid="data" %}

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2> <h2 class="card-header">{% trans "Config Template" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -67,7 +67,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Environment Parameters" %}</h2> <h2 class="card-header">{% trans "Environment Parameters" %}</h2>
<div class="card-body"> <div class="card-body">

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Custom Field" %}</h2> <h2 class="card-header">{% trans "Custom Field" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
@ -100,7 +100,7 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-12 col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Object Types" %}</h2> <h2 class="card-header">{% trans "Object Types" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">

Some files were not shown because too many files have changed in this diff Show More