Merge branch 'feature' into feature-ip-prefix-link

This commit is contained in:
Daniel Sheppard 2025-07-09 13:00:51 -05:00 committed by GitHub
commit f844ec5703
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
269 changed files with 55862 additions and 48589 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.9 placeholder: v4.3.3
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.9 placeholder: v4.3.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -8,7 +8,7 @@
</h3> </h3>
<h3> <h3>
:jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> &middot; :jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> &middot;
:rescue_worker_helmet: <a href="#rescue_worker_helmet-become-a-maintainer">Become a maintainer</a> &middot; :briefcase: <a href="#briefcase-looking-for-a-job">Work with us!</a> &middot;
:heart: <a href="#heart-other-ways-to-contribute">Other ideas</a> :heart: <a href="#heart-other-ways-to-contribute">Other ideas</a>
</h3> </h3>
</div> </div>
@ -109,21 +109,9 @@ Do you have an idea for something you'd like to build in NetBox, but might not b
Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started! Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
## :rescue_worker_helmet: Become a Maintainer ## :briefcase: Looking for a Job?
We're always looking for motivated individuals to join the maintainers team and help drive NetBox's long-term development. Some of our most sought-after skills include: At [NetBox Labs](https://netboxlabs.com/), we're always looking for highly skilled and motivated people to join our team. While NetBox is a core part of our product lineup, we have an ever-expanding suite of solutions serving the network automation space. Check out our [current openings](https://netboxlabs.com/careers/) to see if you might be a fit!
* Python development with a strong focus on the [Django](https://www.djangoproject.com/) framework
* Expertise working with PostgreSQL databases
* Javascript & TypeScript proficiency
* A knack for web application design (HTML & CSS)
* Familiarity with git and software development best practices
* Excellent attention to detail
* Working experience in the field of network operations & engineering
We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items.
Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team!
## :heart: Other Ways to Contribute ## :heart: Other Ways to Contribute

View File

@ -8,7 +8,7 @@
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a> <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a> <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
<p> <p>
<strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> | <strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> | <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
<strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong> <strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>
</p> </p>

View File

@ -14,6 +14,12 @@ Administrators are encouraged to adhere to industry best practices concerning th
* Prohibit access to your database from clients other than the NetBox application * Prohibit access to your database from clients other than the NetBox application
* Keep your deployment updated to the most recent stable release * Keep your deployment updated to the most recent stable release
## Compliance Reporting
Please note that security compliance reports (e.g. SOC 2) are provided by NetBox Labs only to customers using NetBox Cloud or NetBox Enterprise. They are not available to users of self-hosted NetBox Community Edition.
If you would like to consider upgrading to NetBox Cloud or Enterprise, please contact `sales@netboxlabs.com`.
## Reporting a Suspected Vulnerability ## Reporting a Suspected Vulnerability
If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions: If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions:

View File

@ -140,7 +140,8 @@ strawberry-graphql
# Strawberry GraphQL Django extension # Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases # https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django # See #19771
strawberry-graphql-django==0.60.0
# SVG image rendering (used for rack elevations) # SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

View File

@ -329,6 +329,7 @@
"100base-tx", "100base-tx",
"100base-t1", "100base-t1",
"1000base-t", "1000base-t",
"1000base-sx",
"1000base-lx", "1000base-lx",
"1000base-tx", "1000base-tx",
"2.5gbase-t", "2.5gbase-t",

View File

@ -4,7 +4,7 @@
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:
``` ```
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
@ -16,7 +16,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
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/).
!!! note !!! note
The `sentry-sdk` Python package is required to enable Sentry integration. The `sentry-sdk` Python package is required to enable Sentry integration.

View File

@ -6,7 +6,7 @@
Default: `True` Default: `True`
Setting this to False will disable the GraphQL API. Setting this to `False` will disable the GraphQL API.
--- ---

View File

@ -57,7 +57,7 @@ Sets content for the top banner in the user interface.
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`.
This data enables the project maintainers to estimate how many NetBox deployments exist and track the adoption of new versions over time. Census reporting effects a single HTTP request each time a worker starts. The only data reported by this function are the NetBox version, Python version, and a pseudorandom unique identifier. This data enables the project maintainers to estimate how many NetBox deployments exist and track the adoption of new versions over time. Census reporting effects a single HTTP request each time a worker starts. The only data reported by this function are the NetBox version, Python version, and a pseudorandom unique identifier.
@ -102,7 +102,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
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`.
--- ---
@ -143,7 +143,7 @@ The number of days to retain job results (scripts and reports). Set this to `0`
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.
--- ---
@ -181,7 +181,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
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.
--- ---

View File

@ -35,7 +35,7 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff
## PLUGINS_CATALOG_CONFIG ## PLUGINS_CATALOG_CONFIG
Default: Empty Default: `{}` (Empty)
This parameter controls how individual plugins are displayed in the plugins catalog under Admin > System > Plugins. Adding a plugin to the `hidden` list will omit that plugin from the catalog. Adding a plugin to the `static` list will display the plugin, but not link to the plugin details or upgrade instructions. This parameter controls how individual plugins are displayed in the plugins catalog under Admin > System > Plugins. Adding a plugin to the `hidden` list will omit that plugin from the catalog. Adding a plugin to the `static` list will display the plugin, but not link to the plugin details or upgrade instructions.

View File

@ -1,6 +1,6 @@
# Remote Authentication Settings # Remote Authentication Settings
The configuration parameters listed here control remote authentication for NetBox. Note that `REMOTE_AUTH_ENABLED` must be true in order for these settings to take effect. The configuration parameters listed here control remote authentication for NetBox. Note that `REMOTE_AUTH_ENABLED` must be `True` in order for these settings to take effect.
--- ---
@ -8,7 +8,7 @@ The configuration parameters listed here control remote authentication for NetBo
Default: `False` Default: `False`
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.) If `True`, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
--- ---
@ -16,7 +16,7 @@ If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_G
Default: `False` Default: `False`
If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.) If `True`, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
--- ---
@ -43,7 +43,7 @@ The list of groups to assign a new user account when created using remote authen
Default: `{}` (Empty dictionary) Default: `{}` (Empty dictionary)
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.) A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as `True` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as `False`.)
--- ---

View File

@ -2,12 +2,12 @@
## ALLOWED_HOSTS ## ALLOWED_HOSTS
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attacks](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs). This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attacks](https://docs.djangoproject.com/en/stable/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
!!! note !!! note
This parameter must always be defined as a list or tuple, even if only a single value is provided. This parameter must always be defined as a list or tuple, even if only a single value is provided.
The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)). The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to `True`, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
Example: Example:

View File

@ -5,7 +5,7 @@
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.
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
@ -52,7 +52,7 @@ Although it is not recommended, the default validation rules can be disabled by
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).
--- ---
@ -62,7 +62,7 @@ If True, cross-origin resource sharing (CORS) requests will be accepted from all
These settings specify a list of origins that are authorized to make cross-site API requests. Use These settings specify a list of origins that are authorized to make cross-site API requests. Use
`CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular
expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example: expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is `True`.) For example:
```python ```python
CORS_ORIGIN_WHITELIST = [ CORS_ORIGIN_WHITELIST = [
@ -84,7 +84,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
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.
--- ---
@ -92,7 +92,7 @@ If true, the cookie employed for cross-site request forgery (CSRF) protection wi
Default: `[]` Default: `[]`
Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://).
```python ```python
CSRF_TRUSTED_ORIGINS = ( CSRF_TRUSTED_ORIGINS = (
@ -135,7 +135,7 @@ DEFAULT_PERMISSIONS = {
## EXEMPT_VIEW_PERMISSIONS ## EXEMPT_VIEW_PERMISSIONS
Default: Empty list Default: `[]` (Empty list)
A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous. A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous.
@ -164,7 +164,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
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.
Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely. Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
@ -191,7 +191,7 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
## LOGIN_FORM_HIDDEN ## LOGIN_FORM_HIDDEN
Default: False Default: `False`
Option to hide the login form when only SSO authentication is in use. Option to hide the login form when only SSO authentication is in use.
@ -212,7 +212,7 @@ The view name or URL to which a user is redirected after logging out.
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.
--- ---
@ -220,7 +220,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T
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.
--- ---
@ -236,7 +236,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict
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.
!!! warning !!! warning
Ensure that your frontend HTTP daemon has been configured to forward the HTTP scheme correctly before enabling this option. An incorrectly configured frontend may result in a looping redirect. Ensure that your frontend HTTP daemon has been configured to forward the HTTP scheme correctly before enabling this option. An incorrectly configured frontend may result in a looping redirect.
@ -255,7 +255,7 @@ The name used for the session cookie. See the [Django documentation](https://doc
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.
--- ---

View File

@ -95,7 +95,7 @@ Default: `('127.0.0.1', '::1')`
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
addresses (and [`DEBUG`](./development.md#debug) is true). addresses (and [`DEBUG`](./development.md#debug) is `True`).
--- ---
@ -103,7 +103,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true).
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.
!!! note !!! note
If Internet access is available via a proxy, set [`HTTP_PROXIES`](#http_proxies) instead. If Internet access is available via a proxy, set [`HTTP_PROXIES`](#http_proxies) instead.
@ -114,7 +114,7 @@ Set this configuration parameter to True for NetBox deployments which do not hav
Default: `{}` Default: `{}`
A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: A dictionary of custom Jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
```python ```python
def uppercase(x): def uppercase(x):

View File

@ -53,6 +53,8 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions. * Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly. * Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
* Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)
### Manually Perform a New Install ### Manually Perform a New Install
@ -164,7 +166,8 @@ Then, compile these portable (`.po`) files for use in the application:
### Update Version and Changelog ### Update Version and Changelog
* Update the version number and date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable. * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`. * Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release. * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
@ -190,15 +193,3 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones * **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
Once created, the release will become available for users to install. Once created, the release will become available for users to install.
### Update the Public Documentation
After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository.
First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at <https://netboxlabs.com/docs>. The job should take about two minutes.
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
Clear the CDN cache from the [Kinsta](https://my.kinsta.com/) portal. Navigate to _Sites_ / _NetBox Labs_ / _Live_, select _Cache_ in the left-nav, click the _Clear Cache_ button, and confirm the clear operation.
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.

View File

@ -9,7 +9,7 @@ NetBox is the leading solution for modeling and documenting modern networks. By
## :material-server-network: Built for Networks ## :material-server-network: Built for Networks
Unlike general-purpose CMDBs, NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more: Unlike general-purpose configuration management databases (CMDBs), NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more:
* Hierarchical regions, sites, and locations * Hierarchical regions, sites, and locations
* Racks, devices, and device components * Racks, devices, and device components

View File

@ -108,7 +108,7 @@ Open `configuration.py` with your preferred editor to begin configuring NetBox.
### ALLOWED_HOSTS ### ALLOWED_HOSTS
This is a list of the valid hostnames and IP addresses by which this server can be reached. You must specify at least one name or IP address. (Note that this does not restrict the locations from which NetBox may be accessed: It is merely for [HTTP host header validation](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting).) This is a list of the valid hostnames and IP addresses by which this server can be reached. You must specify at least one name or IP address. (Note that this does not restrict the locations from which NetBox may be accessed: It is merely for [HTTP host header validation](https://docs.djangoproject.com/en/stable/topics/security/#host-headers-virtual-hosting).)
```python ```python
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123'] ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']

View File

@ -28,7 +28,7 @@ NetBox ships with a default configuration file for uWSGI. To use it, copy `/opt/
sudo cp /opt/netbox/contrib/uwsgi.ini /opt/netbox/uwsgi.ini sudo cp /opt/netbox/contrib/uwsgi.ini /opt/netbox/uwsgi.ini
``` ```
While the provided configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the uWSGI documentation](https://uwsgi-docs-additions.readthedocs.io/en/latest/Options.html) for the available configuration parameters and take a minute to review the [Things to know](https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html) page. Django also provides [additional documentation](https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/uwsgi/) on configuring uWSGI with a Django app. While the provided configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the uWSGI documentation](https://uwsgi-docs-additions.readthedocs.io/en/latest/Options.html) for the available configuration parameters and take a minute to review the [Things to know](https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html) page. Django also provides [additional documentation](https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/uwsgi/) on configuring uWSGI with a Django app.
## systemd Setup ## systemd Setup

View File

@ -122,7 +122,7 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
### Option B: Check Out a Git Release ### Option B: Check Out a Git Release
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command: This guide assumes that NetBox is installed in `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
``` ```
git ls-remote --tags https://github.com/netbox-community/netbox.git \ git ls-remote --tags https://github.com/netbox-community/netbox.git \
@ -134,6 +134,8 @@ git ls-remote --tags https://github.com/netbox-community/netbox.git \
Check out the desired release by specifying its tag. For example: Check out the desired release by specifying its tag. For example:
``` ```
cd /opt/netbox && \
sudo git fetch && \
sudo git checkout v4.2.7 sudo git checkout v4.2.7
``` ```

View File

@ -10,11 +10,11 @@ The assignment of platforms to devices is an optional feature, and may be disreg
### Name ### Name
A unique human-friendly name. A human-friendly name for the platform. Must be unique per manufacturer.
### Slug ### Slug
A unique URL-friendly identifier. (This value can be used for filtering.) A URL-friendly identifier; must be unique per manufacturer. (This value can be used for filtering.)
### Manufacturer ### Manufacturer

View File

@ -22,7 +22,7 @@ from netbox.plugins import PluginConfig
### ContentType renamed to ObjectType ### ContentType renamed to ObjectType
NetBox's proxy model for Django's [ContentType model](https://docs.djangoproject.com/en/5.0/ref/contrib/contenttypes/#the-contenttype-model) has been renamed to ObjectType for clarity. In general, plugins should use the ObjectType proxy when referencing content types, as it includes several custom manager methods. The one exception to this is when defining [generic foreign keys](https://docs.djangoproject.com/en/5.0/ref/contrib/contenttypes/#generic-relations): The ForeignKey field used for a GFK should point to Django's native ContentType. NetBox's proxy model for Django's [ContentType model](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/#the-contenttype-model) has been renamed to ObjectType for clarity. In general, plugins should use the ObjectType proxy when referencing content types, as it includes several custom manager methods. The one exception to this is when defining [generic foreign keys](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/#generic-relations): The ForeignKey field used for a GFK should point to Django's native ContentType.
Additionally, plugin maintainers are strongly encouraged to adopt the "object type" terminology for field and filter names wherever feasible to be consistent with NetBox core (however this is not required for compatibility). Additionally, plugin maintainers are strongly encouraged to adopt the "object type" terminology for field and filter names wherever feasible to be consistent with NetBox core (however this is not required for compatibility).

View File

@ -1,6 +1,6 @@
# Views # Views
## Writing Views ## Writing Basic Views
If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`. If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`.
@ -47,9 +47,13 @@ A URL pattern has three components:
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
## NetBox Model Views
NetBox provides several generic view classes and additional helper functions, to simplify the implementation of plugin logic. These are recommended to be used whenever possible to keep the maintenance overhead of plugins low.
### View Classes ### View Classes
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. Generic view classes (documented below) facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
| View Class | Description | | View Class | Description |
|----------------------|--------------------------------------------------------| |----------------------|--------------------------------------------------------|
@ -60,23 +64,57 @@ NetBox provides several generic view classes (documented below) to facilitate co
| `ObjectListView` | View a list of objects | | `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects | | `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects | | `BulkEditView` | Edit multiple objects |
| `BulkRenameView` | Rename multiple objects |
| `BulkDeleteView` | Delete multiple objects | | `BulkDeleteView` | Delete multiple objects |
!!! warning !!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
#### Example Usage ### URL registration
The NetBox URL registration process has two parts:
1. View classes can be decorated with `@register_model_view()`. This registers a new URL for the model.
2. All of a model's URLs can be included in `urls.py` using the `get_model_urls()` function. This call is usually required twice: once to import general views for the model and again to import model detail views tied to the object's primary key.
::: utilities.views.register_model_view
!!! note "Changed in NetBox v4.2"
In NetBox v4.2, the `register_model_view()` function was extended to support the registration of list views by passing `detail=False`.
::: utilities.urls.get_model_urls
!!! note "Changed in NetBox v4.2"
In NetBox v4.2, the `get_model_urls()` function was extended to support retrieving registered general model views (e.g. for listing objects) by passing `detail=False`.
### Example Usage
```python ```python
# views.py # views.py
from netbox.views.generic import ObjectEditView from netbox.views.generic import ObjectEditView
from utilities.views import register_model_view
from .models import Thing from .models import Thing
@register_model_view(Thing, name='add', detail=False)
@register_model_view(Thing, name='edit')
class ThingEditView(ObjectEditView): class ThingEditView(ObjectEditView):
queryset = Thing.objects.all() queryset = Thing.objects.all()
template_name = 'myplugin/thing.html' template_name = 'myplugin/thing.html'
... ...
``` ```
```python
# urls.py
from django.urls import include, path
from utilities.urls import get_model_urls
urlpatterns = [
path('thing/', include(get_model_urls('myplugin', 'thing', detail=False))),
path('thing/<int:pk>/', include(get_model_urls('myplugin', 'thing'))),
...
]
```
## Object Views ## Object Views
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly. Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
@ -134,6 +172,10 @@ Below are the class definitions for NetBox's multi-object views. These views han
options: options:
members: false members: false
::: netbox.views.generic.BulkRenameView
options:
members: false
::: netbox.views.generic.BulkDeleteView ::: netbox.views.generic.BulkDeleteView
options: options:
members: members:
@ -143,6 +185,9 @@ Below are the class definitions for NetBox's multi-object views. These views han
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path. These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
!!! note
These feature views are automatically registered for all models that implement the respective feature. There is usually no need to override them. However, if that's the case, the URL must be registered manually in `urls.py` instead of using the `register_model_view()` function or decorator.
::: netbox.views.generic.ObjectChangeLogView ::: netbox.views.generic.ObjectChangeLogView
options: options:
members: members:
@ -157,7 +202,7 @@ These views are provided to enable or enhance certain NetBox model features, suc
### Additional Tabs ### Additional Tabs
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict: Plugins can "attach" a custom view to a NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
```python ```python
from dcim.models import Site from dcim.models import Site
@ -185,11 +230,6 @@ class MyView(generic.ObjectView):
) )
``` ```
!!! note "Changed in NetBox v4.2"
The `register_model_view()` function was extended in NetBox v4.2 to support registration of list views by passing `detail=False`.
::: utilities.views.register_model_view
::: utilities.views.ViewTab ::: utilities.views.ViewTab
### Extra Template Content ### Extra Template Content

View File

@ -86,3 +86,69 @@ netbox=> DELETE FROM django_migrations WHERE app='pluginname';
!!! warning !!! warning
Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions. Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
## Clean Up Content Types and Permissions
After removing a plugin and its database tables, you may find that object type references (`ContentTypes`) created by the plugin still appear in the permissions management section (e.g., when editing permissions in the NetBox UI).
This happens because the `django_content_type` table retains entries for the models that the plugin registered with Django.
!!! warning
Please use caution when removing `ContentTypes`. It is strongly recommended to **back up your database** before making these changes.
**Identify Stale Content Types:**
Open the Django shell to inspect lingering `ContentType` entries related to the removed plugin.
Typically, the Content Type's `app_label` matches the plugins name.
```no-highlight
$ cd /opt/netbox/
$ source /opt/netbox/venv/bin/activate
(venv) $ python3 netbox/manage.py nbshell
```
Then, in the shell:
```no-highlight
from django.contrib.contenttypes.models import ContentType
# Replace 'pluginname' with your plugin's actual name
stale_types = ContentType.objects.filter(app_label="pluginname")
for ct in stale_types:
print(ct)
### ^^^ These will be removed, make sure its ok
```
!!! warning
Review the output carefully and confirm that each listed Content Type is related to the plugin you removed.
**Remove Stale Content Types and Related Permissions:**
Next, check for any permissions associated with these Content Types:
```no-highlight
from django.contrib.auth.models import Permission
for ct in stale_types:
perms = Permission.objects.filter(content_type=ct)
print(list(perms))
```
If there are related Permissions, you can remove them safely:
```no-highlight
for ct in stale_types:
Permission.objects.filter(content_type=ct).delete()
```
After removing any related permissions, delete the Content Type entries:
```no-highlight
stale_types.delete()
```
**Restart NetBox:**
After making these changes, restart the NetBox service to ensure all changes are reflected.
```no-highlight
sudo systemctl restart netbox
```

View File

@ -1,4 +1,90 @@
## v4.3.0-beta2 (2025-04-23) # NetBox v4.3
## v4.3.3 (2025-06-26)
### Enhancements
* [#17183](https://github.com/netbox-community/netbox/issues/17183) - Enable associating tags with object types during bulk import
* [#17719](https://github.com/netbox-community/netbox/issues/17719) - Introduce a user preference for table row striping
* [#19492](https://github.com/netbox-community/netbox/issues/19492) - Add a UI button to download the output of an executed custom script
* [#19499](https://github.com/netbox-community/netbox/issues/19499) - Support qualifying interfaces by parent device when bulk importing wireless links
### Bug Fixes
* [#19529](https://github.com/netbox-community/netbox/issues/19529) - Fix support for running custom scripts via the `runscript` management command
* [#19555](https://github.com/netbox-community/netbox/issues/19555) - Fix support for `schedule_at` when invoking a custom script via the REST API
* [#19617](https://github.com/netbox-community/netbox/issues/19617) - Ensure consistent styling of "connect" buttons in UI
* [#19640](https://github.com/netbox-community/netbox/issues/19640) - Restore ability to filter FHRP group assignments by device/VM in GraphQL API
* [#19644](https://github.com/netbox-community/netbox/issues/19644) - Atomic transactions should always employ database routing
* [#19659](https://github.com/netbox-community/netbox/issues/19659) - Populate initial device/VM selection for "add a service" button
* [#19665](https://github.com/netbox-community/netbox/issues/19665) - Correct field reference in wireless link model validation
* [#19667](https://github.com/netbox-community/netbox/issues/19667) - Fix `TypeError` exception when creating a new module profile type with no schema
* [#19673](https://github.com/netbox-community/netbox/issues/19673) - Ignore custom field references when compiling table prefetches
* [#19677](https://github.com/netbox-community/netbox/issues/19677) - Fix exception when passing null value to `present_in_vrf` filter
* [#19680](https://github.com/netbox-community/netbox/issues/19680) - Correct chronological ordering of change records resulting from device deletions
* [#19687](https://github.com/netbox-community/netbox/issues/19687) - Cellular interface types should be considered non-connectable
* [#19702](https://github.com/netbox-community/netbox/issues/19702) - Fix `DoesNotExist` exception when deleting a notification group with an associated event rule
* [#19745](https://github.com/netbox-community/netbox/issues/19745) - Fix bulk import of services with IP addresses assigned to FHRP groups
---
## v4.3.2 (2025-06-05)
### Enhancements
* [#19200](https://github.com/netbox-community/netbox/issues/19200) - Display assigned virtual chassis (if any) on device view
* [#19461](https://github.com/netbox-community/netbox/issues/19461) - Add color backgrounds for virtual circuit types
* [#19605](https://github.com/netbox-community/netbox/issues/19605) - Enable filtering IP addresses by family in GraphQL API
* [#19627](https://github.com/netbox-community/netbox/issues/19627) - Introduce object change migrators
### Bug Fixes
* [#19415](https://github.com/netbox-community/netbox/issues/19415) - Increase maximum supported distance for circuits and wireless links
* [#19475](https://github.com/netbox-community/netbox/issues/19475) - VLANs belonging to the same location as a VM's cluster should be eligible for assignment to interfaces on that VM
* [#19486](https://github.com/netbox-community/netbox/issues/19486) - Fix connection card rendering for console server ports
* [#19487](https://github.com/netbox-community/netbox/issues/19487) - Fix `FieldError` exception when ordering circuit or tunnel terminations by the terminating object
* [#19490](https://github.com/netbox-community/netbox/issues/19490) - Fix inclusion support for config templates populated via a data source
* [#19496](https://github.com/netbox-community/netbox/issues/19496) - Fix `AttributeError` exception when rendering a config template with no output
* [#19510](https://github.com/netbox-community/netbox/issues/19510) - Restore GraphQL API filtering for assigned IP addresses
* [#19520](https://github.com/netbox-community/netbox/issues/19520) - Restore ability to alter prefix scope via the REST API
* [#19587](https://github.com/netbox-community/netbox/issues/19587) - The `occupied` filter should include interfaces terminating a wireless link
* [#19599](https://github.com/netbox-community/netbox/issues/19599) - Fix `AttributeError` exception when sorting change history under user view
* [#19610](https://github.com/netbox-community/netbox/issues/19610) - Fix `FieldError` exception when sorting tunnel terminations by tenant
* [#19623](https://github.com/netbox-community/netbox/issues/19623) - Display description under provider account view
---
## v4.3.1 (2025-05-13)
### Enhancements
* [#17073](https://github.com/netbox-community/netbox/issues/17073) - Enable global search for tags
* [#18419](https://github.com/netbox-community/netbox/issues/18419) - Enable specifying a queue name when calling `Job.enqueue()`
* [#19416](https://github.com/netbox-community/netbox/issues/19416) - Add the 1000BASE-SX interface type
* [#19434](https://github.com/netbox-community/netbox/issues/19434) - Add pre-populated interface speed choices for 2.5 and 5 Gbps
### Bug Fixes
* [#17107](https://github.com/netbox-community/netbox/issues/17107) - Fix cosmetic issue in cable traces ending at a provider network
* [#19309](https://github.com/netbox-community/netbox/issues/19309) - Improve REST API query performance for prefixes and IP addresses
* [#19361](https://github.com/netbox-community/netbox/issues/19361) - Fix incorrect GraphQL object types
* [#19375](https://github.com/netbox-community/netbox/issues/19375) - Fix table configuration after applying a saved table config
* [#19376](https://github.com/netbox-community/netbox/issues/19376) - Fix `FieldDoesNotExist` exception when global search results include a contact
* [#19380](https://github.com/netbox-community/netbox/issues/19380) - Fix column selections for child object tables
* [#19381](https://github.com/netbox-community/netbox/issues/19381) - Fix syncing of custom scripts from a remote data source
* [#19396](https://github.com/netbox-community/netbox/issues/19396) - Enable nullifying VLAN `qinq_role` via the REST API
* [#19397](https://github.com/netbox-community/netbox/issues/19397) - Correct enum type for IPRangeFilter in GraphQL API
* [#19432](https://github.com/netbox-community/netbox/issues/19432) - Update minimum required PostgreSQL version referenced by server error page
* [#19440](https://github.com/netbox-community/netbox/issues/19440) - Ensure data migrations use the correct database connection
* [#19444](https://github.com/netbox-community/netbox/issues/19444) - Fix change logging for contact group assignments
* [#19463](https://github.com/netbox-community/netbox/issues/19463) - Hide button dropdown for tables which do not support saved configs
* [#19464](https://github.com/netbox-community/netbox/issues/19464) - Fix bulk editing of inventory items from device view
* [#19465](https://github.com/netbox-community/netbox/issues/19465) - Fix ability to clear assigned prefix scope in UI
* [#19472](https://github.com/netbox-community/netbox/issues/19472) - Fix device column rendering in virtual device contexts table
---
## v4.3.0 (2025-05-01)
### Breaking Changes ### Breaking Changes
@ -7,6 +93,7 @@
* The `ALLOW_TOKEN_RETRIEVAL` configuration parameter now defaults to False. * The `ALLOW_TOKEN_RETRIEVAL` configuration parameter now defaults to False.
* The `device` and `virtual_machine` foreign keys on the Service model have been replaced with a generic `parent` relationship to support the assignment of services to FHRP groups as well. * The `device` and `virtual_machine` foreign keys on the Service model have been replaced with a generic `parent` relationship to support the assignment of services to FHRP groups as well.
* The `group` foreign key on the Contact model has been replaced with a many-to-many `groups` field. * The `group` foreign key on the Contact model has been replaced with a many-to-many `groups` field.
* `django-storages` is now a required dependency. (It will be installed automatically on upgrade.)
* PluginTemplateExtension no longer supports registration via the singular `model` attribute (use `models` instead). * PluginTemplateExtension no longer supports registration via the singular `model` attribute (use `models` instead).
* The legacy staged changes functionality has been removed. * The legacy staged changes functionality has been removed.
@ -58,6 +145,7 @@ User can now declare one or more proxy routers via the `PROXY_ROUTERS` configura
* [#18780](https://github.com/netbox-community/netbox/issues/18780) - Introduce `DATABASES` and `DATABASE_ROUTERS` configuration parameters to enable defining connections to external databases (e.g. for plugins) * [#18780](https://github.com/netbox-community/netbox/issues/18780) - Introduce `DATABASES` and `DATABASE_ROUTERS` configuration parameters to enable defining connections to external databases (e.g. for plugins)
* [#18783](https://github.com/netbox-community/netbox/issues/18783) - Enable filtering all applicable models by tag ID * [#18783](https://github.com/netbox-community/netbox/issues/18783) - Enable filtering all applicable models by tag ID
* [#18785](https://github.com/netbox-community/netbox/issues/18785) - Enable custom choices for rack, device, and module airflow * [#18785](https://github.com/netbox-community/netbox/issues/18785) - Enable custom choices for rack, device, and module airflow
* [#18896](https://github.com/netbox-community/netbox/issues/18896) - Enable the use of remote storage for custom scripts
### Plugins ### Plugins
@ -67,15 +155,6 @@ User can now declare one or more proxy routers via the `PROXY_ROUTERS` configura
* [#18305](https://github.com/netbox-community/netbox/issues/18305) - Introduce plugin support for ContactsMixin * [#18305](https://github.com/netbox-community/netbox/issues/18305) - Introduce plugin support for ContactsMixin
* [#19073](https://github.com/netbox-community/netbox/issues/19073) - Allow installed plugins to be omitted from the plugins list * [#19073](https://github.com/netbox-community/netbox/issues/19073) - Allow installed plugins to be omitted from the plugins list
### Bug Fixes (From beta1)
* [#19213](https://github.com/netbox-community/netbox/issues/19213) - Fix rendering of dropdown selection form fields
* [#19224](https://github.com/netbox-community/netbox/issues/19224) - Fix GraphQL API support for custom field choices
* [#19225](https://github.com/netbox-community/netbox/issues/19225) - Restore missing GraphQL API filters
* [#19263](https://github.com/netbox-community/netbox/issues/19263) - Render action buttons only if the record model matches the table model
* [#19264](https://github.com/netbox-community/netbox/issues/19264) - Support table configs on child object list views
* [#19266](https://github.com/netbox-community/netbox/issues/19266) - Fix copy-to-clipboard button for IP addresses
### Other Changes ### Other Changes
* [#18071](https://github.com/netbox-community/netbox/issues/18071) - Removed legacy staged changed functionality in favor of the [netbox-branching](https://github.com/netboxlabs/netbox-branching) plugin * [#18071](https://github.com/netbox-community/netbox/issues/18071) - Removed legacy staged changed functionality in favor of the [netbox-branching](https://github.com/netboxlabs/netbox-branching) plugin
@ -83,7 +162,7 @@ User can now declare one or more proxy routers via the `PROXY_ROUTERS` configura
* [#18191](https://github.com/netbox-community/netbox/issues/18191) - Remove redundant PostgreSQL indexes * [#18191](https://github.com/netbox-community/netbox/issues/18191) - Remove redundant PostgreSQL indexes
* [#18236](https://github.com/netbox-community/netbox/issues/18236) - Upgrade the HTMX library to v2.0 * [#18236](https://github.com/netbox-community/netbox/issues/18236) - Upgrade the HTMX library to v2.0
* [#18540](https://github.com/netbox-community/netbox/issues/18540) - Operational plugins are now recorded in the application registry * [#18540](https://github.com/netbox-community/netbox/issues/18540) - Operational plugins are now recorded in the application registry
* [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.0 * [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.2
* [#18743](https://github.com/netbox-community/netbox/issues/18743) - Upgrade Django to v5.2 * [#18743](https://github.com/netbox-community/netbox/issues/18743) - Upgrade Django to v5.2
* [#18751](https://github.com/netbox-community/netbox/issues/18751) - Change the default value for `ALLOW_TOKEN_RETRIEVAL` to False * [#18751](https://github.com/netbox-community/netbox/issues/18751) - Change the default value for `ALLOW_TOKEN_RETRIEVAL` to False
* [#18808](https://github.com/netbox-community/netbox/issues/18808) - Squashed migration dependencies have been altered to rectify an issue with Django's `sqlmigrate` management command * [#18808](https://github.com/netbox-community/netbox/issues/18808) - Squashed migration dependencies have been altered to rectify an issue with Django's `sqlmigrate` management command

View File

@ -191,12 +191,10 @@ class ProfileView(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
# Compile changelog table # Compile changelog table
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=request.user)[:20]
user=request.user
).prefetch_related(
'changed_object_type'
)[:20]
changelog_table = ObjectChangeTable(changelog) changelog_table = ObjectChangeTable(changelog)
changelog_table.orderable = False
changelog_table.configure(request)
return render(request, self.template_name, { return render(request, self.template_name, {
'changelog_table': changelog_table, 'changelog_table': changelog_table,

View File

@ -16,6 +16,7 @@ from utilities.forms import get_field_value
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
) )
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle from utilities.templatetags.builtins.filters import bettertitle
@ -105,7 +106,7 @@ class CircuitTypeForm(NetBoxModelForm):
] ]
class CircuitForm(TenancyForm, NetBoxModelForm): class CircuitForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'), label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),

View File

@ -41,7 +41,7 @@ __all__ = (
) )
@strawberry_django.filter(models.CircuitTermination, lookups=True) @strawberry_django.filter_type(models.CircuitTermination, lookups=True)
class CircuitTerminationFilter( class CircuitTerminationFilter(
BaseObjectTypeFilterMixin, BaseObjectTypeFilterMixin,
CustomFieldsFilterMixin, CustomFieldsFilterMixin,
@ -87,7 +87,7 @@ class CircuitTerminationFilter(
) )
@strawberry_django.filter(models.Circuit, lookups=True) @strawberry_django.filter_type(models.Circuit, lookups=True)
class CircuitFilter( class CircuitFilter(
ContactFilterMixin, ContactFilterMixin,
ImageAttachmentFilterMixin, ImageAttachmentFilterMixin,
@ -121,17 +121,17 @@ class CircuitFilter(
) )
@strawberry_django.filter(models.CircuitType, lookups=True) @strawberry_django.filter_type(models.CircuitType, lookups=True)
class CircuitTypeFilter(BaseCircuitTypeFilterMixin): class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass pass
@strawberry_django.filter(models.CircuitGroup, lookups=True) @strawberry_django.filter_type(models.CircuitGroup, lookups=True)
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
pass pass
@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True) @strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
class CircuitGroupAssignmentFilter( class CircuitGroupAssignmentFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
): ):
@ -148,7 +148,7 @@ class CircuitGroupAssignmentFilter(
) )
@strawberry_django.filter(models.Provider, lookups=True) @strawberry_django.filter_type(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin): class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -158,7 +158,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
) )
@strawberry_django.filter(models.ProviderAccount, lookups=True) @strawberry_django.filter_type(models.ProviderAccount, lookups=True)
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin): class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -168,7 +168,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ProviderNetwork, lookups=True) @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
class ProviderNetworkFilter(PrimaryModelFilterMixin): class ProviderNetworkFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@ -178,12 +178,12 @@ class ProviderNetworkFilter(PrimaryModelFilterMixin):
service_id: FilterLookup[str] | None = strawberry_django.filter_field() service_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VirtualCircuitType, lookups=True) @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin): class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass pass
@strawberry_django.filter(models.VirtualCircuit, lookups=True) @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin): class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
cid: FilterLookup[str] | None = strawberry_django.filter_field() cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@ -206,7 +206,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
) )
@strawberry_django.filter(models.VirtualCircuitTermination, lookups=True) @strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
class VirtualCircuitTerminationFilter( class VirtualCircuitTerminationFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
): ):

View File

@ -8,10 +8,11 @@ def set_null_values(apps, schema_editor):
Circuit = apps.get_model('circuits', 'Circuit') Circuit = apps.get_model('circuits', 'Circuit')
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment') CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination') CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
db_alias = schema_editor.connection.alias
Circuit.objects.filter(distance_unit='').update(distance_unit=None) Circuit.objects.using(db_alias).filter(distance_unit='').update(distance_unit=None)
CircuitGroupAssignment.objects.filter(priority='').update(priority=None) CircuitGroupAssignment.objects.using(db_alias).filter(priority='').update(priority=None)
CircuitTermination.objects.filter(cable_end='').update(cable_end=None) CircuitTermination.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,4 +1,5 @@
import django.db.models.deletion import django.db.models.deletion
from django.contrib.contenttypes.models import ContentType
from django.db import migrations, models from django.db import migrations, models
@ -8,14 +9,15 @@ def copy_site_assignments(apps, schema_editor):
""" """
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination') CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork')
Site = apps.get_model('dcim', 'Site') Site = apps.get_model('dcim', 'Site')
db_alias = schema_editor.connection.alias
CircuitTermination.objects.filter(site__isnull=False).update( CircuitTermination.objects.using(db_alias).filter(site__isnull=False).update(
termination_type=ContentType.objects.get_for_model(Site), termination_id=models.F('site_id') termination_type=ContentType.objects.get_for_model(Site), termination_id=models.F('site_id')
) )
ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork') CircuitTermination.objects.using(db_alias).filter(provider_network__isnull=False).update(
CircuitTermination.objects.filter(provider_network__isnull=False).update(
termination_type=ContentType.objects.get_for_model(ProviderNetwork), termination_type=ContentType.objects.get_for_model(ProviderNetwork),
termination_id=models.F('provider_network_id'), termination_id=models.F('provider_network_id'),
) )
@ -48,3 +50,26 @@ class Migration(migrations.Migration):
# Copy over existing site assignments # Copy over existing site assignments
migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop), migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop),
] ]
def oc_circuittermination_termination(objectchange, reverting):
site_ct = ContentType.objects.get_by_natural_key('dcim', 'site').pk
provider_network_ct = ContentType.objects.get_by_natural_key('circuits', 'providernetwork').pk
for data in (objectchange.prechange_data, objectchange.postchange_data):
if data is None:
continue
if site_id := data.get('site'):
data.update({
'termination_type': site_ct,
'termination_id': site_id,
})
elif provider_network_id := data.get('provider_network'):
data.update({
'termination_type': provider_network_ct,
'termination_id': provider_network_id,
})
objectchange_migrators = {
'circuits.circuittermination': oc_circuittermination_termination,
}

View File

@ -7,15 +7,20 @@ def populate_denormalized_fields(apps, schema_editor):
Copy site ForeignKey values to the Termination GFK. Copy site ForeignKey values to the Termination GFK.
""" """
CircuitTermination = apps.get_model('circuits', 'CircuitTermination') CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
db_alias = schema_editor.connection.alias
terminations = CircuitTermination.objects.filter(site__isnull=False).prefetch_related('site') terminations = CircuitTermination.objects.using(db_alias).filter(site__isnull=False).prefetch_related('site')
for termination in terminations: for termination in terminations:
termination._region_id = termination.site.region_id termination._region_id = termination.site.region_id
termination._site_group_id = termination.site.group_id termination._site_group_id = termination.site.group_id
termination._site_id = termination.site_id termination._site_id = termination.site_id
# Note: Location cannot be set prior to migration # Note: Location cannot be set prior to migration
CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'], batch_size=100) CircuitTermination.objects.using(db_alias).bulk_update(
terminations,
['_region', '_site_group', '_site'],
batch_size=100
)
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -81,3 +86,15 @@ class Migration(migrations.Migration):
new_name='_provider_network', new_name='_provider_network',
), ),
] ]
def oc_circuittermination_remove_fields(objectchange, reverting):
for data in (objectchange.prechange_data, objectchange.postchange_data):
if data is not None:
data.pop('site', None)
data.pop('provider_network', None)
objectchange_migrators = {
'circuits.circuittermination': oc_circuittermination_remove_fields,
}

View File

@ -1,4 +1,5 @@
import django.db.models.deletion import django.db.models.deletion
from django.contrib.contenttypes.models import ContentType
from django.db import migrations, models from django.db import migrations, models
@ -9,8 +10,9 @@ def set_member_type(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')
Circuit = apps.get_model('circuits', 'Circuit') Circuit = apps.get_model('circuits', 'Circuit')
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment') CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
db_alias = schema_editor.connection.alias
CircuitGroupAssignment.objects.update( CircuitGroupAssignment.objects.using(db_alias).update(
member_type=ContentType.objects.get_for_model(Circuit) member_type=ContentType.objects.get_for_model(Circuit)
) )
@ -81,3 +83,21 @@ class Migration(migrations.Migration):
), ),
), ),
] ]
def oc_circuitgroupassignment_member(objectchange, reverting):
circuit_ct = ContentType.objects.get_by_natural_key('circuits', 'circuit').pk
for data in (objectchange.prechange_data, objectchange.postchange_data):
if data is None:
continue
if circuit_id := data.get('circuit'):
data.update({
'member_type': circuit_ct,
'member_id': circuit_id,
})
data.pop('circuit', None)
objectchange_migrators = {
'circuits.circuitgroupassignment': oc_circuitgroupassignment_member,
}

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0051_virtualcircuit_group_assignment'),
]
operations = [
migrations.AlterField(
model_name='circuit',
name='_abs_distance',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=13, null=True),
),
]

View File

@ -120,7 +120,8 @@ class CircuitTerminationTable(NetBoxTable):
) )
termination = tables.Column( termination = tables.Column(
verbose_name=_('Termination Point'), verbose_name=_('Termination Point'),
linkify=True linkify=True,
orderable=False,
) )
# Termination types # Termination types
@ -132,7 +133,7 @@ class CircuitTerminationTable(NetBoxTable):
site_group = tables.Column( site_group = tables.Column(
verbose_name=_('Site Group'), verbose_name=_('Site Group'),
linkify=True, linkify=True,
accessor='_sitegroup' accessor='_site_group'
) )
region = tables.Column( region = tables.Column(
verbose_name=_('Region'), verbose_name=_('Region'),

View File

@ -54,9 +54,8 @@ class VirtualCircuitTable(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_count = columns.LinkedCountColumn( termination_count = columns.LinkedCountColumn(

View File

@ -0,0 +1,23 @@
from django.test import RequestFactory, tag, TestCase
from circuits.models import CircuitTermination
from circuits.tables import CircuitTerminationTable
@tag('regression')
class CircuitTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = CircuitTermination.objects.all()
disallowed = {'actions', }
orderable_columns = [
column.name for column in CircuitTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
for col in orderable_columns:
for dir in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{dir}{col}'
table.as_html(fake_request)

View File

@ -1,10 +1,11 @@
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import router, transaction
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView from dcim.views import PathTraceView
from ipam.models import ASN from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.query import count_related from utilities.query import count_related
@ -79,6 +80,11 @@ class ProviderBulkEditView(generic.BulkEditView):
form = forms.ProviderBulkEditForm form = forms.ProviderBulkEditForm
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
class ProviderBulkRenameView(generic.BulkRenameView):
queryset = Provider.objects.all()
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False) @register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
class ProviderBulkDeleteView(generic.BulkDeleteView): class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
@ -141,6 +147,11 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
form = forms.ProviderAccountBulkEditForm form = forms.ProviderAccountBulkEditForm
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
class ProviderAccountBulkRenameView(generic.BulkRenameView):
queryset = ProviderAccount.objects.all()
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False) @register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
class ProviderAccountBulkDeleteView(generic.BulkDeleteView): class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderAccount.objects.annotate( queryset = ProviderAccount.objects.annotate(
@ -212,6 +223,11 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
form = forms.ProviderNetworkBulkEditForm form = forms.ProviderNetworkBulkEditForm
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
queryset = ProviderNetwork.objects.all()
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False) @register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView): class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
@ -271,6 +287,11 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
form = forms.CircuitTypeBulkEditForm form = forms.CircuitTypeBulkEditForm
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
class CircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = CircuitType.objects.all()
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False) @register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
@ -337,6 +358,12 @@ class CircuitBulkEditView(generic.BulkEditView):
form = forms.CircuitBulkEditForm form = forms.CircuitBulkEditForm
@register_model_view(Circuit, 'bulk_rename', path='rename', detail=False)
class CircuitBulkRenameView(generic.BulkRenameView):
queryset = Circuit.objects.all()
field_name = 'cid'
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False) @register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
class CircuitBulkDeleteView(generic.BulkDeleteView): class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
@ -384,7 +411,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
if termination_a and termination_z: if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
with transaction.atomic(): with transaction.atomic(using=router.db_for_write(CircuitTermination)):
termination_a.term_side = '_' termination_a.term_side = '_'
termination_a.save() termination_a.save()
termination_z.term_side = 'A' termination_z.term_side = 'A'
@ -432,6 +459,7 @@ class CircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.CircuitTerminationFilterSet filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable table = tables.CircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitTermination) @register_model_view(CircuitTermination)
@ -526,6 +554,11 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
form = forms.CircuitGroupBulkEditForm form = forms.CircuitGroupBulkEditForm
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
class CircuitGroupBulkRenameView(generic.BulkRenameView):
queryset = CircuitGroup.objects.all()
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
class CircuitGroupBulkDeleteView(generic.BulkDeleteView): class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroup.objects.all() queryset = CircuitGroup.objects.all()
@ -543,6 +576,7 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
filterset = filtersets.CircuitGroupAssignmentFilterSet filterset = filtersets.CircuitGroupAssignmentFilterSet
filterset_form = forms.CircuitGroupAssignmentFilterForm filterset_form = forms.CircuitGroupAssignmentFilterForm
table = tables.CircuitGroupAssignmentTable table = tables.CircuitGroupAssignmentTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitGroupAssignment) @register_model_view(CircuitGroupAssignment)
@ -635,6 +669,11 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitTypeBulkEditForm form = forms.VirtualCircuitTypeBulkEditForm
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False) @register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView): class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuitType.objects.annotate( queryset = VirtualCircuitType.objects.annotate(
@ -697,6 +736,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitBulkEditForm form = forms.VirtualCircuitBulkEditForm
@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all()
field_name = 'cid'
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView): class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuit.objects.annotate( queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@ -714,6 +759,7 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.VirtualCircuitTerminationFilterSet filterset = filtersets.VirtualCircuitTerminationFilterSet
filterset_form = forms.VirtualCircuitTerminationFilterForm filterset_form = forms.VirtualCircuitTerminationFilterForm
table = tables.VirtualCircuitTerminationTable table = tables.VirtualCircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VirtualCircuitTermination) @register_model_view(VirtualCircuitTermination)

View File

@ -23,7 +23,7 @@ __all__ = (
) )
@strawberry_django.filter(models.DataFile, lookups=True) @strawberry_django.filter_type(models.DataFile, lookups=True)
class DataFileFilter(BaseFilterMixin): class DataFileFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field() id: ID | None = strawberry_django.filter_field()
created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
@ -39,7 +39,7 @@ class DataFileFilter(BaseFilterMixin):
hash: FilterLookup[str] | None = strawberry_django.filter_field() hash: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DataSource, lookups=True) @strawberry_django.filter_type(models.DataSource, lookups=True)
class DataSourceFilter(PrimaryModelFilterMixin): class DataSourceFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
type: FilterLookup[str] | None = strawberry_django.filter_field() type: FilterLookup[str] | None = strawberry_django.filter_field()
@ -56,7 +56,7 @@ class DataSourceFilter(PrimaryModelFilterMixin):
) )
@strawberry_django.filter(models.ObjectChange, lookups=True) @strawberry_django.filter_type(models.ObjectChange, lookups=True)
class ObjectChangeFilter(BaseFilterMixin): class ObjectChangeFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field() id: ID | None = strawberry_django.filter_field()
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
@ -82,7 +82,7 @@ class ObjectChangeFilter(BaseFilterMixin):
) )
@strawberry_django.filter(DjangoContentType, lookups=True) @strawberry_django.filter_type(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseFilterMixin): class ContentTypeFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field() id: ID | None = strawberry_django.filter_field()
app_label: FilterLookup[str] | None = strawberry_django.filter_field() app_label: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -1,7 +1,9 @@
from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.contrib.contenttypes.models import ContentType, ContentTypeManager
from django.db.models import Q from django.db.models import Q
from netbox.plugins import PluginConfig
from netbox.registry import registry from netbox.registry import registry
from utilities.string import title
__all__ = ( __all__ = (
'ObjectType', 'ObjectType',
@ -48,3 +50,29 @@ class ObjectType(ContentType):
class Meta: class Meta:
proxy = True proxy = True
@property
def app_labeled_name(self):
# Override ContentType's "app | model" representation style.
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
@property
def app_verbose_name(self):
if model := self.model_class():
return model._meta.app_config.verbose_name
@property
def model_verbose_name(self):
if model := self.model_class():
return model._meta.verbose_name
@property
def model_verbose_name_plural(self):
if model := self.model_class():
return model._meta.verbose_name_plural
@property
def is_plugin_model(self):
if not (model := self.model_class()):
return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig)

View File

@ -88,19 +88,11 @@ class ManagedFile(SyncedDataMixin, models.Model):
def sync_data(self): def sync_data(self):
if self.data_file: if self.data_file:
self.file_path = os.path.basename(self.data_path) self.file_path = os.path.basename(self.data_path)
self._write_to_disk(self.full_path, overwrite=True)
def _write_to_disk(self, path, overwrite=False): storage = self.storage
"""
Write the object's data to disk at the specified path
"""
# Check whether file already exists
storage = self.storage
if storage.exists(path) and not overwrite:
raise FileExistsError()
with storage.open(path, 'wb+') as new_file: with storage.open(self.full_path, 'wb+') as new_file:
new_file.write(self.data) new_file.write(self.data_file.data)
@cached_property @cached_property
def storage(self): def storage(self):

View File

@ -215,6 +215,7 @@ class Job(models.Model):
schedule_at=None, schedule_at=None,
interval=None, interval=None,
immediate=False, immediate=False,
queue_name=None,
**kwargs **kwargs
): ):
""" """
@ -238,7 +239,7 @@ class Job(models.Model):
object_id = instance.pk object_id = instance.pk
else: else:
object_type = object_id = None object_type = object_id = None
rq_queue_name = get_queue_for_model(object_type.model if object_type else None) rq_queue_name = queue_name if queue_name else get_queue_for_model(object_type.model if object_type else None)
queue = django_rq.get_queue(rq_queue_name) queue = django_rq.get_queue(rq_queue_name)
status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
job = Job( job = Job(

View File

@ -0,0 +1,18 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkSync',
)
class BulkSync(ObjectAction):
"""
Synchronize multiple objects at once.
"""
name = 'bulk_sync'
label = _('Sync Data')
multi = True
permissions_required = {'sync'}
template_name = 'core/buttons/bulk_sync.html'

View File

@ -162,6 +162,12 @@ def handle_deleted_object(sender, instance, **kwargs):
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: elif type(relation) is ManyToOneRel and relation.field.null is True:
setattr(obj, related_field_name, None) setattr(obj, related_field_name, None)
# make sure the object hasn't been deleted - in case of
# deletion chaining of related objects
try:
obj.refresh_from_db()
except DoesNotExist:
continue
obj.save() obj.save()
# Enqueue the object for event processing # Enqueue the object for event processing

View File

@ -9,6 +9,7 @@ from rq.registry import FailedJobRegistry, StartedJobRegistry
from users.models import Token, User from users.models import Token, User
from utilities.testing import APITestCase, APIViewTestCases, TestCase from utilities.testing import APITestCase, APIViewTestCases, TestCase
from utilities.testing.utils import disable_logging
from ..models import * from ..models import *
@ -189,7 +190,8 @@ class BackgroundTaskTestCase(TestCase):
# Enqueue & run a job that will fail # Enqueue & run a job that will fail
job = queue.enqueue(self.dummy_job_failing) job = queue.enqueue(self.dummy_job_failing)
worker = get_worker('default') worker = get_worker('default')
worker.work(burst=True) with disable_logging():
worker.work(burst=True)
self.assertTrue(job.is_failed) self.assertTrue(job.is_failed)
# Re-enqueue the failed job and check that its status has been reset # Re-enqueue the failed job and check that its status has been reset
@ -231,7 +233,8 @@ class BackgroundTaskTestCase(TestCase):
self.assertEqual(job.get_status(), JobStatus.STARTED) self.assertEqual(job.get_status(), JobStatus.STARTED)
response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header) response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started with disable_logging():
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection) started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection)
self.assertEqual(len(started_job_registry), 0) self.assertEqual(len(started_job_registry), 0)

View File

@ -6,12 +6,13 @@ from rest_framework import status
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType from core.models import ObjectChange, ObjectType
from dcim.choices import SiteStatusChoices from dcim.choices import SiteStatusChoices
from dcim.models import Site from dcim.models import Site, CableTermination, Device, DeviceType, DeviceRole, Interface, Cable
from extras.choices import * from extras.choices import *
from extras.models import CustomField, CustomFieldChoiceSet, Tag from extras.models import CustomField, CustomFieldChoiceSet, Tag
from utilities.testing import APITestCase from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, post_data from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase from utilities.testing.views import ModelViewTestCase
from dcim.models import Manufacturer
class ChangeLogViewTest(ModelViewTestCase): class ChangeLogViewTest(ModelViewTestCase):
@ -270,6 +271,81 @@ class ChangeLogViewTest(ModelViewTestCase):
# Check that no ObjectChange records have been created # Check that no ObjectChange records have been created
self.assertEqual(ObjectChange.objects.count(), 0) self.assertEqual(ObjectChange.objects.count(), 0)
def test_ordering_genericrelation(self):
# Create required objects first
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Model 1',
slug='model-1'
)
device_role = DeviceRole.objects.create(
name='Role 1',
slug='role-1'
)
site = Site.objects.create(
name='Site 1',
slug='site-1'
)
# Create two devices
device1 = Device.objects.create(
name='Device 1',
device_type=device_type,
role=device_role,
site=site
)
device2 = Device.objects.create(
name='Device 2',
device_type=device_type,
role=device_role,
site=site
)
# Create interfaces on both devices
interface1 = Interface.objects.create(
device=device1,
name='eth0',
type='1000base-t'
)
interface2 = Interface.objects.create(
device=device2,
name='eth0',
type='1000base-t'
)
# Create a cable between the interfaces
_ = Cable.objects.create(
a_terminations=[interface1],
b_terminations=[interface2],
status='connected'
)
# Delete device1
request = {
'path': reverse('dcim:device_delete', kwargs={'pk': device1.pk}),
'data': post_data({'confirm': True}),
}
self.add_permissions(
'dcim.delete_device',
'dcim.delete_interface',
'dcim.delete_cable',
'dcim.delete_cabletermination'
)
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
# Get the ObjectChange records for delete actions ordered by time
changes = ObjectChange.objects.filter(
action=ObjectChangeActionChoices.ACTION_DELETE
).order_by('time')[:3]
# Verify the order of deletion
self.assertEqual(len(changes), 3)
self.assertEqual(changes[0].changed_object_type, ContentType.objects.get_for_model(CableTermination))
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))
class ChangeLogAPITest(APITestCase): class ChangeLogAPITest(APITestCase):

View File

@ -14,7 +14,7 @@ from core.choices import ObjectChangeActionChoices
from core.models import * from core.models import *
from dcim.models import Site from dcim.models import Site
from users.models import User from users.models import User
from utilities.testing import TestCase, ViewTestCases, create_tags from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@ -271,7 +271,8 @@ class BackgroundTaskTestCase(TestCase):
# Enqueue & run a job that will fail # Enqueue & run a job that will fail
job = queue.enqueue(self.dummy_job_failing) job = queue.enqueue(self.dummy_job_failing)
worker = get_worker('default') worker = get_worker('default')
worker.work(burst=True) with disable_logging():
worker.work(burst=True)
self.assertTrue(job.is_failed) self.assertTrue(job.is_failed)
# Re-enqueue the failed job and check that its status has been reset # Re-enqueue the failed job and check that its status has been reset
@ -317,7 +318,8 @@ class BackgroundTaskTestCase(TestCase):
self.assertEqual(len(started_job_registry), 1) self.assertEqual(len(started_job_registry), 1)
response = self.client.get(reverse('core:background_task_stop', args=[job.id])) response = self.client.get(reverse('core:background_task_stop', args=[job.id]))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started with disable_logging():
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
self.assertEqual(len(started_job_registry), 0) self.assertEqual(len(started_job_registry), 0)
canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection) canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection)

View File

@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from netbox.config import get_config, PARAMS from netbox.config import get_config, PARAMS
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.registry import registry from netbox.registry import registry
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.base import BaseObjectView from netbox.views.generic.base import BaseObjectView
@ -119,6 +120,11 @@ class DataSourceBulkEditView(generic.BulkEditView):
form = forms.DataSourceBulkEditForm form = forms.DataSourceBulkEditForm
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
class DataSourceBulkRenameView(generic.BulkRenameView):
queryset = DataSource.objects.all()
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False) @register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
class DataSourceBulkDeleteView(generic.BulkDeleteView): class DataSourceBulkDeleteView(generic.BulkDeleteView):
queryset = DataSource.objects.annotate( queryset = DataSource.objects.annotate(
@ -138,14 +144,13 @@ class DataFileListView(generic.ObjectListView):
filterset = filtersets.DataFileFilterSet filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable table = tables.DataFileTable
actions = { actions = (BulkDelete,)
'bulk_delete': {'delete'},
}
@register_model_view(DataFile) @register_model_view(DataFile)
class DataFileView(generic.ObjectView): class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all() queryset = DataFile.objects.all()
actions = (DeleteObject,)
@register_model_view(DataFile, 'delete') @register_model_view(DataFile, 'delete')
@ -170,15 +175,13 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm filterset_form = forms.JobFilterForm
table = tables.JobTable table = tables.JobTable
actions = { actions = (BulkExport, BulkDelete)
'export': {'view'},
'bulk_delete': {'delete'},
}
@register_model_view(Job) @register_model_view(Job)
class JobView(generic.ObjectView): class JobView(generic.ObjectView):
queryset = Job.objects.all() queryset = Job.objects.all()
actions = (DeleteObject,)
@register_model_view(Job, 'delete') @register_model_view(Job, 'delete')
@ -204,9 +207,7 @@ class ObjectChangeListView(generic.ObjectListView):
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html' template_name = 'core/objectchange_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
@register_model_view(ObjectChange) @register_model_view(ObjectChange)
@ -223,6 +224,7 @@ class ObjectChangeView(generic.ObjectView):
data=related_changes[:50], data=related_changes[:50],
orderable=False orderable=False
) )
related_changes_table.configure(request)
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
changed_object_type=instance.changed_object_type, changed_object_type=instance.changed_object_type,
@ -273,6 +275,7 @@ class ConfigRevisionListView(generic.ObjectListView):
filterset = filtersets.ConfigRevisionFilterSet filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable table = tables.ConfigRevisionTable
actions = (AddObject, BulkExport)
@register_model_view(ConfigRevision) @register_model_view(ConfigRevision)

View File

@ -461,6 +461,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
Interface.objects.select_related("device", "cable"), Interface.objects.select_related("device", "cable"),
], ],
), ),
'virtual_circuit_termination',
'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination 'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
'ip_addresses', # Referenced by Interface.count_ipaddresses() 'ip_addresses', # Referenced by Interface.count_ipaddresses()
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups() 'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()

View File

@ -874,6 +874,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100ME_T1 = '100base-t1' TYPE_100ME_T1 = '100base-t1'
TYPE_100ME_SFP = '100base-x-sfp' TYPE_100ME_SFP = '100base-x-sfp'
TYPE_1GE_FIXED = '1000base-t' TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_SX_FIXED = '1000base-sx'
TYPE_1GE_LX_FIXED = '1000base-lx' TYPE_1GE_LX_FIXED = '1000base-lx'
TYPE_1GE_TX_FIXED = '1000base-tx' TYPE_1GE_TX_FIXED = '1000base-tx'
TYPE_1GE_GBIC = '1000base-x-gbic' TYPE_1GE_GBIC = '1000base-x-gbic'
@ -1038,6 +1039,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'), (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'), (TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'), (TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_1GE_SX_FIXED, '1000BASE-SX (1GE)'),
(TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'), (TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'),
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'), (TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'), (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
@ -1238,6 +1240,8 @@ class InterfaceSpeedChoices(ChoiceSet):
(10000, '10 Mbps'), (10000, '10 Mbps'),
(100000, '100 Mbps'), (100000, '100 Mbps'),
(1000000, '1 Gbps'), (1000000, '1 Gbps'),
(2500000, '2.5 Gbps'),
(5000000, '5 Gbps'),
(10000000, '10 Gbps'), (10000000, '10 Gbps'),
(25000000, '25 Gbps'), (25000000, '25 Gbps'),
(40000000, '40 Gbps'), (40000000, '40 Gbps'),

View File

@ -53,6 +53,11 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_802151, InterfaceTypeChoices.TYPE_802151,
InterfaceTypeChoices.TYPE_802154, InterfaceTypeChoices.TYPE_802154,
InterfaceTypeChoices.TYPE_OTHER_WIRELESS, InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
InterfaceTypeChoices.TYPE_GSM,
InterfaceTypeChoices.TYPE_CDMA,
InterfaceTypeChoices.TYPE_LTE,
InterfaceTypeChoices.TYPE_4G,
InterfaceTypeChoices.TYPE_5G,
] ]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES

View File

@ -2012,6 +2012,21 @@ class InterfaceFilterSet(
'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
}.get(value, queryset.none()) }.get(value, queryset.none())
# Override the method on CabledObjectFilterSet to also check for wireless links
def filter_occupied(self, queryset, name, value):
if value:
return queryset.filter(
Q(cable__isnull=False) |
Q(wireless_link__isnull=False) |
Q(mark_connected=True)
)
else:
return queryset.filter(
cable__isnull=True,
wireless_link__isnull=True,
mark_connected=False
)
class FrontPortFilterSet( class FrontPortFilterSet(
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,

View File

@ -1779,6 +1779,13 @@ class InventoryItemBulkEditForm(
) )
nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Remove parent device passed as context to avoid conflicts with the actual device field
# on this form (see bug #19464)
self.initial.pop('device', None)
# #
# Device component roles # Device component roles

View File

@ -1507,7 +1507,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tx_power = forms.IntegerField( tx_power = forms.IntegerField(
required=False, required=False,
label=_('Transmit power (dBm)'), label=_('Transmit power (dBm)'),
min_value=0, min_value=-40,
max_value=127 max_value=127
) )
vrf_id = DynamicModelMultipleChoiceField( vrf_id = DynamicModelMultipleChoiceField(

View File

@ -66,6 +66,10 @@ class ScopedForm(forms.Form):
if self.instance and scope_type_id != self.instance.scope_type_id: if self.instance and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None self.initial['scope'] = None
else:
# Clear the initial scope value if scope_type is not set
self.initial['scope'] = None
class ScopedBulkEditForm(forms.Form): class ScopedBulkEditForm(forms.Form):
scope_type = ContentTypeChoiceField( scope_type = ContentTypeChoiceField(

View File

@ -90,7 +90,7 @@ __all__ = (
) )
@strawberry_django.filter(models.Cable, lookups=True) @strawberry_django.filter_type(models.Cable, lookups=True)
class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
@ -107,7 +107,7 @@ class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
) )
@strawberry_django.filter(models.CableTermination, lookups=True) @strawberry_django.filter_type(models.CableTermination, lookups=True)
class CableTerminationFilter(ChangeLogFilterMixin): class CableTerminationFilter(ChangeLogFilterMixin):
cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
cable_id: ID | None = strawberry_django.filter_field() cable_id: ID | None = strawberry_django.filter_field()
@ -120,7 +120,7 @@ class CableTerminationFilter(ChangeLogFilterMixin):
termination_id: ID | None = strawberry_django.filter_field() termination_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ConsolePort, lookups=True) @strawberry_django.filter_type(models.ConsolePort, lookups=True)
class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -130,14 +130,14 @@ class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
) )
@strawberry_django.filter(models.ConsolePortTemplate, lookups=True) @strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin): class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@strawberry_django.filter(models.ConsoleServerPort, lookups=True) @strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -147,14 +147,14 @@ class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectMode
) )
@strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True) @strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin): class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@strawberry_django.filter(models.Device, lookups=True) @strawberry_django.filter_type(models.Device, lookups=True)
class DeviceFilter( class DeviceFilter(
ContactFilterMixin, ContactFilterMixin,
TenancyFilterMixin, TenancyFilterMixin,
@ -271,7 +271,7 @@ class DeviceFilter(
inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field() inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceBay, lookups=True) @strawberry_django.filter_type(models.DeviceBay, lookups=True)
class DeviceBayFilter(ComponentModelFilterMixin): class DeviceBayFilter(ComponentModelFilterMixin):
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -279,12 +279,12 @@ class DeviceBayFilter(ComponentModelFilterMixin):
installed_device_id: ID | None = strawberry_django.filter_field() installed_device_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceBayTemplate, lookups=True) @strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin): class DeviceBayTemplateFilter(ComponentTemplateFilterMixin):
pass pass
@strawberry_django.filter(models.InventoryItemTemplate, lookups=True) @strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -304,13 +304,13 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
part_id: FilterLookup[str] | None = strawberry_django.filter_field() part_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceRole, lookups=True) @strawberry_django.filter_type(models.DeviceRole, lookups=True)
class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin): class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() vm_role: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceType, lookups=True) @strawberry_django.filter_type(models.DeviceType, lookups=True)
class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -382,7 +382,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.FrontPort, lookups=True) @strawberry_django.filter_type(models.FrontPort, lookups=True)
class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -395,7 +395,7 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
) )
@strawberry_django.filter(models.FrontPortTemplate, lookups=True) @strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin): class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -408,7 +408,7 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
) )
@strawberry_django.filter(models.MACAddress, lookups=True) @strawberry_django.filter_type(models.MACAddress, lookups=True)
class MACAddressFilter(PrimaryModelFilterMixin): class MACAddressFilter(PrimaryModelFilterMixin):
mac_address: FilterLookup[str] | None = strawberry_django.filter_field() mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@ -417,7 +417,7 @@ class MACAddressFilter(PrimaryModelFilterMixin):
assigned_object_id: ID | None = strawberry_django.filter_field() assigned_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Interface, lookups=True) @strawberry_django.filter_type(models.Interface, lookups=True)
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin): class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -486,7 +486,7 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin
) )
@strawberry_django.filter(models.InterfaceTemplate, lookups=True) @strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -508,7 +508,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
) )
@strawberry_django.filter(models.InventoryItem, lookups=True) @strawberry_django.filter_type(models.InventoryItem, lookups=True)
class InventoryItemFilter(ComponentModelFilterMixin): class InventoryItemFilter(ComponentModelFilterMixin):
parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -535,12 +535,12 @@ class InventoryItemFilter(ComponentModelFilterMixin):
discovered: FilterLookup[bool] | None = strawberry_django.filter_field() discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.InventoryItemRole, lookups=True) @strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
class InventoryItemRoleFilter(OrganizationalModelFilterMixin): class InventoryItemRoleFilter(OrganizationalModelFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Location, lookups=True) @strawberry_django.filter_type(models.Location, lookups=True)
class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin): class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field() site_id: ID | None = strawberry_django.filter_field()
@ -556,12 +556,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
) )
@strawberry_django.filter(models.Manufacturer, lookups=True) @strawberry_django.filter_type(models.Manufacturer, lookups=True)
class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin): class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
pass pass
@strawberry_django.filter(models.Module, lookups=True) @strawberry_django.filter_type(models.Module, lookups=True)
class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin): class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field() device_id: ID | None = strawberry_django.filter_field()
@ -610,7 +610,7 @@ class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
) )
@strawberry_django.filter(models.ModuleBay, lookups=True) @strawberry_django.filter_type(models.ModuleBay, lookups=True)
class ModuleBayFilter(ModularComponentModelFilterMixin): class ModuleBayFilter(ModularComponentModelFilterMixin):
parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -619,17 +619,17 @@ class ModuleBayFilter(ModularComponentModelFilterMixin):
position: FilterLookup[str] | None = strawberry_django.filter_field() position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleBayTemplate, lookups=True) @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin): class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
position: FilterLookup[str] | None = strawberry_django.filter_field() position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleTypeProfile, lookups=True) @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
class ModuleTypeProfileFilter(PrimaryModelFilterMixin): class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleType, lookups=True) @strawberry_django.filter_type(models.ModuleType, lookups=True)
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -676,7 +676,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
) = strawberry_django.filter_field() ) = strawberry_django.filter_field()
@strawberry_django.filter(models.Platform, lookups=True) @strawberry_django.filter_type(models.Platform, lookups=True)
class PlatformFilter(OrganizationalModelFilterMixin): class PlatformFilter(OrganizationalModelFilterMixin):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -688,7 +688,7 @@ class PlatformFilter(OrganizationalModelFilterMixin):
config_template_id: ID | None = strawberry_django.filter_field() config_template_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerFeed, lookups=True) @strawberry_django.filter_type(models.PowerFeed, lookups=True)
class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -723,7 +723,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
) )
@strawberry_django.filter(models.PowerOutlet, lookups=True) @strawberry_django.filter_type(models.PowerOutlet, lookups=True)
class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -738,7 +738,7 @@ class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerOutletTemplate, lookups=True) @strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
class PowerOutletTemplateFilter(ModularComponentModelFilterMixin): class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -752,7 +752,7 @@ class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
) )
@strawberry_django.filter(models.PowerPanel, lookups=True) @strawberry_django.filter_type(models.PowerPanel, lookups=True)
class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin): class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field() site_id: ID | None = strawberry_django.filter_field()
@ -765,7 +765,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerPort, lookups=True) @strawberry_django.filter_type(models.PowerPort, lookups=True)
class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -778,7 +778,7 @@ class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
) )
@strawberry_django.filter(models.PowerPortTemplate, lookups=True) @strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -791,7 +791,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
) )
@strawberry_django.filter(models.RackType, lookups=True) @strawberry_django.filter_type(models.RackType, lookups=True)
class RackTypeFilter(RackBaseFilterMixin): class RackTypeFilter(RackBaseFilterMixin):
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -804,7 +804,7 @@ class RackTypeFilter(RackBaseFilterMixin):
slug: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Rack, lookups=True) @strawberry_django.filter_type(models.Rack, lookups=True)
class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin): class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin):
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -836,7 +836,7 @@ class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
) )
@strawberry_django.filter(models.RackReservation, lookups=True) @strawberry_django.filter_type(models.RackReservation, lookups=True)
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin): class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_id: ID | None = strawberry_django.filter_field() rack_id: ID | None = strawberry_django.filter_field()
@ -848,12 +848,12 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RackRole, lookups=True) @strawberry_django.filter_type(models.RackRole, lookups=True)
class RackRoleFilter(OrganizationalModelFilterMixin): class RackRoleFilter(OrganizationalModelFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RearPort, lookups=True) @strawberry_django.filter_type(models.RearPort, lookups=True)
class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -862,7 +862,7 @@ class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMi
) )
@strawberry_django.filter(models.RearPortTemplate, lookups=True) @strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
class RearPortTemplateFilter(ModularComponentTemplateFilterMixin): class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -871,7 +871,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
) )
@strawberry_django.filter(models.Region, lookups=True) @strawberry_django.filter_type(models.Region, lookups=True)
class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin): class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -881,7 +881,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
) )
@strawberry_django.filter(models.Site, lookups=True) @strawberry_django.filter_type(models.Site, lookups=True)
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -915,7 +915,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
) )
@strawberry_django.filter(models.SiteGroup, lookups=True) @strawberry_django.filter_type(models.SiteGroup, lookups=True)
class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin): class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -925,7 +925,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
) )
@strawberry_django.filter(models.VirtualChassis, lookups=True) @strawberry_django.filter_type(models.VirtualChassis, lookups=True)
class VirtualChassisFilter(PrimaryModelFilterMixin): class VirtualChassisFilter(PrimaryModelFilterMixin):
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
master_id: ID | None = strawberry_django.filter_field() master_id: ID | None = strawberry_django.filter_field()
@ -937,7 +937,7 @@ class VirtualChassisFilter(PrimaryModelFilterMixin):
member_count: FilterLookup[int] | None = strawberry_django.filter_field() member_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VirtualDeviceContext, lookups=True) @strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin): class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field() device_id: ID | None = strawberry_django.filter_field()

View File

@ -541,10 +541,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
class ManufacturerType(OrganizationalObjectType, ContactsMixin): class ManufacturerType(OrganizationalObjectType, ContactsMixin):
platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
device_types: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]]
inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] module_types: List[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@ -617,11 +617,11 @@ class ModuleTypeType(NetBoxObjectType):
frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
powerporttemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] powerporttemplates: List[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]] rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
instances: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] instances: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
consoleporttemplates: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] consoleporttemplates: List[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(

View File

@ -100,3 +100,16 @@ class Migration(migrations.Migration):
), ),
), ),
] ]
def oc_rename_type(objectchange, reverting):
for data in (objectchange.prechange_data, objectchange.postchange_data):
if data is None:
continue
if 'type' in data:
data['form_factor'] = data.pop('type')
objectchange_migrators = {
'dcim.rack': oc_rename_type,
}

View File

@ -26,49 +26,50 @@ def set_null_values(apps, schema_editor):
RackType = apps.get_model('dcim', 'RackType') RackType = apps.get_model('dcim', 'RackType')
RearPort = apps.get_model('dcim', 'RearPort') RearPort = apps.get_model('dcim', 'RearPort')
Site = apps.get_model('dcim', 'Site') Site = apps.get_model('dcim', 'Site')
db_alias = schema_editor.connection.alias
Cable.objects.filter(length_unit='').update(length_unit=None) Cable.objects.using(db_alias).filter(length_unit='').update(length_unit=None)
Cable.objects.filter(type='').update(type=None) Cable.objects.using(db_alias).filter(type='').update(type=None)
ConsolePort.objects.filter(cable_end='').update(cable_end=None) ConsolePort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
ConsolePort.objects.filter(type='').update(type=None) ConsolePort.objects.using(db_alias).filter(type='').update(type=None)
ConsolePortTemplate.objects.filter(type='').update(type=None) ConsolePortTemplate.objects.using(db_alias).filter(type='').update(type=None)
ConsoleServerPort.objects.filter(cable_end='').update(cable_end=None) ConsoleServerPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
ConsoleServerPort.objects.filter(type='').update(type=None) ConsoleServerPort.objects.using(db_alias).filter(type='').update(type=None)
ConsoleServerPortTemplate.objects.filter(type='').update(type=None) ConsoleServerPortTemplate.objects.using(db_alias).filter(type='').update(type=None)
Device.objects.filter(airflow='').update(airflow=None) Device.objects.using(db_alias).filter(airflow='').update(airflow=None)
Device.objects.filter(face='').update(face=None) Device.objects.using(db_alias).filter(face='').update(face=None)
DeviceType.objects.filter(airflow='').update(airflow=None) DeviceType.objects.using(db_alias).filter(airflow='').update(airflow=None)
DeviceType.objects.filter(subdevice_role='').update(subdevice_role=None) DeviceType.objects.using(db_alias).filter(subdevice_role='').update(subdevice_role=None)
DeviceType.objects.filter(weight_unit='').update(weight_unit=None) DeviceType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
FrontPort.objects.filter(cable_end='').update(cable_end=None) FrontPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
Interface.objects.filter(cable_end='').update(cable_end=None) Interface.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
Interface.objects.filter(mode='').update(mode=None) Interface.objects.using(db_alias).filter(mode='').update(mode=None)
Interface.objects.filter(poe_mode='').update(poe_mode=None) Interface.objects.using(db_alias).filter(poe_mode='').update(poe_mode=None)
Interface.objects.filter(poe_type='').update(poe_type=None) Interface.objects.using(db_alias).filter(poe_type='').update(poe_type=None)
Interface.objects.filter(rf_channel='').update(rf_channel=None) Interface.objects.using(db_alias).filter(rf_channel='').update(rf_channel=None)
Interface.objects.filter(rf_role='').update(rf_role=None) Interface.objects.using(db_alias).filter(rf_role='').update(rf_role=None)
InterfaceTemplate.objects.filter(poe_mode='').update(poe_mode=None) InterfaceTemplate.objects.using(db_alias).filter(poe_mode='').update(poe_mode=None)
InterfaceTemplate.objects.filter(poe_type='').update(poe_type=None) InterfaceTemplate.objects.using(db_alias).filter(poe_type='').update(poe_type=None)
InterfaceTemplate.objects.filter(rf_role='').update(rf_role=None) InterfaceTemplate.objects.using(db_alias).filter(rf_role='').update(rf_role=None)
ModuleType.objects.filter(airflow='').update(airflow=None) ModuleType.objects.using(db_alias).filter(airflow='').update(airflow=None)
ModuleType.objects.filter(weight_unit='').update(weight_unit=None) ModuleType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
PowerFeed.objects.filter(cable_end='').update(cable_end=None) PowerFeed.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
PowerOutlet.objects.filter(cable_end='').update(cable_end=None) PowerOutlet.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
PowerOutlet.objects.filter(feed_leg='').update(feed_leg=None) PowerOutlet.objects.using(db_alias).filter(feed_leg='').update(feed_leg=None)
PowerOutlet.objects.filter(type='').update(type=None) PowerOutlet.objects.using(db_alias).filter(type='').update(type=None)
PowerOutletTemplate.objects.filter(feed_leg='').update(feed_leg=None) PowerOutletTemplate.objects.using(db_alias).filter(feed_leg='').update(feed_leg=None)
PowerOutletTemplate.objects.filter(type='').update(type=None) PowerOutletTemplate.objects.using(db_alias).filter(type='').update(type=None)
PowerPort.objects.filter(cable_end='').update(cable_end=None) PowerPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
PowerPort.objects.filter(type='').update(type=None) PowerPort.objects.using(db_alias).filter(type='').update(type=None)
PowerPortTemplate.objects.filter(type='').update(type=None) PowerPortTemplate.objects.using(db_alias).filter(type='').update(type=None)
Rack.objects.filter(airflow='').update(airflow=None) Rack.objects.using(db_alias).filter(airflow='').update(airflow=None)
Rack.objects.filter(form_factor='').update(form_factor=None) Rack.objects.using(db_alias).filter(form_factor='').update(form_factor=None)
Rack.objects.filter(outer_unit='').update(outer_unit=None) Rack.objects.using(db_alias).filter(outer_unit='').update(outer_unit=None)
Rack.objects.filter(weight_unit='').update(weight_unit=None) Rack.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
RackType.objects.filter(outer_unit='').update(outer_unit=None) RackType.objects.using(db_alias).filter(outer_unit='').update(outer_unit=None)
RackType.objects.filter(weight_unit='').update(weight_unit=None) RackType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None)
RearPort.objects.filter(cable_end='').update(cable_end=None) RearPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
Site.objects.filter(time_zone='').update(time_zone=None) Site.objects.using(db_alias).filter(time_zone='').update(time_zone=None)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,4 +1,6 @@
import django.db.models.deletion import django.db.models.deletion
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import migrations, models from django.db import migrations, models
@ -6,19 +8,26 @@ def populate_mac_addresses(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')
Interface = apps.get_model('dcim', 'Interface') Interface = apps.get_model('dcim', 'Interface')
MACAddress = apps.get_model('dcim', 'MACAddress') MACAddress = apps.get_model('dcim', 'MACAddress')
db_alias = schema_editor.connection.alias
interface_ct = ContentType.objects.get_for_model(Interface) interface_ct = ContentType.objects.get_for_model(Interface)
mac_addresses = [ mac_addresses = [
MACAddress( MACAddress(
mac_address=interface.mac_address, assigned_object_type=interface_ct, assigned_object_id=interface.pk mac_address=interface.mac_address,
assigned_object_type=interface_ct,
assigned_object_id=interface.pk
) )
for interface in Interface.objects.filter(mac_address__isnull=False) for interface in Interface.objects.using(db_alias).filter(mac_address__isnull=False)
] ]
MACAddress.objects.bulk_create(mac_addresses, batch_size=100) MACAddress.objects.using(db_alias).bulk_create(mac_addresses, batch_size=100)
# TODO: Optimize interface updates # TODO: Optimize interface updates
for mac_address in mac_addresses: for mac_address in mac_addresses:
Interface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address) Interface.objects.using(db_alias).filter(
pk=mac_address.assigned_object_id
).update(
primary_mac_address=mac_address
)
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -44,3 +53,43 @@ class Migration(migrations.Migration):
name='mac_address', name='mac_address',
), ),
] ]
# See peer migrator in virtualization.0048_populate_mac_addresses before making changes
def oc_interface_primary_mac_address(objectchange, reverting):
MACAddress = apps.get_model('dcim', 'MACAddress')
interface_ct = ContentType.objects.get_by_natural_key('dcim', 'interface')
# Swap data order if the change is being reverted
if not reverting:
before, after = objectchange.prechange_data, objectchange.postchange_data
else:
before, after = objectchange.postchange_data, objectchange.prechange_data
if after.get('mac_address') != before.get('mac_address'):
# Create & assign the new MACAddress (if any)
if after.get('mac_address'):
mac = MACAddress.objects.create(
mac_address=after['mac_address'],
assigned_object_type=interface_ct,
assigned_object_id=objectchange.changed_object_id,
)
after['primary_mac_address'] = mac.pk
else:
after['primary_mac_address'] = None
# Delete the old MACAddress (if any)
if before.get('mac_address'):
MACAddress.objects.filter(
mac_address=before['mac_address'],
assigned_object_type=interface_ct,
assigned_object_id=objectchange.changed_object_id,
).delete()
before['primary_mac_address'] = None
before.pop('mac_address', None)
after.pop('mac_address', None)
objectchange_migrators = {
'dcim.interface': oc_interface_primary_mac_address,
}

View File

@ -11,6 +11,8 @@ def load_initial_data(apps, schema_editor):
Load initial ModuleTypeProfile objects from file. Load initial ModuleTypeProfile objects from file.
""" """
ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile') ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile')
db_alias = schema_editor.connection.alias
initial_profiles = ( initial_profiles = (
'cpu', 'cpu',
'fan', 'fan',
@ -25,7 +27,7 @@ def load_initial_data(apps, schema_editor):
with file_path.open('r') as f: with file_path.open('r') as f:
data = json.load(f) data = json.load(f)
try: try:
ModuleTypeProfile.objects.create(**data) ModuleTypeProfile.objects.using(db_alias).create(**data)
except Exception as e: except Exception as e:
print(f"Error loading data from {file_path}") print(f"Error loading data from {file_path}")
raise e raise e

View File

@ -0,0 +1,54 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='dcim_platform_manufacturer_name'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('name',),
name='dcim_platform_name',
violation_error_message='Platform name must be unique.'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='dcim_platform_manufacturer_slug'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('slug',),
name='dcim_platform_slug',
violation_error_message='Platform slug must be unique.'
),
),
]

View File

@ -0,0 +1,24 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0208_platform_manufacturer_uniqueness'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='tx_power',
field=models.SmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(-40),
django.core.validators.MaxValueValidator(127)
]
),
),
]

View File

@ -719,10 +719,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
verbose_name=('channel width (MHz)'), verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)") help_text=_("Populated by selected channel (if set)")
) )
tx_power = models.PositiveSmallIntegerField( tx_power = models.SmallIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=(MaxValueValidator(127),), validators=(
MinValueValidator(-40),
MaxValueValidator(127),
),
verbose_name=_('transmit power (dBm)') verbose_name=_('transmit power (dBm)')
) )
poe_mode = models.CharField( poe_mode = models.CharField(

View File

@ -415,6 +415,15 @@ class Platform(OrganizationalModel):
null=True, null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer') help_text=_('Optionally limit this platform to devices of a certain manufacturer')
) )
# Override name & slug from OrganizationalModel to not enforce uniqueness
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
)
config_template = models.ForeignKey( config_template = models.ForeignKey(
to='extras.ConfigTemplate', to='extras.ConfigTemplate',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -427,6 +436,28 @@ class Platform(OrganizationalModel):
ordering = ('name',) ordering = ('name',)
verbose_name = _('platform') verbose_name = _('platform')
verbose_name_plural = _('platforms') verbose_name_plural = _('platforms')
constraints = (
models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='%(app_label)s_%(class)s_manufacturer_name',
),
models.UniqueConstraint(
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform name must be unique.")
),
models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='%(app_label)s_%(class)s_manufacturer_slug',
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform slug must be unique.")
),
)
class Device( class Device(

View File

@ -85,7 +85,7 @@ class CachedScopeMixin(models.Model):
abstract = True abstract = True
def clean(self): def clean(self):
if self.scope_type and not self.scope: if self.scope_type and not (self.scope or self.scope_id):
scope_type = self.scope_type.model_class() scope_type = self.scope_type.model_class()
raise ValidationError({ raise ValidationError({
'scope': _( 'scope': _(

View File

@ -144,7 +144,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
super().clean() super().clean()
# Validate any attributes against the assigned profile's schema # Validate any attributes against the assigned profile's schema
if self.profile: if self.profile and self.profile.schema:
try: try:
jsonschema.validate(self.attribute_data, schema=self.profile.schema) jsonschema.validate(self.attribute_data, schema=self.profile.schema)
except JSONValidationError as e: except JSONValidationError as e:

View File

@ -0,0 +1,38 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkAddComponents',
'BulkDisconnect',
)
class BulkAddComponents(ObjectAction):
"""
Add components to the selected devices.
"""
label = _('Add Components')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_add_components.html'
@classmethod
def get_context(cls, context, obj):
return {
'perms': context.get('perms'),
'request': context.get('request'),
'formaction': context.get('formaction'),
'label': cls.label,
}
class BulkDisconnect(ObjectAction):
"""
Disconnect each of a set of objects to which a cable is connected.
"""
name = 'bulk_disconnect'
label = _('Disconnect Selected')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_disconnect.html'

View File

@ -329,11 +329,9 @@ class CableTraceSVG:
# Draw attachment (line) # Draw attachment (line)
start = (OFFSET + self.center, OFFSET + self.cursor) start = (OFFSET + self.center, OFFSET + self.cursor)
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2 end = (start[0], start[1] + CABLE_HEIGHT)
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='attachment') line = Line(start=start, end=end, class_='attachment')
group.add(line) group.add(line)
self.cursor += PADDING * 4
return group return group
@ -358,10 +356,10 @@ class CableTraceSVG:
# Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!) # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
near_terminations = self.draw_terminations(near_ends, parent_object_nodes) near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
self.cursor += CABLE_HEIGHT
# Connector (a Cable or WirelessLink) # Connector (a Cable or WirelessLink)
if links and far_ends: if links and far_ends:
self.cursor += CABLE_HEIGHT
obj_list = {end.parent_object for end in far_ends} obj_list = {end.parent_object for end in far_ends}
parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends) parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)
@ -449,6 +447,7 @@ class CableTraceSVG:
# Attachment # Attachment
attachment = self.draw_attachment() attachment = self.draw_attachment()
self.connectors.append(attachment) self.connectors.append(attachment)
self.cursor += CABLE_HEIGHT
# Object # Object
parent_object_nodes = self.draw_parent_objects(far_ends) parent_object_nodes = self.draw_parent_objects(far_ends)

View File

@ -1091,10 +1091,9 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
device = tables.TemplateColumn( device = tables.Column(
verbose_name=_('Device'), verbose_name=_('Device'),
order_by=('device___name',), order_by=('device___name',),
template_code=DEVICE_LINK,
linkify=True linkify=True
) )
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(

View File

@ -14,7 +14,7 @@ from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant from tenancy.models import Tenant
from users.models import User from users.models import User
from utilities.testing import APITestCase, APIViewTestCases, create_test_device from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
@ -1858,7 +1858,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
# Attempt to delete only the parent interface # Attempt to delete only the parent interface
url = self._get_detail_url(interface1) url = self._get_detail_url(interface1)
self.client.delete(url, **self.header) with disable_logging():
self.client.delete(url, **self.header)
self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted
# Attempt to bulk delete parent & child together # Attempt to bulk delete parent & child together

View File

@ -12,6 +12,7 @@ from users.models import User
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
from wireless.models import WirelessLink
class DeviceComponentFilterSetTests: class DeviceComponentFilterSetTests:
@ -4496,7 +4497,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
# Cables # Cables
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save() Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save() Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
# Third pair is not connected
# Wireless links
WirelessLink(interface_a=interfaces[7], interface_b=interfaces[8]).save()
def test_name(self): def test_name(self):
params = {'name': ['Interface 1', 'Interface 2']} params = {'name': ['Interface 1', 'Interface 2']}
@ -4684,15 +4687,15 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
def test_occupied(self): def test_occupied(self):
params = {'occupied': True} params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'occupied': False} params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_connected(self): def test_connected(self):
params = {'connected': True} params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'connected': False} params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_kind(self): def test_kind(self):
params = {'kind': 'physical'} params = {'kind': 'physical'}

View File

@ -954,6 +954,19 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
@tag('regression')
def test_cable_cannot_terminate_to_a_cellular_interface(self):
"""
A cable cannot terminate to a cellular interface
"""
device1 = Device.objects.get(name='TestDevice1')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
cellular_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_LTE)
cable = Cable(a_terminations=[interface2], b_terminations=[cellular_interface])
with self.assertRaises(ValidationError):
cable.clean()
class VirtualDeviceContextTestCase(TestCase): class VirtualDeviceContextTestCase(TestCase):

View File

@ -1,6 +1,6 @@
from django.apps import apps from django.apps import apps
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import router, transaction
def compile_path_node(ct_id, object_id): def compile_path_node(ct_id, object_id):
@ -53,7 +53,7 @@ def rebuild_paths(terminations):
for obj in terminations: for obj in terminations:
cable_paths = CablePath.objects.filter(_nodes__contains=obj) cable_paths = CablePath.objects.filter(_nodes__contains=obj)
with transaction.atomic(): with transaction.atomic(using=router.db_for_write(CablePath)):
for cp in cable_paths: for cp in cable_paths:
cp.delete() cp.delete()
create_cablepath(cp.origins) create_cablepath(cp.origins)

View File

@ -1,7 +1,7 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction from django.db import router, transaction
from django.db.models import Prefetch from django.db.models import Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@ -15,7 +15,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView, ObjectRenderConfigView from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.object_actions import *
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@ -34,6 +34,7 @@ from wireless.models import WirelessLAN
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DeviceFaceChoices, InterfaceModeChoices from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import * from .models import *
from .object_actions import BulkAddComponents, BulkDisconnect
CABLE_TERMINATION_TYPES = { CABLE_TERMINATION_TYPES = {
'dcim.consoleport': ConsolePort, 'dcim.consoleport': ConsolePort,
@ -49,11 +50,6 @@ CABLE_TERMINATION_TYPES = {
class DeviceComponentsView(generic.ObjectChildrenView): class DeviceComponentsView(generic.ObjectChildrenView):
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
'bulk_disconnect': {'change'},
}
queryset = Device.objects.all() queryset = Device.objects.all()
def get_children(self, request, parent): def get_children(self, request, parent):
@ -61,12 +57,8 @@ class DeviceComponentsView(generic.ObjectChildrenView):
class DeviceTypeComponentsView(generic.ObjectChildrenView): class DeviceTypeComponentsView(generic.ObjectChildrenView):
actions = { actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
queryset = DeviceType.objects.all() queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution viewname = None # Used for return_url resolution
def get_children(self, request, parent): def get_children(self, request, parent):
@ -78,9 +70,9 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView):
} }
class ModuleTypeComponentsView(DeviceComponentsView): class ModuleTypeComponentsView(generic.ObjectChildrenView):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
template_name = 'dcim/moduletype/component_templates.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
viewname = None # Used for return_url resolution viewname = None # Used for return_url resolution
def get_children(self, request, parent): def get_children(self, request, parent):
@ -124,7 +116,7 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
if form.is_valid(): if form.is_valid():
with transaction.atomic(): with transaction.atomic(using=router.db_for_write(Cable)):
count = 0 count = 0
cable_ids = set() cable_ids = set()
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
@ -300,6 +292,11 @@ class RegionBulkEditView(generic.BulkEditView):
form = forms.RegionBulkEditForm form = forms.RegionBulkEditForm
@register_model_view(Region, 'bulk_rename', path='rename', detail=False)
class RegionBulkRenameView(generic.BulkRenameView):
queryset = Region.objects.all()
@register_model_view(Region, 'bulk_delete', path='delete', detail=False) @register_model_view(Region, 'bulk_delete', path='delete', detail=False)
class RegionBulkDeleteView(generic.BulkDeleteView): class RegionBulkDeleteView(generic.BulkDeleteView):
queryset = Region.objects.add_related_count( queryset = Region.objects.add_related_count(
@ -426,6 +423,11 @@ class SiteGroupBulkEditView(generic.BulkEditView):
form = forms.SiteGroupBulkEditForm form = forms.SiteGroupBulkEditForm
@register_model_view(SiteGroup, 'bulk_rename', path='rename', detail=False)
class SiteGroupBulkRenameView(generic.BulkRenameView):
queryset = SiteGroup.objects.all()
@register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False)
class SiteGroupBulkDeleteView(generic.BulkDeleteView): class SiteGroupBulkDeleteView(generic.BulkDeleteView):
queryset = SiteGroup.objects.add_related_count( queryset = SiteGroup.objects.add_related_count(
@ -511,6 +513,11 @@ class SiteBulkEditView(generic.BulkEditView):
form = forms.SiteBulkEditForm form = forms.SiteBulkEditForm
@register_model_view(Site, 'bulk_rename', path='rename', detail=False)
class SiteBulkRenameView(generic.BulkRenameView):
queryset = Site.objects.all()
@register_model_view(Site, 'bulk_delete', path='delete', detail=False) @register_model_view(Site, 'bulk_delete', path='delete', detail=False)
class SiteBulkDeleteView(generic.BulkDeleteView): class SiteBulkDeleteView(generic.BulkDeleteView):
queryset = Site.objects.all() queryset = Site.objects.all()
@ -615,6 +622,11 @@ class LocationBulkEditView(generic.BulkEditView):
form = forms.LocationBulkEditForm form = forms.LocationBulkEditForm
@register_model_view(Location, 'bulk_rename', path='rename', detail=False)
class LocationBulkRenameView(generic.BulkRenameView):
queryset = Location.objects.all()
@register_model_view(Location, 'bulk_delete', path='delete', detail=False) @register_model_view(Location, 'bulk_delete', path='delete', detail=False)
class LocationBulkDeleteView(generic.BulkDeleteView): class LocationBulkDeleteView(generic.BulkDeleteView):
queryset = Location.objects.add_related_count( queryset = Location.objects.add_related_count(
@ -680,6 +692,11 @@ class RackRoleBulkEditView(generic.BulkEditView):
form = forms.RackRoleBulkEditForm form = forms.RackRoleBulkEditForm
@register_model_view(RackRole, 'bulk_rename', path='rename', detail=False)
class RackRoleBulkRenameView(generic.BulkRenameView):
queryset = RackRole.objects.all()
@register_model_view(RackRole, 'bulk_delete', path='delete', detail=False) @register_model_view(RackRole, 'bulk_delete', path='delete', detail=False)
class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
@ -739,6 +756,12 @@ class RackTypeBulkEditView(generic.BulkEditView):
form = forms.RackTypeBulkEditForm form = forms.RackTypeBulkEditForm
@register_model_view(RackType, 'bulk_rename', path='rename', detail=False)
class RackTypeBulkRenameView(generic.BulkRenameView):
queryset = RackType.objects.all()
field_name = 'model'
@register_model_view(RackType, 'bulk_delete', path='delete', detail=False) @register_model_view(RackType, 'bulk_delete', path='delete', detail=False)
class RackTypeBulkDeleteView(generic.BulkDeleteView): class RackTypeBulkDeleteView(generic.BulkDeleteView):
queryset = RackType.objects.all() queryset = RackType.objects.all()
@ -918,6 +941,11 @@ class RackBulkEditView(generic.BulkEditView):
form = forms.RackBulkEditForm form = forms.RackBulkEditForm
@register_model_view(Rack, 'bulk_rename', path='rename', detail=False)
class RackBulkRenameView(generic.BulkRenameView):
queryset = Rack.objects.all()
@register_model_view(Rack, 'bulk_delete', path='delete', detail=False) @register_model_view(Rack, 'bulk_delete', path='delete', detail=False)
class RackBulkDeleteView(generic.BulkDeleteView): class RackBulkDeleteView(generic.BulkDeleteView):
queryset = Rack.objects.all() queryset = Rack.objects.all()
@ -935,6 +963,7 @@ class RackReservationListView(generic.ObjectListView):
filterset = filtersets.RackReservationFilterSet filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable table = tables.RackReservationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(RackReservation) @register_model_view(RackReservation)
@ -1051,6 +1080,11 @@ class ManufacturerBulkEditView(generic.BulkEditView):
form = forms.ManufacturerBulkEditForm form = forms.ManufacturerBulkEditForm
@register_model_view(Manufacturer, 'bulk_rename', path='rename', detail=False)
class ManufacturerBulkRenameView(generic.BulkRenameView):
queryset = Manufacturer.objects.all()
@register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False) @register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False)
class ManufacturerBulkDeleteView(generic.BulkDeleteView): class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
@ -1298,6 +1332,12 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
form = forms.DeviceTypeBulkEditForm form = forms.DeviceTypeBulkEditForm
@register_model_view(DeviceType, 'bulk_rename', path='rename', detail=False)
class DeviceTypeBulkRenameView(generic.BulkRenameView):
queryset = DeviceType.objects.all()
field_name = 'model'
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False) @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
class DeviceTypeBulkDeleteView(generic.BulkDeleteView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.annotate( queryset = DeviceType.objects.annotate(
@ -1354,6 +1394,11 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeProfileBulkEditForm form = forms.ModuleTypeProfileBulkEditForm
@register_model_view(ModuleTypeProfile, 'bulk_rename', path='rename', detail=False)
class ModuleTypeProfileBulkRenameView(generic.BulkRenameView):
queryset = ModuleTypeProfile.objects.all()
@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False) @register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView): class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleTypeProfile.objects.annotate( queryset = ModuleTypeProfile.objects.annotate(
@ -1564,6 +1609,11 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeBulkEditForm form = forms.ModuleTypeBulkEditForm
@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
class ModuleTypeBulkRenameView(generic.BulkRenameView):
queryset = ModuleType.objects.all()
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False) @register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
class ModuleTypeBulkDeleteView(generic.BulkDeleteView): class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleType.objects.annotate( queryset = ModuleType.objects.annotate(
@ -2038,6 +2088,11 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
form = forms.DeviceRoleBulkEditForm form = forms.DeviceRoleBulkEditForm
@register_model_view(DeviceRole, 'bulk_rename', path='rename', detail=False)
class DeviceRoleBulkRenameView(generic.BulkRenameView):
queryset = DeviceRole.objects.all()
@register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False) @register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False)
class DeviceRoleBulkDeleteView(generic.BulkDeleteView): class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.annotate(
@ -2099,6 +2154,11 @@ class PlatformBulkEditView(generic.BulkEditView):
form = forms.PlatformBulkEditForm form = forms.PlatformBulkEditForm
@register_model_view(Platform, 'bulk_rename', path='rename', detail=False)
class PlatformBulkRenameView(generic.BulkRenameView):
queryset = Platform.objects.all()
@register_model_view(Platform, 'bulk_delete', path='delete', detail=False) @register_model_view(Platform, 'bulk_delete', path='delete', detail=False)
class PlatformBulkDeleteView(generic.BulkDeleteView): class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all() queryset = Platform.objects.all()
@ -2116,7 +2176,7 @@ class DeviceListView(generic.ObjectListView):
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm filterset_form = forms.DeviceFilterForm
table = tables.DeviceTable table = tables.DeviceTable
template_name = 'dcim/device_list.html' actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
@register_model_view(Device) @register_model_view(Device)
@ -2157,7 +2217,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
table = tables.DeviceConsolePortTable table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
template_name = 'dcim/device/consoleports.html', actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
badge=lambda obj: obj.console_port_count, badge=lambda obj: obj.console_port_count,
@ -2173,7 +2233,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
table = tables.DeviceConsoleServerPortTable table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
template_name = 'dcim/device/consoleserverports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
badge=lambda obj: obj.console_server_port_count, badge=lambda obj: obj.console_server_port_count,
@ -2189,7 +2249,7 @@ class DevicePowerPortsView(DeviceComponentsView):
table = tables.DevicePowerPortTable table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
template_name = 'dcim/device/powerports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
badge=lambda obj: obj.power_port_count, badge=lambda obj: obj.power_port_count,
@ -2205,7 +2265,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
table = tables.DevicePowerOutletTable table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
template_name = 'dcim/device/poweroutlets.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
badge=lambda obj: obj.power_outlet_count, badge=lambda obj: obj.power_outlet_count,
@ -2221,6 +2281,7 @@ class DeviceInterfacesView(DeviceComponentsView):
table = tables.DeviceInterfaceTable table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/interfaces.html' template_name = 'dcim/device/interfaces.html'
tab = ViewTab( tab = ViewTab(
label=_('Interfaces'), label=_('Interfaces'),
@ -2243,7 +2304,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
table = tables.DeviceFrontPortTable table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
template_name = 'dcim/device/frontports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
badge=lambda obj: obj.front_port_count, badge=lambda obj: obj.front_port_count,
@ -2259,7 +2320,7 @@ class DeviceRearPortsView(DeviceComponentsView):
table = tables.DeviceRearPortTable table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
template_name = 'dcim/device/rearports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
badge=lambda obj: obj.rear_port_count, badge=lambda obj: obj.rear_port_count,
@ -2275,11 +2336,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
template_name = 'dcim/device/modulebays.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.module_bay_count, badge=lambda obj: obj.module_bay_count,
@ -2295,11 +2352,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
template_name = 'dcim/device/devicebays.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.device_bay_count, badge=lambda obj: obj.device_bay_count,
@ -2315,11 +2368,7 @@ class DeviceInventoryView(DeviceComponentsView):
table = tables.DeviceInventoryItemTable table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
template_name = 'dcim/device/inventory.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Inventory Items'), label=_('Inventory Items'),
badge=lambda obj: obj.inventory_item_count, badge=lambda obj: obj.inventory_item_count,
@ -2393,16 +2442,16 @@ class DeviceBulkEditView(generic.BulkEditView):
form = forms.DeviceBulkEditForm form = forms.DeviceBulkEditForm
@register_model_view(Device, 'bulk_delete', path='delete', detail=False) @register_model_view(Device, 'bulk_rename', path='rename', detail=False)
class DeviceBulkDeleteView(generic.BulkDeleteView): class DeviceBulkRenameView(generic.BulkRenameView):
queryset = Device.objects.prefetch_related('device_type__manufacturer') queryset = Device.objects.all()
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable table = tables.DeviceTable
@register_model_view(Device, 'bulk_rename', path='rename', detail=False) @register_model_view(Device, 'bulk_delete', path='delete', detail=False)
class DeviceBulkRenameView(generic.BulkRenameView): class DeviceBulkDeleteView(generic.BulkDeleteView):
queryset = Device.objects.all() queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable table = tables.DeviceTable
@ -2417,6 +2466,7 @@ class ModuleListView(generic.ObjectListView):
filterset = filtersets.ModuleFilterSet filterset = filtersets.ModuleFilterSet
filterset_form = forms.ModuleFilterForm filterset_form = forms.ModuleFilterForm
table = tables.ModuleTable table = tables.ModuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Module) @register_model_view(Module)
@ -2472,11 +2522,6 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable table = tables.ConsolePortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsolePort) @register_model_view(ConsolePort)
@ -2547,11 +2592,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable table = tables.ConsoleServerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsoleServerPort) @register_model_view(ConsoleServerPort)
@ -2622,11 +2662,6 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable table = tables.PowerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerPort) @register_model_view(PowerPort)
@ -2697,11 +2732,6 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable table = tables.PowerOutletTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerOutlet) @register_model_view(PowerOutlet)
@ -2772,11 +2802,6 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable table = tables.InterfaceTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(Interface) @register_model_view(Interface)
@ -2793,6 +2818,7 @@ class InterfaceView(generic.ObjectView):
), ),
orderable=False orderable=False
) )
vdc_table.configure(request)
# Get bridge interfaces # Get bridge interfaces
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance) bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
@ -2801,6 +2827,7 @@ class InterfaceView(generic.ObjectView):
exclude=('device', 'parent'), exclude=('device', 'parent'),
orderable=False orderable=False
) )
bridge_interfaces_table.configure(request)
# Get child interfaces # Get child interfaces
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
@ -2809,6 +2836,7 @@ class InterfaceView(generic.ObjectView):
exclude=('device', 'parent'), exclude=('device', 'parent'),
orderable=False orderable=False
) )
child_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged # Get assigned VLANs and annotate whether each is tagged or untagged
vlans = [] vlans = []
@ -2823,6 +2851,7 @@ class InterfaceView(generic.ObjectView):
data=vlans, data=vlans,
orderable=False orderable=False
) )
vlan_table.configure(request)
# Get VLAN translation rules # Get VLAN translation rules
vlan_translation_table = None vlan_translation_table = None
@ -2831,6 +2860,7 @@ class InterfaceView(generic.ObjectView):
data=instance.vlan_translation_policy.rules.all(), data=instance.vlan_translation_policy.rules.all(),
orderable=False orderable=False
) )
vlan_translation_table.configure(request)
return { return {
'vdc_table': vdc_table, 'vdc_table': vdc_table,
@ -2915,11 +2945,6 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable table = tables.FrontPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(FrontPort) @register_model_view(FrontPort)
@ -2990,11 +3015,6 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable table = tables.RearPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(RearPort) @register_model_view(RearPort)
@ -3065,11 +3085,6 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable table = tables.ModuleBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ModuleBay) @register_model_view(ModuleBay)
@ -3131,11 +3146,6 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable table = tables.DeviceBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(DeviceBay) @register_model_view(DeviceBay)
@ -3278,11 +3288,6 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(InventoryItem) @register_model_view(InventoryItem)
@ -3405,6 +3410,11 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
form = forms.InventoryItemRoleBulkEditForm form = forms.InventoryItemRoleBulkEditForm
@register_model_view(InventoryItemRole, 'bulk_rename', path='rename', detail=False)
class InventoryItemRoleBulkRenameView(generic.BulkRenameView):
queryset = InventoryItemRole.objects.all()
@register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False) @register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False)
class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView): class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate( queryset = InventoryItemRole.objects.annotate(
@ -3602,6 +3612,12 @@ class CableBulkEditView(generic.BulkEditView):
form = forms.CableBulkEditForm form = forms.CableBulkEditForm
@register_model_view(Cable, 'bulk_rename', path='rename', detail=False)
class CableBulkRenameView(generic.BulkRenameView):
queryset = Cable.objects.all()
field_name = 'label'
@register_model_view(Cable, 'bulk_delete', path='delete', detail=False) @register_model_view(Cable, 'bulk_delete', path='delete', detail=False)
class CableBulkDeleteView(generic.BulkDeleteView): class CableBulkDeleteView(generic.BulkDeleteView):
queryset = Cable.objects.prefetch_related( queryset = Cable.objects.prefetch_related(
@ -3622,9 +3638,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
filterset_form = forms.ConsoleConnectionFilterForm filterset_form = forms.ConsoleConnectionFilterForm
table = tables.ConsoleConnectionTable table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@ -3638,9 +3652,7 @@ class PowerConnectionsListView(generic.ObjectListView):
filterset_form = forms.PowerConnectionFilterForm filterset_form = forms.PowerConnectionFilterForm
table = tables.PowerConnectionTable table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@ -3654,9 +3666,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
filterset_form = forms.InterfaceConnectionFilterForm filterset_form = forms.InterfaceConnectionFilterForm
table = tables.InterfaceConnectionTable table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@ -3741,7 +3751,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
if vc_form.is_valid() and formset.is_valid(): if vc_form.is_valid() and formset.is_valid():
with transaction.atomic(): with transaction.atomic(using=router.db_for_write(Device)):
# Save the VirtualChassis # Save the VirtualChassis
vc_form.save() vc_form.save()
@ -3900,6 +3910,11 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
form = forms.VirtualChassisBulkEditForm form = forms.VirtualChassisBulkEditForm
@register_model_view(VirtualChassis, 'bulk_rename', path='rename', detail=False)
class VirtualChassisBulkRenameView(generic.BulkRenameView):
queryset = VirtualChassis.objects.all()
@register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False) @register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False)
class VirtualChassisBulkDeleteView(generic.BulkDeleteView): class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualChassis.objects.all() queryset = VirtualChassis.objects.all()
@ -3957,6 +3972,11 @@ class PowerPanelBulkEditView(generic.BulkEditView):
form = forms.PowerPanelBulkEditForm form = forms.PowerPanelBulkEditForm
@register_model_view(PowerPanel, 'bulk_rename', path='rename', detail=False)
class PowerPanelBulkRenameView(generic.BulkRenameView):
queryset = PowerPanel.objects.all()
@register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False) @register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False)
class PowerPanelBulkDeleteView(generic.BulkDeleteView): class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.annotate( queryset = PowerPanel.objects.annotate(
@ -4009,6 +4029,11 @@ class PowerFeedBulkEditView(generic.BulkEditView):
form = forms.PowerFeedBulkEditForm form = forms.PowerFeedBulkEditForm
@register_model_view(PowerFeed, 'bulk_rename', path='rename', detail=False)
class PowerFeedBulkRenameView(generic.BulkRenameView):
queryset = PowerFeed.objects.all()
@register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False) @register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False)
class PowerFeedBulkDisconnectView(BulkDisconnectView): class PowerFeedBulkDisconnectView(BulkDisconnectView):
queryset = PowerFeed.objects.all() queryset = PowerFeed.objects.all()
@ -4037,6 +4062,7 @@ class VirtualDeviceContextListView(generic.ObjectListView):
filterset = filtersets.VirtualDeviceContextFilterSet filterset = filtersets.VirtualDeviceContextFilterSet
filterset_form = forms.VirtualDeviceContextFilterForm filterset_form = forms.VirtualDeviceContextFilterForm
table = tables.VirtualDeviceContextTable table = tables.VirtualDeviceContextTable
actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
@register_model_view(VirtualDeviceContext) @register_model_view(VirtualDeviceContext)
@ -4081,6 +4107,11 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
form = forms.VirtualDeviceContextBulkEditForm form = forms.VirtualDeviceContextBulkEditForm
@register_model_view(VirtualDeviceContext, 'bulk_rename', path='rename', detail=False)
class VirtualDeviceContextBulkRenameView(generic.BulkRenameView):
queryset = VirtualDeviceContext.objects.all()
@register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False) @register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False)
class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView): class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualDeviceContext.objects.all() queryset = VirtualDeviceContext.objects.all()
@ -4098,6 +4129,7 @@ class MACAddressListView(generic.ObjectListView):
filterset = filtersets.MACAddressFilterSet filterset = filtersets.MACAddressFilterSet
filterset_form = forms.MACAddressFilterForm filterset_form = forms.MACAddressFilterForm
table = tables.MACAddressTable table = tables.MACAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(MACAddress) @register_model_view(MACAddress)

View File

@ -1,7 +1,13 @@
import inspect
from django.urls import NoReverseMatch, reverse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from netbox.api.serializers import BaseModelSerializer from netbox.api.serializers import BaseModelSerializer
from utilities.views import get_viewname
__all__ = ( __all__ = (
'ObjectTypeSerializer', 'ObjectTypeSerializer',
@ -10,7 +16,32 @@ __all__ = (
class ObjectTypeSerializer(BaseModelSerializer): class ObjectTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
app_name = serializers.CharField(source='app_verbose_name', read_only=True)
model_name = serializers.CharField(source='model_verbose_name', read_only=True)
model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
is_plugin_model = serializers.BooleanField(read_only=True)
rest_api_endpoint = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
class Meta: class Meta:
model = ObjectType model = ObjectType
fields = ['id', 'url', 'display', 'app_label', 'model'] fields = [
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural',
'is_plugin_model', 'rest_api_endpoint', 'description',
]
@extend_schema_field(OpenApiTypes.STR)
def get_rest_api_endpoint(self, obj):
if not (model := obj.model_class()):
return
if viewname := get_viewname(model, action='list', rest_api=True):
try:
return reverse(viewname)
except NoReverseMatch:
return
@extend_schema_field(OpenApiTypes.STR)
def get_description(self, obj):
if not (model := obj.model_class()):
return
return inspect.getdoc(model)

View File

@ -66,11 +66,11 @@ class ScriptInputSerializer(serializers.Serializer):
interval = serializers.IntegerField(required=False, allow_null=True) interval = serializers.IntegerField(required=False, allow_null=True)
def validate_schedule_at(self, value): def validate_schedule_at(self, value):
if value and not self.context['script'].scheduling_enabled: if value and not self.context['script'].python_class.scheduling_enabled:
raise serializers.ValidationError(_("Scheduling is not enabled for this script.")) raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value return value
def validate_interval(self, value): def validate_interval(self, value):
if value and not self.context['script'].scheduling_enabled: if value and not self.context['script'].python_class.scheduling_enabled:
raise serializers.ValidationError(_("Scheduling is not enabled for this script.")) raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value return value

View File

@ -270,6 +270,7 @@ class ScriptViewSet(ModelViewSet):
module_name, script_name = pk.split('.', maxsplit=1) module_name, script_name = pk.split('.', maxsplit=1)
except ValueError: except ValueError:
raise Http404 raise Http404
return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name) return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
def retrieve(self, request, pk): def retrieve(self, request, pk):

View File

@ -238,10 +238,18 @@ class TagImportForm(CSVModelForm):
label=_('Weight'), label=_('Weight'),
required=False required=False
) )
object_types = CSVMultipleContentTypeField(
label=_('Object types'),
queryset=ObjectType.objects.with_feature('tags'),
help_text=_("One or more assigned object types"),
required=False,
)
class Meta: class Meta:
model = Tag model = Tag
fields = ('name', 'slug', 'color', 'weight', 'description') fields = (
'name', 'slug', 'color', 'weight', 'description', 'object_types',
)
class JournalEntryImportForm(NetBoxModelImportForm): class JournalEntryImportForm(NetBoxModelImportForm):

View File

@ -1,13 +1,8 @@
import os
from django import forms
from django.conf import settings
from django.core.files.storage import storages
from django.utils.translation import gettext_lazy as _
from core.choices import JobIntervalChoices from core.choices import JobIntervalChoices
from core.forms import ManagedFileForm from core.forms import ManagedFileForm
from extras.storage import ScriptFileSystemStorage from django import forms
from django.core.files.storage import storages
from django.utils.translation import gettext_lazy as _
from utilities.datetime import local_now from utilities.datetime import local_now
from utilities.forms.widgets import DateTimePicker, NumberWithOptions from utilities.forms.widgets import DateTimePicker, NumberWithOptions
@ -74,12 +69,7 @@ class ScriptFileForm(ManagedFileForm):
storage = storages.create_storage(storages.backends["scripts"]) storage = storages.create_storage(storages.backends["scripts"])
filename = self.cleaned_data['upload_file'].name filename = self.cleaned_data['upload_file'].name
if isinstance(storage, ScriptFileSystemStorage): self.instance.file_path = filename
full_path = os.path.join(settings.SCRIPTS_ROOT, filename)
else:
full_path = filename
self.instance.file_path = full_path
data = self.cleaned_data['upload_file'] data = self.cleaned_data['upload_file']
storage.save(filename, data) storage.save(filename, data)

View File

@ -40,7 +40,7 @@ __all__ = (
) )
@strawberry_django.filter(models.ConfigContext, lookups=True) @strawberry_django.filter_type(models.ConfigContext, lookups=True)
class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] = strawberry_django.filter_field() name: FilterLookup[str] = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@ -97,7 +97,7 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
) )
@strawberry_django.filter(models.ConfigTemplate, lookups=True) @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -111,7 +111,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomField, lookups=True) @strawberry_django.filter_type(models.CustomField, lookups=True)
class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = ( type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -164,7 +164,7 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
comments: FilterLookup[str] | None = strawberry_django.filter_field() comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomFieldChoiceSet, lookups=True) @strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -177,7 +177,7 @@ class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin
order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field() order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomLink, lookups=True) @strawberry_django.filter_type(models.CustomLink, lookups=True)
class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@ -193,7 +193,7 @@ class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
new_window: FilterLookup[bool] | None = strawberry_django.filter_field() new_window: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ExportTemplate, lookups=True) @strawberry_django.filter_type(models.ExportTemplate, lookups=True)
class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -207,7 +207,7 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ImageAttachment, lookups=True) @strawberry_django.filter_type(models.ImageAttachment, lookups=True)
class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -222,7 +222,7 @@ class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.JournalEntry, lookups=True) @strawberry_django.filter_type(models.JournalEntry, lookups=True)
class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@ -238,7 +238,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag
comments: FilterLookup[str] | None = strawberry_django.filter_field() comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.NotificationGroup, lookups=True) @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -246,7 +246,7 @@ class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.SavedFilter, lookups=True) @strawberry_django.filter_type(models.SavedFilter, lookups=True)
class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -263,7 +263,7 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
) )
@strawberry_django.filter(models.TableConfig, lookups=True) @strawberry_django.filter_type(models.TableConfig, lookups=True)
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -276,13 +276,13 @@ class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
shared: FilterLookup[bool] | None = strawberry_django.filter_field() shared: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Tag, lookups=True) @strawberry_django.filter_type(models.Tag, lookups=True)
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin): class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Webhook, lookups=True) @strawberry_django.filter_type(models.Webhook, lookups=True)
class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -301,7 +301,7 @@ class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilt
) )
@strawberry_django.filter(models.EventRule, lookups=True) @strawberry_django.filter_type(models.EventRule, lookups=True)
class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -39,6 +39,9 @@ class ScriptJob(JobRunner):
try: try:
try: try:
# A script can modify multiple models so need to do an atomic lock on
# both the default database (for non ChangeLogged models) and potentially
# any other database (for ChangeLogged models)
with transaction.atomic(): with transaction.atomic():
script.output = script.run(data, commit) script.output = script.run(data, commit)
if not commit: if not commit:

View File

@ -4,11 +4,12 @@ from django.db import migrations
def convert_reportmodule_jobs(apps, schema_editor): def convert_reportmodule_jobs(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')
Job = apps.get_model('core', 'Job') Job = apps.get_model('core', 'Job')
db_alias = schema_editor.connection.alias
# Convert all ReportModule jobs to ScriptModule jobs # Convert all ReportModule jobs to ScriptModule jobs
if reportmodule_ct := ContentType.objects.filter(app_label='extras', model='reportmodule').first(): if reportmodule_ct := ContentType.objects.using(db_alias).filter(app_label='extras', model='reportmodule').first():
scriptmodule_ct = ContentType.objects.get(app_label='extras', model='scriptmodule') scriptmodule_ct = ContentType.objects.using(db_alias).get(app_label='extras', model='scriptmodule')
Job.objects.filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id) Job.objects.using(db_alias).filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -88,24 +88,33 @@ def update_scripts(apps, schema_editor):
ScriptModule = apps.get_model('extras', 'ScriptModule') ScriptModule = apps.get_model('extras', 'ScriptModule')
ReportModule = apps.get_model('extras', 'ReportModule') ReportModule = apps.get_model('extras', 'ReportModule')
Job = apps.get_model('core', 'Job') Job = apps.get_model('core', 'Job')
db_alias = schema_editor.connection.alias
script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False) script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule, for_concrete_model=False) scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule, for_concrete_model=False)
reportmodule_ct = ContentType.objects.get_for_model(ReportModule, for_concrete_model=False) reportmodule_ct = ContentType.objects.get_for_model(ReportModule, for_concrete_model=False)
for module in ScriptModule.objects.all(): for module in ScriptModule.objects.using(db_alias).all():
for script_name in get_module_scripts(module): for script_name in get_module_scripts(module):
script = Script.objects.create( script = Script.objects.using(db_alias).create(
name=script_name, name=script_name,
module=module, module=module,
) )
# Update all Jobs associated with this ScriptModule & script name to point to the new Script object # Update all Jobs associated with this ScriptModule & script name to point to the new Script object
Job.objects.filter(object_type_id=scriptmodule_ct.id, object_id=module.pk, name=script_name).update( Job.objects.using(db_alias).filter(
object_type_id=scriptmodule_ct.id,
object_id=module.pk,
name=script_name
).update(
object_type_id=script_ct.id, object_id=script.pk object_type_id=script_ct.id, object_id=script.pk
) )
# Update all Jobs associated with this ScriptModule & script name to point to the new Script object # Update all Jobs associated with this ScriptModule & script name to point to the new Script object
Job.objects.filter(object_type_id=reportmodule_ct.id, object_id=module.pk, name=script_name).update( Job.objects.using(db_alias).filter(
object_type_id=reportmodule_ct.id,
object_id=module.pk,
name=script_name
).update(
object_type_id=script_ct.id, object_id=script.pk object_type_id=script_ct.id, object_id=script.pk
) )
@ -119,16 +128,22 @@ def update_event_rules(apps, schema_editor):
Script = apps.get_model('extras', 'Script') Script = apps.get_model('extras', 'Script')
ScriptModule = apps.get_model('extras', 'ScriptModule') ScriptModule = apps.get_model('extras', 'ScriptModule')
EventRule = apps.get_model('extras', 'EventRule') EventRule = apps.get_model('extras', 'EventRule')
db_alias = schema_editor.connection.alias
script_ct = ContentType.objects.get_for_model(Script) script_ct = ContentType.objects.get_for_model(Script)
scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule) scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct): for eventrule in EventRule.objects.using(db_alias).filter(action_object_type=scriptmodule_ct):
name = eventrule.action_parameters.get('script_name') name = eventrule.action_parameters.get('script_name')
obj, __ = Script.objects.get_or_create( obj, __ = Script.objects.using(db_alias).get_or_create(
module_id=eventrule.action_object_id, name=name, defaults={'is_executable': False} module_id=eventrule.action_object_id,
name=name,
defaults={'is_executable': False}
)
EventRule.objects.using(db_alias).filter(pk=eventrule.pk).update(
action_object_type=script_ct,
action_object_id=obj.id
) )
EventRule.objects.filter(pk=eventrule.pk).update(action_object_type=script_ct, action_object_id=obj.id)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,12 +1,11 @@
# Generated by Django 5.0.4 on 2024-04-24 20:09
from django.db import migrations from django.db import migrations
def update_dashboard_widgets(apps, schema_editor): def update_dashboard_widgets(apps, schema_editor):
Dashboard = apps.get_model('extras', 'Dashboard') Dashboard = apps.get_model('extras', 'Dashboard')
db_alias = schema_editor.connection.alias
for dashboard in Dashboard.objects.all(): for dashboard in Dashboard.objects.using(db_alias).all():
for key, widget in dashboard.config.items(): for key, widget in dashboard.config.items():
if models := widget['config'].get('models'): if models := widget['config'].get('models'):
models = list(map(lambda x: x.replace('users.netboxgroup', 'users.group'), models)) models = list(map(lambda x: x.replace('users.netboxgroup', 'users.group'), models))

View File

@ -3,7 +3,9 @@ from django.db import migrations, models
def update_link_buttons(apps, schema_editor): def update_link_buttons(apps, schema_editor):
CustomLink = apps.get_model('extras', 'CustomLink') CustomLink = apps.get_model('extras', 'CustomLink')
CustomLink.objects.filter(button_class='outline-dark').update(button_class='default') db_alias = schema_editor.connection.alias
CustomLink.objects.using(db_alias).filter(button_class='outline-dark').update(button_class='default')
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -3,19 +3,21 @@ from django.db import migrations
def update_content_types(apps, schema_editor): def update_content_types(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')
db_alias = schema_editor.connection.alias
# Delete the new ContentTypes effected by the new model in the core app # Delete the new ContentTypes effected by the new model in the core app
ContentType.objects.filter(app_label='core', model='objectchange').delete() ContentType.objects.using(db_alias).filter(app_label='core', model='objectchange').delete()
# Update the app labels of the original ContentTypes for extras.ObjectChange to ensure that any # Update the app labels of the original ContentTypes for extras.ObjectChange to ensure that any
# foreign key references are preserved # foreign key references are preserved
ContentType.objects.filter(app_label='extras', model='objectchange').update(app_label='core') ContentType.objects.using(db_alias).filter(app_label='extras', model='objectchange').update(app_label='core')
def update_dashboard_widgets(apps, schema_editor): def update_dashboard_widgets(apps, schema_editor):
Dashboard = apps.get_model('extras', 'Dashboard') Dashboard = apps.get_model('extras', 'Dashboard')
db_alias = schema_editor.connection.alias
for dashboard in Dashboard.objects.all(): for dashboard in Dashboard.objects.using(db_alias).all():
for key, widget in dashboard.config.items(): for key, widget in dashboard.config.items():
if widget['config'].get('model') == 'extras.objectchange': if widget['config'].get('model') == 'extras.objectchange':
widget['config']['model'] = 'core.objectchange' widget['config']['model'] = 'core.objectchange'

View File

@ -6,8 +6,9 @@ from core.events import *
def set_event_types(apps, schema_editor): def set_event_types(apps, schema_editor):
EventRule = apps.get_model('extras', 'EventRule') EventRule = apps.get_model('extras', 'EventRule')
event_rules = EventRule.objects.all() db_alias = schema_editor.connection.alias
event_rules = EventRule.objects.using(db_alias).all()
for event_rule in event_rules: for event_rule in event_rules:
event_rule.event_types = [] event_rule.event_types = []
if event_rule.type_create: if event_rule.type_create:

View File

@ -6,8 +6,9 @@ def set_null_values(apps, schema_editor):
Replace empty strings with null values. Replace empty strings with null values.
""" """
CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet') CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet')
db_alias = schema_editor.connection.alias
CustomFieldChoiceSet.objects.filter(base_choices='').update(base_choices=None) CustomFieldChoiceSet.objects.using(db_alias).filter(base_choices='').update(base_choices=None)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -8,7 +8,9 @@ def set_kind_default(apps, schema_editor):
Set kind to "info" on any entries with no kind assigned. Set kind to "info" on any entries with no kind assigned.
""" """
JournalEntry = apps.get_model('extras', 'JournalEntry') JournalEntry = apps.get_model('extras', 'JournalEntry')
JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO) db_alias = schema_editor.connection.alias
JournalEntry.objects.using(db_alias).filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -0,0 +1,56 @@
from django.conf import settings
from django.core.files.storage import storages
from django.db import migrations
from urllib.parse import urlparse
from extras.storage import ScriptFileSystemStorage
def normalize(url):
parsed_url = urlparse(url)
if not parsed_url.path.endswith('/'):
return url + '/'
return url
def fix_script_paths(apps, schema_editor):
"""
Fix script paths for scripts that had incorrect path from NB 4.3.
"""
storage = storages.create_storage(storages.backends["scripts"])
if not isinstance(storage, ScriptFileSystemStorage):
return
ScriptModule = apps.get_model('extras', 'ScriptModule')
script_root_path = normalize(settings.SCRIPTS_ROOT)
for script in ScriptModule.objects.filter(file_path__startswith=script_root_path):
script.file_path = script.file_path[len(script_root_path):]
script.save()
class Migration(migrations.Migration):
dependencies = [
('extras', '0128_tableconfig'),
]
operations = [
migrations.RunPython(code=fix_script_paths, reverse_code=migrations.RunPython.noop),
]
def oc_fix_script_paths(objectchange, reverting):
script_root_path = normalize(settings.SCRIPTS_ROOT)
for data in (objectchange.prechange_data, objectchange.postchange_data):
if data is None:
continue
if file_path := data.get('file_path'):
if file_path.startswith(script_root_path):
data['file_path'] = file_path[len(script_root_path):]
objectchange_migrators = {
'extras.scriptmodule': oc_fix_script_paths,
}

View File

@ -131,7 +131,7 @@ class RenderTemplateMixin(models.Model):
""" """
context = self.get_context(context=context, queryset=queryset) context = self.get_context(context=context, queryset=queryset)
env_params = self.environment_params or {} env_params = self.environment_params or {}
output = render_jinja2(self.template_code, context, env_params) output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
# Replace CRLF-style line terminators # Replace CRLF-style line terminators
output = output.replace('\r\n', '\n') output = output.replace('\r\n', '\n')

View File

@ -1,7 +1,7 @@
from functools import cached_property from functools import cached_property
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -144,6 +144,12 @@ class NotificationGroup(ChangeLoggedModel):
blank=True, blank=True,
related_name='notification_groups' related_name='notification_groups'
) )
event_rules = GenericRelation(
to='extras.EventRule',
content_type_field='action_object_type',
object_id_field='action_object_id',
related_query_name='+'
)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()

View File

@ -24,6 +24,17 @@ class JournalEntryIndex(SearchIndex):
display_attrs = ('kind', 'created_by') display_attrs = ('kind', 'created_by')
@register_search
class TagIndex(SearchIndex):
model = models.Tag
fields = (
('name', 100),
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search @register_search
class WebhookEntryIndex(SearchIndex): class WebhookEntryIndex(SearchIndex):
model = models.Webhook model = models.Webhook

View File

@ -2,7 +2,7 @@ import datetime
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import make_aware from django.utils.timezone import make_aware, now
from rest_framework import status from rest_framework import status
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices
@ -991,6 +991,10 @@ class SubscriptionTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
cls.bulk_update_data = {
'user': users[3].pk,
}
class NotificationGroupTest(APIViewTestCases.APIViewTestCase): class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
model = NotificationGroup model = NotificationGroup
@ -1072,6 +1076,9 @@ class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
class NotificationTest(APIViewTestCases.APIViewTestCase): class NotificationTest(APIViewTestCases.APIViewTestCase):
model = Notification model = Notification
brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user'] brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
bulk_update_data = {
'read': now(),
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -1,9 +1,12 @@
from django.forms import ValidationError import tempfile
from django.test import TestCase from pathlib import Path
from core.models import ObjectType from django.forms import ValidationError
from django.test import tag, TestCase
from core.models import DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, Tag from extras.models import ConfigContext, ConfigTemplate, Tag
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -33,8 +36,8 @@ class TagTest(TestCase):
] ]
site = Site.objects.create(name='Site 1') site = Site.objects.create(name='Site 1')
for tag in tags: for _tag in tags:
site.tags.add(tag) site.tags.add(_tag)
site.save() site.save()
site = Site.objects.first() site = Site.objects.first()
@ -540,3 +543,66 @@ class ConfigContextTest(TestCase):
device.local_context_data = 'foo' device.local_context_data = 'foo'
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
device.clean() device.clean()
class ConfigTemplateTest(TestCase):
"""
TODO: These test cases deal with the weighting, ordering, and deep merge logic of config context data.
"""
MAIN_TEMPLATE = """
{%- include 'base.j2' %}
""".strip()
BASE_TEMPLATE = """
Hi
""".strip()
@classmethod
def _create_template_file(cls, templates_dir, file_name, content):
template_file_name = file_name
if not template_file_name.endswith('j2'):
template_file_name += '.j2'
temp_file_path = templates_dir / template_file_name
with open(temp_file_path, 'w') as f:
f.write(content)
@classmethod
def setUpTestData(cls):
temp_dir = tempfile.TemporaryDirectory()
templates_dir = Path(temp_dir.name) / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
cls._create_template_file(templates_dir, 'base.j2', cls.BASE_TEMPLATE)
cls._create_template_file(templates_dir, 'main.j2', cls.MAIN_TEMPLATE)
data_source = DataSource(
name="Test DataSource",
type="local",
source_url=str(templates_dir),
)
data_source.save()
data_source.sync()
base_config_template = ConfigTemplate(
name="BaseTemplate",
data_file=data_source.datafiles.filter(path__endswith='base.j2').first()
)
base_config_template.clean()
base_config_template.save()
cls.base_config_template = base_config_template
main_config_template = ConfigTemplate(
name="MainTemplate",
data_file=data_source.datafiles.filter(path__endswith='main.j2').first()
)
main_config_template.clean()
main_config_template.save()
cls.main_config_template = main_config_template
@tag('regression')
def test_config_template_with_data_source(self):
self.assertEqual(self.BASE_TEMPLATE, self.base_config_template.render({}))
@tag('regression')
def test_config_template_with_data_source_nested_templates(self):
self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))

View File

@ -1,3 +1,4 @@
import logging
import tempfile import tempfile
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
@ -7,6 +8,7 @@ from netaddr import IPAddress, IPNetwork
from dcim.models import DeviceRole from dcim.models import DeviceRole
from extras.scripts import * from extras.scripts import *
from utilities.testing import disable_logging
CHOICES = ( CHOICES = (
('ff0000', 'Red'), ('ff0000', 'Red'),
@ -39,7 +41,8 @@ class ScriptTest(TestCase):
datafile.write(bytes(YAML_DATA, 'UTF-8')) datafile.write(bytes(YAML_DATA, 'UTF-8'))
datafile.seek(0) datafile.seek(0)
data = Script().load_yaml(datafile.name) with disable_logging(level=logging.WARNING):
data = Script().load_yaml(datafile.name)
self.assertEqual(data, { self.assertEqual(data, {
'Foo': 123, 'Foo': 123,
'Bar': 456, 'Bar': 456,
@ -51,7 +54,8 @@ class ScriptTest(TestCase):
datafile.write(bytes(JSON_DATA, 'UTF-8')) datafile.write(bytes(JSON_DATA, 'UTF-8'))
datafile.seek(0) datafile.seek(0)
data = Script().load_json(datafile.name) with disable_logging(level=logging.WARNING):
data = Script().load_json(datafile.name)
self.assertEqual(data, { self.assertEqual(data, {
'Foo': 123, 'Foo': 123,
'Bar': 456, 'Bar': 456,

View File

@ -444,6 +444,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2', weight=1), Tag(name='Tag 2', slug='tag-2', weight=1),
@ -456,14 +458,15 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'slug': 'tag-x', 'slug': 'tag-x',
'color': 'c0c0c0', 'color': 'c0c0c0',
'comments': 'Some comments', 'comments': 'Some comments',
'object_types': [site_ct.pk],
'weight': 11, 'weight': 11,
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,color,description,weight", "name,slug,color,description,object_types,weight",
"Tag 4,tag-4,ff0000,Fourth tag,0", "Tag 4,tag-4,ff0000,Fourth tag,dcim.interface,0",
"Tag 5,tag-5,00ff00,Fifth tag,1111", "Tag 5,tag-5,00ff00,Fifth tag,'dcim.device,dcim.site',1111",
"Tag 6,tag-6,0000ff,Sixth tag,0", "Tag 6,tag-6,0000ff,Sixth tag,dcim.site,0",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -14,12 +14,13 @@ 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.object_actions import BulkSync
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
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from extras.utils import SharedObjectViewMixin from extras.utils import SharedObjectViewMixin
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.object_actions import *
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
@ -96,6 +97,11 @@ class CustomFieldBulkEditView(generic.BulkEditView):
form = forms.CustomFieldBulkEditForm form = forms.CustomFieldBulkEditForm
@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
class CustomFieldBulkRenameView(generic.BulkRenameView):
queryset = CustomField.objects.all()
@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False) @register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
class CustomFieldBulkDeleteView(generic.BulkDeleteView): class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.select_related('choice_set') queryset = CustomField.objects.select_related('choice_set')
@ -165,6 +171,11 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
form = forms.CustomFieldChoiceSetBulkEditForm form = forms.CustomFieldChoiceSetBulkEditForm
@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False) @register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView): class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
queryset = CustomFieldChoiceSet.objects.all() queryset = CustomFieldChoiceSet.objects.all()
@ -215,6 +226,11 @@ class CustomLinkBulkEditView(generic.BulkEditView):
form = forms.CustomLinkBulkEditForm form = forms.CustomLinkBulkEditForm
@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
class CustomLinkBulkRenameView(generic.BulkRenameView):
queryset = CustomLink.objects.all()
@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False) @register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
class CustomLinkBulkDeleteView(generic.BulkDeleteView): class CustomLinkBulkDeleteView(generic.BulkDeleteView):
queryset = CustomLink.objects.all() queryset = CustomLink.objects.all()
@ -232,11 +248,7 @@ class ExportTemplateListView(generic.ObjectListView):
filterset = filtersets.ExportTemplateFilterSet filterset = filtersets.ExportTemplateFilterSet
filterset_form = forms.ExportTemplateFilterForm filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable table = tables.ExportTemplateTable
template_name = 'extras/exporttemplate_list.html' actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ExportTemplate) @register_model_view(ExportTemplate)
@ -270,6 +282,11 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
form = forms.ExportTemplateBulkEditForm form = forms.ExportTemplateBulkEditForm
@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
class ExportTemplateBulkRenameView(generic.BulkRenameView):
queryset = ExportTemplate.objects.all()
@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False) @register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
class ExportTemplateBulkDeleteView(generic.BulkDeleteView): class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
@ -330,6 +347,11 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.SavedFilterBulkEditForm form = forms.SavedFilterBulkEditForm
@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
class SavedFilterBulkRenameView(generic.BulkRenameView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False) @register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
@ -347,9 +369,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
filterset = filtersets.TableConfigFilterSet filterset = filtersets.TableConfigFilterSet
filterset_form = forms.TableConfigFilterForm filterset_form = forms.TableConfigFilterForm
table = tables.TableConfigTable table = tables.TableConfigTable
actions = { actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
'export': {'view'},
}
@register_model_view(TableConfig) @register_model_view(TableConfig)
@ -389,6 +409,11 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.TableConfigBulkEditForm form = forms.TableConfigBulkEditForm
@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
class TableConfigBulkRenameView(generic.BulkRenameView):
queryset = TableConfig.objects.all()
@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False) @register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = TableConfig.objects.all() queryset = TableConfig.objects.all()
@ -470,6 +495,11 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
form = forms.NotificationGroupBulkEditForm form = forms.NotificationGroupBulkEditForm
@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
class NotificationGroupBulkRenameView(generic.BulkRenameView):
queryset = NotificationGroup.objects.all()
@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
class NotificationGroupBulkDeleteView(generic.BulkDeleteView): class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
queryset = NotificationGroup.objects.all() queryset = NotificationGroup.objects.all()
@ -616,6 +646,11 @@ class WebhookBulkEditView(generic.BulkEditView):
form = forms.WebhookBulkEditForm form = forms.WebhookBulkEditForm
@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
class WebhookBulkRenameView(generic.BulkRenameView):
queryset = Webhook.objects.all()
@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False) @register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
class WebhookBulkDeleteView(generic.BulkDeleteView): class WebhookBulkDeleteView(generic.BulkDeleteView):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()
@ -666,6 +701,11 @@ class EventRuleBulkEditView(generic.BulkEditView):
form = forms.EventRuleBulkEditForm form = forms.EventRuleBulkEditForm
@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
class EventRuleBulkRenameView(generic.BulkRenameView):
queryset = EventRule.objects.all()
@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False) @register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
class EventRuleBulkDeleteView(generic.BulkDeleteView): class EventRuleBulkDeleteView(generic.BulkDeleteView):
queryset = EventRule.objects.all() queryset = EventRule.objects.all()
@ -740,6 +780,11 @@ class TagBulkEditView(generic.BulkEditView):
form = forms.TagBulkEditForm form = forms.TagBulkEditForm
@register_model_view(Tag, 'bulk_rename', path='rename', detail=False)
class TagBulkRenameView(generic.BulkRenameView):
queryset = Tag.objects.all()
@register_model_view(Tag, 'bulk_delete', path='delete', detail=False) @register_model_view(Tag, 'bulk_delete', path='delete', detail=False)
class TagBulkDeleteView(generic.BulkDeleteView): class TagBulkDeleteView(generic.BulkDeleteView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
@ -758,13 +803,7 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable table = tables.ConfigContextTable
template_name = 'extras/configcontext_list.html' actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
actions = {
'add': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_sync': {'sync'},
}
@register_model_view(ConfigContext) @register_model_view(ConfigContext)
@ -825,6 +864,11 @@ class ConfigContextBulkEditView(generic.BulkEditView):
form = forms.ConfigContextBulkEditForm form = forms.ConfigContextBulkEditForm
@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
class ConfigContextBulkRenameView(generic.BulkRenameView):
queryset = ConfigContext.objects.all()
@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False) @register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
class ConfigContextBulkDeleteView(generic.BulkDeleteView): class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
@ -877,11 +921,7 @@ class ConfigTemplateListView(generic.ObjectListView):
filterset = filtersets.ConfigTemplateFilterSet filterset = filtersets.ConfigTemplateFilterSet
filterset_form = forms.ConfigTemplateFilterForm filterset_form = forms.ConfigTemplateFilterForm
table = tables.ConfigTemplateTable table = tables.ConfigTemplateTable
template_name = 'extras/configtemplate_list.html' actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ConfigTemplate) @register_model_view(ConfigTemplate)
@ -915,6 +955,11 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
form = forms.ConfigTemplateBulkEditForm form = forms.ConfigTemplateBulkEditForm
@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
class ConfigTemplateBulkRenameView(generic.BulkRenameView):
queryset = ConfigTemplate.objects.all()
@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False) @register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
class ConfigTemplateBulkDeleteView(generic.BulkDeleteView): class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigTemplate.objects.all() queryset = ConfigTemplate.objects.all()
@ -966,7 +1011,7 @@ class ObjectRenderConfigView(generic.ObjectView):
# Render the config template # Render the config template
rendered_config = None rendered_config = None
error_message = None error_message = ''
if config_template := instance.get_config_template(): if config_template := instance.get_config_template():
try: try:
rendered_config = config_template.render(context=context_data) rendered_config = config_template.render(context=context_data)
@ -992,9 +1037,7 @@ class ImageAttachmentListView(generic.ObjectListView):
filterset = filtersets.ImageAttachmentFilterSet filterset = filtersets.ImageAttachmentFilterSet
filterset_form = forms.ImageAttachmentFilterForm filterset_form = forms.ImageAttachmentFilterForm
table = tables.ImageAttachmentTable table = tables.ImageAttachmentTable
actions = { actions = (BulkExport,)
'export': {'view'},
}
@register_model_view(ImageAttachment, 'add', detail=False) @register_model_view(ImageAttachment, 'add', detail=False)
@ -1038,12 +1081,7 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable table = tables.JournalEntryTable
actions = { actions = (BulkImport, BulkEdit, BulkDelete)
'export': {'view'},
'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(JournalEntry) @register_model_view(JournalEntry)
@ -1476,7 +1514,16 @@ class ScriptResultView(TableMixin, generic.ObjectView):
table = None table = None
job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk')) job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
if job.completed: # If a direct export output has been requested, return the job data content as a
# downloadable file.
if job.completed and request.GET.get('export') == 'output':
content = (job.data.get("output") or "").encode()
response = HttpResponse(content, content_type='text')
filename = f"{job.object.name or 'script-output'}_{job.completed.strftime('%Y-%m-%d_%H%M%S')}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
elif job.completed:
table = self.get_table(job, request, bulk_actions=False) table = self.get_table(job, request, bulk_actions=False)
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO) log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)

View File

@ -66,7 +66,7 @@ class VLANSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False) status = ChoiceField(choices=VLANStatusChoices, required=False)
role = RoleSerializer(nested=True, required=False, allow_null=True) role = RoleSerializer(nested=True, required=False, allow_null=True)
qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False) qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False, allow_null=True)
qinq_svlan = NestedVLANSerializer(required=False, allow_null=True, default=None) qinq_svlan = NestedVLANSerializer(required=False, allow_null=True, default=None)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)

View File

@ -1,7 +1,8 @@
from copy import deepcopy from copy import deepcopy
from django.contrib.contenttypes.prefetch import GenericPrefetch
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import router, transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
@ -13,6 +14,7 @@ from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.views import APIView from rest_framework.views import APIView
from dcim.models import Interface
from ipam import filtersets from ipam import filtersets
from ipam.models import * from ipam.models import *
from ipam.utils import get_next_available_prefix from ipam.utils import get_next_available_prefix
@ -21,6 +23,7 @@ from netbox.api.viewsets.mixins import ObjectValidationMixin
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from virtualization.models import VMInterface
from . import serializers from . import serializers
@ -79,7 +82,7 @@ class RoleViewSet(NetBoxModelViewSet):
class PrefixViewSet(NetBoxModelViewSet): class PrefixViewSet(NetBoxModelViewSet):
queryset = Prefix.objects.all() queryset = Prefix.objects.prefetch_related("scope")
serializer_class = serializers.PrefixSerializer serializer_class = serializers.PrefixSerializer
filterset_class = filtersets.PrefixFilterSet filterset_class = filtersets.PrefixFilterSet
@ -100,7 +103,17 @@ class IPRangeViewSet(NetBoxModelViewSet):
class IPAddressViewSet(NetBoxModelViewSet): class IPAddressViewSet(NetBoxModelViewSet):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.prefetch_related(
GenericPrefetch(
"assigned_object",
[
# serializers are taken according to IPADDRESS_ASSIGNMENT_MODELS
FHRPGroup.objects.all(),
Interface.objects.select_related("cable", "device"),
VMInterface.objects.select_related("virtual_machine"),
],
),
)
serializer_class = serializers.IPAddressSerializer serializer_class = serializers.IPAddressSerializer
filterset_class = filtersets.IPAddressFilterSet filterset_class = filtersets.IPAddressFilterSet
@ -282,7 +295,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
# Create the new IP address(es) # Create the new IP address(es)
try: try:
with transaction.atomic(): with transaction.atomic(using=router.db_for_write(self.queryset.model)):
created = serializer.save() created = serializer.save()
self._validate_objects(created) self._validate_objects(created)
except ObjectDoesNotExist: except ObjectDoesNotExist:

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