diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index 039e24fc4..557cb3d63 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -15,7 +15,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.2.9 + placeholder: v4.3.3 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 0f18e6267..77ca1ecc9 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -27,7 +27,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v4.2.9 + placeholder: v4.3.3 validations: required: true - type: dropdown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 100d996c6..af1954c9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@

:jigsaw: Create a plugin · - :rescue_worker_helmet: Become a maintainer · + :briefcase: Work with us! · :heart: Other ideas

@@ -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! -## :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: - -* 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! +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! ## :heart: Other Ways to Contribute diff --git a/README.md b/README.md index 3a29a6fd2..745205a24 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Languages supported CI status

- NetBox Community | + NetBox Community | NetBox Cloud | NetBox Enterprise

diff --git a/SECURITY.md b/SECURITY.md index 97881a901..58b73cbb7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,12 @@ Administrators are encouraged to adhere to industry best practices concerning th * Prohibit access to your database from clients other than the NetBox application * Keep your deployment updated to the most recent stable release +## Compliance Reporting + +Please note that security compliance reports (e.g. SOC 2) are provided by NetBox Labs only to customers using NetBox Cloud or NetBox Enterprise. They are not available to users of self-hosted NetBox Community Edition. + +If you would like to consider upgrading to NetBox Cloud or Enterprise, please contact `sales@netboxlabs.com`. + ## Reporting a Suspected Vulnerability If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions: diff --git a/base_requirements.txt b/base_requirements.txt index 0c6e308e1..e52ff042e 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -140,7 +140,8 @@ strawberry-graphql # Strawberry GraphQL Django extension # 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) # https://github.com/mozman/svgwrite/blob/master/NEWS.rst diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 66a61cbad..baecb910f 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -329,6 +329,7 @@ "100base-tx", "100base-t1", "1000base-t", + "1000base-sx", "1000base-lx", "1000base-tx", "2.5gbase-t", diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md index 3b86a78d2..45b18953a 100644 --- a/docs/configuration/error-reporting.md +++ b/docs/configuration/error-reporting.md @@ -4,7 +4,7 @@ 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" @@ -16,7 +16,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" 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 The `sentry-sdk` Python package is required to enable Sentry integration. diff --git a/docs/configuration/graphql-api.md b/docs/configuration/graphql-api.md index 9b02d745c..2c1a1c33b 100644 --- a/docs/configuration/graphql-api.md +++ b/docs/configuration/graphql-api.md @@ -6,7 +6,7 @@ Default: `True` -Setting this to False will disable the GraphQL API. +Setting this to `False` will disable the GraphQL API. --- diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index e4d46f428..1fbd0ee35 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -57,7 +57,7 @@ Sets content for the top banner in the user interface. 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. @@ -102,7 +102,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da 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` -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` -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. --- diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md index 9e19622f9..9f838c119 100644 --- a/docs/configuration/plugins.md +++ b/docs/configuration/plugins.md @@ -35,7 +35,7 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff ## PLUGINS_CATALOG_CONFIG -Default: Empty +Default: `{}` (Empty) This parameter controls how individual plugins are displayed in the plugins catalog under Admin > System > Plugins. Adding a plugin to the `hidden` list will omit that plugin from the catalog. Adding a plugin to the `static` list will display the plugin, but not link to the plugin details or upgrade instructions. diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 5f28d987f..5d5f1ee58 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -1,6 +1,6 @@ # 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` -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` -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) -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`.) --- diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 4a18e8a6c..25aa7978a 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -2,12 +2,12 @@ ## 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 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: diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 771eba5c5..775490b70 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -5,7 +5,7 @@ Default: `False` !!! 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. @@ -52,7 +52,7 @@ Although it is not recommended, the default validation rules can be disabled by 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 `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 CORS_ORIGIN_WHITELIST = [ @@ -84,7 +84,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti 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: `[]` -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 CSRF_TRUSTED_ORIGINS = ( @@ -135,7 +135,7 @@ DEFAULT_PERMISSIONS = { ## EXEMPT_VIEW_PERMISSIONS -Default: Empty list +Default: `[]` (Empty list) A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous. @@ -164,7 +164,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] 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. @@ -191,7 +191,7 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u ## LOGIN_FORM_HIDDEN -Default: False +Default: `False` Option to hide the login form when only SSO authentication is in use. @@ -212,7 +212,7 @@ The view name or URL to which a user is redirected after logging out. 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` -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` -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 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` -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. --- diff --git a/docs/configuration/system.md b/docs/configuration/system.md index fe01e40b1..20143276c 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -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 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` -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 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: `{}` -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 def uppercase(x): diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index e48cb140e..5478e37e9 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -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 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 @@ -164,7 +166,8 @@ Then, compile these portable (`.po`) files for use in the application: ### 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/`. * 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 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 . 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 has been updated. diff --git a/docs/index.md b/docs/index.md index a79ab03b4..1494de5f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ NetBox is the leading solution for modeling and documenting modern networks. By ## :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 * Racks, devices, and device components diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 67a19e2e3..60a118977 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -108,7 +108,7 @@ Open `configuration.py` with your preferred editor to begin configuring NetBox. ### 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 ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123'] diff --git a/docs/installation/4b-uwsgi.md b/docs/installation/4b-uwsgi.md index c8d1437a0..783ef9f06 100644 --- a/docs/installation/4b-uwsgi.md +++ b/docs/installation/4b-uwsgi.md @@ -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 ``` -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 diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index f9a7a3189..21ffa9766 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -122,7 +122,7 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/ ### 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 \ @@ -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: ``` +cd /opt/netbox && \ +sudo git fetch && \ sudo git checkout v4.2.7 ``` diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index 0914d0aa6..35b0b68eb 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -10,11 +10,11 @@ The assignment of platforms to devices is an optional feature, and may be disreg ### Name -A unique human-friendly name. +A human-friendly name for the platform. Must be unique per manufacturer. ### 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 diff --git a/docs/plugins/development/migration-v4.md b/docs/plugins/development/migration-v4.md index 9622fab30..bf7e720ac 100644 --- a/docs/plugins/development/migration-v4.md +++ b/docs/plugins/development/migration-v4.md @@ -22,7 +22,7 @@ from netbox.plugins import PluginConfig ### 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). diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 43cc0ce82..01c7737ba 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -1,6 +1,6 @@ # 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`. @@ -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. +## 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 -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 | |----------------------|--------------------------------------------------------| @@ -60,23 +64,57 @@ NetBox provides several generic view classes (documented below) to facilitate co | `ObjectListView` | View a list of objects | | `BulkImportView` | Import a set of new objects | | `BulkEditView` | Edit multiple objects | +| `BulkRenameView` | Rename multiple objects | | `BulkDeleteView` | Delete multiple objects | !!! 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. -#### 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 # views.py from netbox.views.generic import ObjectEditView +from utilities.views import register_model_view from .models import Thing +@register_model_view(Thing, name='add', detail=False) +@register_model_view(Thing, name='edit') class ThingEditView(ObjectEditView): queryset = Thing.objects.all() 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//', include(get_model_urls('myplugin', 'thing'))), + ... +] +``` + ## 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. @@ -134,6 +172,10 @@ Below are the class definitions for NetBox's multi-object views. These views han options: members: false +::: netbox.views.generic.BulkRenameView + options: + members: false + ::: netbox.views.generic.BulkDeleteView options: 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. +!!! 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 options: members: @@ -157,7 +202,7 @@ These views are provided to enable or enhance certain NetBox model features, suc ### Additional Tabs -Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, 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 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 ### Extra Template Content diff --git a/docs/plugins/removal.md b/docs/plugins/removal.md index 37228a939..039dcec83 100644 --- a/docs/plugins/removal.md +++ b/docs/plugins/removal.md @@ -86,3 +86,69 @@ netbox=> DELETE FROM django_migrations WHERE app='pluginname'; !!! 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. + +## 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 plugin’s 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 +``` diff --git a/docs/release-notes/version-4.3.md b/docs/release-notes/version-4.3.md index 7597baa20..d36698c00 100644 --- a/docs/release-notes/version-4.3.md +++ b/docs/release-notes/version-4.3.md @@ -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 @@ -7,6 +93,7 @@ * The `ALLOW_TOKEN_RETRIEVAL` configuration parameter now defaults to False. * The `device` and `virtual_machine` foreign keys on the Service model have been replaced with a generic `parent` relationship to support the assignment of services to FHRP groups as well. * The `group` foreign key on the Contact model has been replaced with a many-to-many `groups` field. +* `django-storages` is now a required dependency. (It will be installed automatically on upgrade.) * PluginTemplateExtension no longer supports registration via the singular `model` attribute (use `models` instead). * The legacy staged changes functionality has been removed. @@ -58,6 +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) * [#18783](https://github.com/netbox-community/netbox/issues/18783) - Enable filtering all applicable models by tag ID * [#18785](https://github.com/netbox-community/netbox/issues/18785) - Enable custom choices for rack, device, and module airflow +* [#18896](https://github.com/netbox-community/netbox/issues/18896) - Enable the use of remote storage for custom scripts ### Plugins @@ -67,15 +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 * [#19073](https://github.com/netbox-community/netbox/issues/19073) - Allow installed plugins to be omitted from the plugins list -### Bug Fixes (From beta1) - -* [#19213](https://github.com/netbox-community/netbox/issues/19213) - Fix rendering of dropdown selection form fields -* [#19224](https://github.com/netbox-community/netbox/issues/19224) - Fix GraphQL API support for custom field choices -* [#19225](https://github.com/netbox-community/netbox/issues/19225) - Restore missing GraphQL API filters -* [#19263](https://github.com/netbox-community/netbox/issues/19263) - Render action buttons only if the record model matches the table model -* [#19264](https://github.com/netbox-community/netbox/issues/19264) - Support table configs on child object list views -* [#19266](https://github.com/netbox-community/netbox/issues/19266) - Fix copy-to-clipboard button for IP addresses - ### Other Changes * [#18071](https://github.com/netbox-community/netbox/issues/18071) - Removed legacy staged changed functionality in favor of the [netbox-branching](https://github.com/netboxlabs/netbox-branching) plugin @@ -83,7 +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 * [#18236](https://github.com/netbox-community/netbox/issues/18236) - Upgrade the HTMX library to v2.0 * [#18540](https://github.com/netbox-community/netbox/issues/18540) - Operational plugins are now recorded in the application registry -* [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.0 +* [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.2 * [#18743](https://github.com/netbox-community/netbox/issues/18743) - Upgrade Django to v5.2 * [#18751](https://github.com/netbox-community/netbox/issues/18751) - Change the default value for `ALLOW_TOKEN_RETRIEVAL` to False * [#18808](https://github.com/netbox-community/netbox/issues/18808) - Squashed migration dependencies have been altered to rectify an issue with Django's `sqlmigrate` management command diff --git a/netbox/account/views.py b/netbox/account/views.py index f28d5eff5..f5ef534ce 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -191,12 +191,10 @@ class ProfileView(LoginRequiredMixin, View): def get(self, request): # Compile changelog table - changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( - user=request.user - ).prefetch_related( - 'changed_object_type' - )[:20] + changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=request.user)[:20] changelog_table = ObjectChangeTable(changelog) + changelog_table.orderable = False + changelog_table.configure(request) return render(request, self.template_name, { 'changelog_table': changelog_table, diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 6f8ab783d..ce09862ae 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -16,6 +16,7 @@ from utilities.forms import get_field_value from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, ) +from utilities.forms.mixins import DistanceValidationMixin from utilities.forms.rendering import FieldSet, InlineFields from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions 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( label=_('Provider'), queryset=Provider.objects.all(), diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 966849fd0..d6ef2976d 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -41,7 +41,7 @@ __all__ = ( ) -@strawberry_django.filter(models.CircuitTermination, lookups=True) +@strawberry_django.filter_type(models.CircuitTermination, lookups=True) class CircuitTerminationFilter( BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, @@ -87,7 +87,7 @@ class CircuitTerminationFilter( ) -@strawberry_django.filter(models.Circuit, lookups=True) +@strawberry_django.filter_type(models.Circuit, lookups=True) class CircuitFilter( ContactFilterMixin, 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): pass -@strawberry_django.filter(models.CircuitGroup, lookups=True) +@strawberry_django.filter_type(models.CircuitGroup, lookups=True) class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): pass -@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True) +@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True) class CircuitGroupAssignmentFilter( 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): name: 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): provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -168,7 +168,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin): 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): name: FilterLookup[str] | None = strawberry_django.filter_field() 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() -@strawberry_django.filter(models.VirtualCircuitType, lookups=True) +@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True) class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin): pass -@strawberry_django.filter(models.VirtualCircuit, lookups=True) +@strawberry_django.filter_type(models.VirtualCircuit, lookups=True) class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin): cid: FilterLookup[str] | None = strawberry_django.filter_field() 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( BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin ): diff --git a/netbox/circuits/migrations/0046_charfield_null_choices.py b/netbox/circuits/migrations/0046_charfield_null_choices.py index 2a8bcde90..de6b2e6f9 100644 --- a/netbox/circuits/migrations/0046_charfield_null_choices.py +++ b/netbox/circuits/migrations/0046_charfield_null_choices.py @@ -8,10 +8,11 @@ def set_null_values(apps, schema_editor): Circuit = apps.get_model('circuits', 'Circuit') CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment') CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + db_alias = schema_editor.connection.alias - Circuit.objects.filter(distance_unit='').update(distance_unit=None) - CircuitGroupAssignment.objects.filter(priority='').update(priority=None) - CircuitTermination.objects.filter(cable_end='').update(cable_end=None) + Circuit.objects.using(db_alias).filter(distance_unit='').update(distance_unit=None) + CircuitGroupAssignment.objects.using(db_alias).filter(priority='').update(priority=None) + CircuitTermination.objects.using(db_alias).filter(cable_end='').update(cable_end=None) class Migration(migrations.Migration): diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py index 4caa3a37d..0b0c6233c 100644 --- a/netbox/circuits/migrations/0047_circuittermination__termination.py +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -1,4 +1,5 @@ import django.db.models.deletion +from django.contrib.contenttypes.models import ContentType from django.db import migrations, models @@ -8,14 +9,15 @@ def copy_site_assignments(apps, schema_editor): """ ContentType = apps.get_model('contenttypes', 'ContentType') CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork') Site = apps.get_model('dcim', 'Site') + db_alias = schema_editor.connection.alias - CircuitTermination.objects.filter(site__isnull=False).update( + CircuitTermination.objects.using(db_alias).filter(site__isnull=False).update( termination_type=ContentType.objects.get_for_model(Site), termination_id=models.F('site_id') ) - ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork') - CircuitTermination.objects.filter(provider_network__isnull=False).update( + CircuitTermination.objects.using(db_alias).filter(provider_network__isnull=False).update( termination_type=ContentType.objects.get_for_model(ProviderNetwork), termination_id=models.F('provider_network_id'), ) @@ -48,3 +50,26 @@ class Migration(migrations.Migration): # Copy over existing site assignments 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, +} diff --git a/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py index 9be254d54..f2676c2ee 100644 --- a/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py +++ b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py @@ -7,15 +7,20 @@ def populate_denormalized_fields(apps, schema_editor): Copy site ForeignKey values to the Termination GFK. """ CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + db_alias = schema_editor.connection.alias - terminations = CircuitTermination.objects.filter(site__isnull=False).prefetch_related('site') + terminations = CircuitTermination.objects.using(db_alias).filter(site__isnull=False).prefetch_related('site') for termination in terminations: termination._region_id = termination.site.region_id termination._site_group_id = termination.site.group_id termination._site_id = termination.site_id # Note: Location cannot be set prior to migration - CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'], batch_size=100) + CircuitTermination.objects.using(db_alias).bulk_update( + terminations, + ['_region', '_site_group', '_site'], + batch_size=100 + ) class Migration(migrations.Migration): @@ -81,3 +86,15 @@ class Migration(migrations.Migration): 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, +} diff --git a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py index 0418c26e5..50f5fd5a6 100644 --- a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py +++ b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py @@ -1,4 +1,5 @@ import django.db.models.deletion +from django.contrib.contenttypes.models import ContentType from django.db import migrations, models @@ -9,8 +10,9 @@ def set_member_type(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') Circuit = apps.get_model('circuits', 'Circuit') CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment') + db_alias = schema_editor.connection.alias - CircuitGroupAssignment.objects.update( + CircuitGroupAssignment.objects.using(db_alias).update( member_type=ContentType.objects.get_for_model(Circuit) ) @@ -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, +} diff --git a/netbox/circuits/migrations/0052_extend_circuit_abs_distance_upper_limit.py b/netbox/circuits/migrations/0052_extend_circuit_abs_distance_upper_limit.py new file mode 100644 index 000000000..abc54f627 --- /dev/null +++ b/netbox/circuits/migrations/0052_extend_circuit_abs_distance_upper_limit.py @@ -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), + ), + ] diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 3643446bd..901893a77 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -120,7 +120,8 @@ class CircuitTerminationTable(NetBoxTable): ) termination = tables.Column( verbose_name=_('Termination Point'), - linkify=True + linkify=True, + orderable=False, ) # Termination types @@ -132,7 +133,7 @@ class CircuitTerminationTable(NetBoxTable): site_group = tables.Column( verbose_name=_('Site Group'), linkify=True, - accessor='_sitegroup' + accessor='_site_group' ) region = tables.Column( verbose_name=_('Region'), diff --git a/netbox/circuits/tables/virtual_circuits.py b/netbox/circuits/tables/virtual_circuits.py index 67ac03d59..ea3b6dc13 100644 --- a/netbox/circuits/tables/virtual_circuits.py +++ b/netbox/circuits/tables/virtual_circuits.py @@ -54,9 +54,8 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) linkify=True, verbose_name=_('Account') ) - type = tables.Column( + type = columns.ColoredLabelColumn( verbose_name=_('Type'), - linkify=True ) status = columns.ChoiceFieldColumn() termination_count = columns.LinkedCountColumn( diff --git a/netbox/circuits/tests/test_tables.py b/netbox/circuits/tests/test_tables.py new file mode 100644 index 000000000..2ab001c9b --- /dev/null +++ b/netbox/circuits/tests/test_tables.py @@ -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) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 62056cfbe..8670b8535 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,10 +1,11 @@ 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.utils.translation import gettext_lazy as _ from dcim.views import PathTraceView from ipam.models import ASN +from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.query import count_related @@ -79,6 +80,11 @@ class ProviderBulkEditView(generic.BulkEditView): 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) class ProviderBulkDeleteView(generic.BulkDeleteView): queryset = Provider.objects.annotate( @@ -141,6 +147,11 @@ class ProviderAccountBulkEditView(generic.BulkEditView): 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) class ProviderAccountBulkDeleteView(generic.BulkDeleteView): queryset = ProviderAccount.objects.annotate( @@ -212,6 +223,11 @@ class ProviderNetworkBulkEditView(generic.BulkEditView): 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) class ProviderNetworkBulkDeleteView(generic.BulkDeleteView): queryset = ProviderNetwork.objects.all() @@ -271,6 +287,11 @@ class CircuitTypeBulkEditView(generic.BulkEditView): 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) class CircuitTypeBulkDeleteView(generic.BulkDeleteView): queryset = CircuitType.objects.annotate( @@ -337,6 +358,12 @@ class CircuitBulkEditView(generic.BulkEditView): 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) class CircuitBulkDeleteView(generic.BulkDeleteView): queryset = Circuit.objects.prefetch_related( @@ -384,7 +411,7 @@ class CircuitSwapTerminations(generic.ObjectEditView): if termination_a and termination_z: # 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.save() termination_z.term_side = 'A' @@ -432,6 +459,7 @@ class CircuitTerminationListView(generic.ObjectListView): filterset = filtersets.CircuitTerminationFilterSet filterset_form = forms.CircuitTerminationFilterForm table = tables.CircuitTerminationTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(CircuitTermination) @@ -526,6 +554,11 @@ class CircuitGroupBulkEditView(generic.BulkEditView): 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) class CircuitGroupBulkDeleteView(generic.BulkDeleteView): queryset = CircuitGroup.objects.all() @@ -543,6 +576,7 @@ class CircuitGroupAssignmentListView(generic.ObjectListView): filterset = filtersets.CircuitGroupAssignmentFilterSet filterset_form = forms.CircuitGroupAssignmentFilterForm table = tables.CircuitGroupAssignmentTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(CircuitGroupAssignment) @@ -635,6 +669,11 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView): 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) class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView): queryset = VirtualCircuitType.objects.annotate( @@ -697,6 +736,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView): 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): queryset = VirtualCircuit.objects.annotate( termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') @@ -714,6 +759,7 @@ class VirtualCircuitTerminationListView(generic.ObjectListView): filterset = filtersets.VirtualCircuitTerminationFilterSet filterset_form = forms.VirtualCircuitTerminationFilterForm table = tables.VirtualCircuitTerminationTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(VirtualCircuitTermination) diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index e5d44674a..76ace2362 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -23,7 +23,7 @@ __all__ = ( ) -@strawberry_django.filter(models.DataFile, lookups=True) +@strawberry_django.filter_type(models.DataFile, lookups=True) class DataFileFilter(BaseFilterMixin): id: ID | 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() -@strawberry_django.filter(models.DataSource, lookups=True) +@strawberry_django.filter_type(models.DataSource, lookups=True) class DataSourceFilter(PrimaryModelFilterMixin): name: 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): id: ID | 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): id: ID | None = strawberry_django.filter_field() app_label: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index b0301848f..a7d5c91af 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,7 +1,9 @@ from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.db.models import Q +from netbox.plugins import PluginConfig from netbox.registry import registry +from utilities.string import title __all__ = ( 'ObjectType', @@ -48,3 +50,29 @@ class ObjectType(ContentType): class Meta: 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) diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index d60269b8b..9af20b484 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -88,19 +88,11 @@ class ManagedFile(SyncedDataMixin, models.Model): def sync_data(self): if self.data_file: self.file_path = os.path.basename(self.data_path) - self._write_to_disk(self.full_path, overwrite=True) - def _write_to_disk(self, path, overwrite=False): - """ - Write the object's data to disk at the specified path - """ - # Check whether file already exists - storage = self.storage - if storage.exists(path) and not overwrite: - raise FileExistsError() + storage = self.storage - with storage.open(path, 'wb+') as new_file: - new_file.write(self.data) + with storage.open(self.full_path, 'wb+') as new_file: + new_file.write(self.data_file.data) @cached_property def storage(self): diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 8c704ecad..779e767b6 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -215,6 +215,7 @@ class Job(models.Model): schedule_at=None, interval=None, immediate=False, + queue_name=None, **kwargs ): """ @@ -238,7 +239,7 @@ class Job(models.Model): object_id = instance.pk else: object_type = object_id = None - rq_queue_name = get_queue_for_model(object_type.model if object_type else None) + rq_queue_name = queue_name if queue_name else get_queue_for_model(object_type.model if object_type else None) queue = django_rq.get_queue(rq_queue_name) status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING job = Job( diff --git a/netbox/core/object_actions.py b/netbox/core/object_actions.py new file mode 100644 index 000000000..81b5fb2c8 --- /dev/null +++ b/netbox/core/object_actions.py @@ -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' diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 4b537b2d4..8ba8cc244 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -162,6 +162,12 @@ def handle_deleted_object(sender, instance, **kwargs): getattr(obj, related_field_name).remove(instance) elif type(relation) is ManyToOneRel and relation.field.null is True: 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() # Enqueue the object for event processing diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index d8fb8fd83..e9e77f252 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -9,6 +9,7 @@ from rq.registry import FailedJobRegistry, StartedJobRegistry from users.models import Token, User from utilities.testing import APITestCase, APIViewTestCases, TestCase +from utilities.testing.utils import disable_logging from ..models import * @@ -189,7 +190,8 @@ class BackgroundTaskTestCase(TestCase): # Enqueue & run a job that will fail job = queue.enqueue(self.dummy_job_failing) worker = get_worker('default') - worker.work(burst=True) + with disable_logging(): + worker.work(burst=True) self.assertTrue(job.is_failed) # 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) response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header) 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) self.assertEqual(len(started_job_registry), 0) diff --git a/netbox/core/tests/test_changelog.py b/netbox/core/tests/test_changelog.py index 4914dbaf3..df8461076 100644 --- a/netbox/core/tests/test_changelog.py +++ b/netbox/core/tests/test_changelog.py @@ -6,12 +6,13 @@ from rest_framework import status from core.choices import ObjectChangeActionChoices from core.models import ObjectChange, ObjectType 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.models import CustomField, CustomFieldChoiceSet, Tag from utilities.testing import APITestCase from utilities.testing.utils import create_tags, post_data from utilities.testing.views import ModelViewTestCase +from dcim.models import Manufacturer class ChangeLogViewTest(ModelViewTestCase): @@ -270,6 +271,81 @@ class ChangeLogViewTest(ModelViewTestCase): # Check that no ObjectChange records have been created 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): diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 047b51ef6..96a4292df 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -14,7 +14,7 @@ from core.choices import ObjectChangeActionChoices from core.models import * from dcim.models import Site 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): @@ -271,7 +271,8 @@ class BackgroundTaskTestCase(TestCase): # Enqueue & run a job that will fail job = queue.enqueue(self.dummy_job_failing) worker = get_worker('default') - worker.work(burst=True) + with disable_logging(): + worker.work(burst=True) self.assertTrue(job.is_failed) # 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) response = self.client.get(reverse('core:background_task_stop', args=[job.id])) 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) canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection) diff --git a/netbox/core/views.py b/netbox/core/views.py index 1264c6c1b..5729e5f2c 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -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 netbox.config import get_config, PARAMS +from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject from netbox.registry import registry from netbox.views import generic from netbox.views.generic.base import BaseObjectView @@ -119,6 +120,11 @@ class DataSourceBulkEditView(generic.BulkEditView): 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) class DataSourceBulkDeleteView(generic.BulkDeleteView): queryset = DataSource.objects.annotate( @@ -138,14 +144,13 @@ class DataFileListView(generic.ObjectListView): filterset = filtersets.DataFileFilterSet filterset_form = forms.DataFileFilterForm table = tables.DataFileTable - actions = { - 'bulk_delete': {'delete'}, - } + actions = (BulkDelete,) @register_model_view(DataFile) class DataFileView(generic.ObjectView): queryset = DataFile.objects.all() + actions = (DeleteObject,) @register_model_view(DataFile, 'delete') @@ -170,15 +175,13 @@ class JobListView(generic.ObjectListView): filterset = filtersets.JobFilterSet filterset_form = forms.JobFilterForm table = tables.JobTable - actions = { - 'export': {'view'}, - 'bulk_delete': {'delete'}, - } + actions = (BulkExport, BulkDelete) @register_model_view(Job) class JobView(generic.ObjectView): queryset = Job.objects.all() + actions = (DeleteObject,) @register_model_view(Job, 'delete') @@ -204,9 +207,7 @@ class ObjectChangeListView(generic.ObjectListView): filterset_form = forms.ObjectChangeFilterForm table = tables.ObjectChangeTable template_name = 'core/objectchange_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) @register_model_view(ObjectChange) @@ -223,6 +224,7 @@ class ObjectChangeView(generic.ObjectView): data=related_changes[:50], orderable=False ) + related_changes_table.configure(request) objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( changed_object_type=instance.changed_object_type, @@ -273,6 +275,7 @@ class ConfigRevisionListView(generic.ObjectListView): filterset = filtersets.ConfigRevisionFilterSet filterset_form = forms.ConfigRevisionFilterForm table = tables.ConfigRevisionTable + actions = (AddObject, BulkExport) @register_model_view(ConfigRevision) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 710e55001..a64c9e5e3 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -461,6 +461,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): Interface.objects.select_related("device", "cable"), ], ), + 'virtual_circuit_termination', 'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination 'ip_addresses', # Referenced by Interface.count_ipaddresses() 'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups() diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 267966e10..ad96bd47c 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -874,6 +874,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100ME_T1 = '100base-t1' TYPE_100ME_SFP = '100base-x-sfp' TYPE_1GE_FIXED = '1000base-t' + TYPE_1GE_SX_FIXED = '1000base-sx' TYPE_1GE_LX_FIXED = '1000base-lx' TYPE_1GE_TX_FIXED = '1000base-tx' TYPE_1GE_GBIC = '1000base-x-gbic' @@ -1038,6 +1039,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'), (TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'), (TYPE_1GE_FIXED, '1000BASE-T (1GE)'), + (TYPE_1GE_SX_FIXED, '1000BASE-SX (1GE)'), (TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'), (TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'), (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'), @@ -1238,6 +1240,8 @@ class InterfaceSpeedChoices(ChoiceSet): (10000, '10 Mbps'), (100000, '100 Mbps'), (1000000, '1 Gbps'), + (2500000, '2.5 Gbps'), + (5000000, '5 Gbps'), (10000000, '10 Gbps'), (25000000, '25 Gbps'), (40000000, '40 Gbps'), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index c6e18ca82..387b4d6a7 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -53,6 +53,11 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_802151, InterfaceTypeChoices.TYPE_802154, 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 diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a31cf136d..7f1493557 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -2012,6 +2012,21 @@ class InterfaceFilterSet( 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.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( ModularDeviceComponentFilterSet, diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 098c1a58e..9db7c250e 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1779,6 +1779,13 @@ class InventoryItemBulkEditForm( ) nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Remove parent device passed as context to avoid conflicts with the actual device field + # on this form (see bug #19464) + self.initial.pop('device', None) + # # Device component roles diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 813a578d6..8e7569509 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1507,7 +1507,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): tx_power = forms.IntegerField( required=False, label=_('Transmit power (dBm)'), - min_value=0, + min_value=-40, max_value=127 ) vrf_id = DynamicModelMultipleChoiceField( diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 98862af10..5a57e3364 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -66,6 +66,10 @@ class ScopedForm(forms.Form): if self.instance and scope_type_id != self.instance.scope_type_id: self.initial['scope'] = None + else: + # Clear the initial scope value if scope_type is not set + self.initial['scope'] = None + class ScopedBulkEditForm(forms.Form): scope_type = ContentTypeChoiceField( diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 77e7a53b9..a8a6c2a5e 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -90,7 +90,7 @@ __all__ = ( ) -@strawberry_django.filter(models.Cable, lookups=True) +@strawberry_django.filter_type(models.Cable, lookups=True) class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): 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() @@ -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): cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | 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() -@strawberry_django.filter(models.ConsolePort, lookups=True) +@strawberry_django.filter_type(models.ConsolePort, lookups=True) class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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): type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( strawberry_django.filter_field() ) -@strawberry_django.filter(models.ConsoleServerPort, lookups=True) +@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True) class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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): type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( strawberry_django.filter_field() ) -@strawberry_django.filter(models.Device, lookups=True) +@strawberry_django.filter_type(models.Device, lookups=True) class DeviceFilter( ContactFilterMixin, TenancyFilterMixin, @@ -271,7 +271,7 @@ class DeviceFilter( 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): installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -279,12 +279,12 @@ class DeviceBayFilter(ComponentModelFilterMixin): 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): pass -@strawberry_django.filter(models.InventoryItemTemplate, lookups=True) +@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True) class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -304,13 +304,13 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): 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): color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | 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): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -382,7 +382,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig 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): 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() @@ -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): 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() @@ -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): mac_address: FilterLookup[str] | None = strawberry_django.filter_field() 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() -@strawberry_django.filter(models.Interface, lookups=True) +@strawberry_django.filter_type(models.Interface, lookups=True) class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin): vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( 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): type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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): parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -535,12 +535,12 @@ class InventoryItemFilter(ComponentModelFilterMixin): 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): 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): site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | 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): pass -@strawberry_django.filter(models.Module, lookups=True) +@strawberry_django.filter_type(models.Module, lookups=True) class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin): device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | 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): parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -619,17 +619,17 @@ class ModuleBayFilter(ModularComponentModelFilterMixin): 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): 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): 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): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -676,7 +676,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig ) = strawberry_django.filter_field() -@strawberry_django.filter(models.Platform, lookups=True) +@strawberry_django.filter_type(models.Platform, lookups=True) class PlatformFilter(OrganizationalModelFilterMixin): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -688,7 +688,7 @@ class PlatformFilter(OrganizationalModelFilterMixin): 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): power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( 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): type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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() -@strawberry_django.filter(models.PowerOutletTemplate, lookups=True) +@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True) class PowerOutletTemplateFilter(ModularComponentModelFilterMixin): type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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): site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | 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() -@strawberry_django.filter(models.PowerPort, lookups=True) +@strawberry_django.filter_type(models.PowerPort, lookups=True) class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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): type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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): form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( strawberry_django.filter_field() @@ -804,7 +804,7 @@ class RackTypeFilter(RackBaseFilterMixin): 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): form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( 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): rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | 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() -@strawberry_django.filter(models.RackRole, lookups=True) +@strawberry_django.filter_type(models.RackRole, lookups=True) class RackRoleFilter(OrganizationalModelFilterMixin): 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): 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() @@ -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): 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() @@ -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): prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( 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): name: 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): prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( 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): master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | 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() -@strawberry_django.filter(models.VirtualDeviceContext, lookups=True) +@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True) class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin): device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() device_id: ID | None = strawberry_django.filter_field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 7f801c01b..d0818a738 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -541,10 +541,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi class ManufacturerType(OrganizationalObjectType, ContactsMixin): platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] - device_types: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]] inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] - module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + module_types: List[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -617,11 +617,11 @@ class ModuleTypeType(NetBoxObjectType): frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] - powerporttemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] + powerporttemplates: List[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]] - instances: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] - consoleporttemplates: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + instances: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + consoleporttemplates: List[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( diff --git a/netbox/dcim/migrations/0188_racktype.py b/netbox/dcim/migrations/0188_racktype.py index a5265d030..7c36e03ba 100644 --- a/netbox/dcim/migrations/0188_racktype.py +++ b/netbox/dcim/migrations/0188_racktype.py @@ -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, +} diff --git a/netbox/dcim/migrations/0194_charfield_null_choices.py b/netbox/dcim/migrations/0194_charfield_null_choices.py index e13b0e10d..e23b25556 100644 --- a/netbox/dcim/migrations/0194_charfield_null_choices.py +++ b/netbox/dcim/migrations/0194_charfield_null_choices.py @@ -26,49 +26,50 @@ def set_null_values(apps, schema_editor): RackType = apps.get_model('dcim', 'RackType') RearPort = apps.get_model('dcim', 'RearPort') Site = apps.get_model('dcim', 'Site') + db_alias = schema_editor.connection.alias - Cable.objects.filter(length_unit='').update(length_unit=None) - Cable.objects.filter(type='').update(type=None) - ConsolePort.objects.filter(cable_end='').update(cable_end=None) - ConsolePort.objects.filter(type='').update(type=None) - ConsolePortTemplate.objects.filter(type='').update(type=None) - ConsoleServerPort.objects.filter(cable_end='').update(cable_end=None) - ConsoleServerPort.objects.filter(type='').update(type=None) - ConsoleServerPortTemplate.objects.filter(type='').update(type=None) - Device.objects.filter(airflow='').update(airflow=None) - Device.objects.filter(face='').update(face=None) - DeviceType.objects.filter(airflow='').update(airflow=None) - DeviceType.objects.filter(subdevice_role='').update(subdevice_role=None) - DeviceType.objects.filter(weight_unit='').update(weight_unit=None) - FrontPort.objects.filter(cable_end='').update(cable_end=None) - Interface.objects.filter(cable_end='').update(cable_end=None) - Interface.objects.filter(mode='').update(mode=None) - Interface.objects.filter(poe_mode='').update(poe_mode=None) - Interface.objects.filter(poe_type='').update(poe_type=None) - Interface.objects.filter(rf_channel='').update(rf_channel=None) - Interface.objects.filter(rf_role='').update(rf_role=None) - InterfaceTemplate.objects.filter(poe_mode='').update(poe_mode=None) - InterfaceTemplate.objects.filter(poe_type='').update(poe_type=None) - InterfaceTemplate.objects.filter(rf_role='').update(rf_role=None) - ModuleType.objects.filter(airflow='').update(airflow=None) - ModuleType.objects.filter(weight_unit='').update(weight_unit=None) - PowerFeed.objects.filter(cable_end='').update(cable_end=None) - PowerOutlet.objects.filter(cable_end='').update(cable_end=None) - PowerOutlet.objects.filter(feed_leg='').update(feed_leg=None) - PowerOutlet.objects.filter(type='').update(type=None) - PowerOutletTemplate.objects.filter(feed_leg='').update(feed_leg=None) - PowerOutletTemplate.objects.filter(type='').update(type=None) - PowerPort.objects.filter(cable_end='').update(cable_end=None) - PowerPort.objects.filter(type='').update(type=None) - PowerPortTemplate.objects.filter(type='').update(type=None) - Rack.objects.filter(airflow='').update(airflow=None) - Rack.objects.filter(form_factor='').update(form_factor=None) - Rack.objects.filter(outer_unit='').update(outer_unit=None) - Rack.objects.filter(weight_unit='').update(weight_unit=None) - RackType.objects.filter(outer_unit='').update(outer_unit=None) - RackType.objects.filter(weight_unit='').update(weight_unit=None) - RearPort.objects.filter(cable_end='').update(cable_end=None) - Site.objects.filter(time_zone='').update(time_zone=None) + Cable.objects.using(db_alias).filter(length_unit='').update(length_unit=None) + Cable.objects.using(db_alias).filter(type='').update(type=None) + ConsolePort.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + ConsolePort.objects.using(db_alias).filter(type='').update(type=None) + ConsolePortTemplate.objects.using(db_alias).filter(type='').update(type=None) + ConsoleServerPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + ConsoleServerPort.objects.using(db_alias).filter(type='').update(type=None) + ConsoleServerPortTemplate.objects.using(db_alias).filter(type='').update(type=None) + Device.objects.using(db_alias).filter(airflow='').update(airflow=None) + Device.objects.using(db_alias).filter(face='').update(face=None) + DeviceType.objects.using(db_alias).filter(airflow='').update(airflow=None) + DeviceType.objects.using(db_alias).filter(subdevice_role='').update(subdevice_role=None) + DeviceType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None) + FrontPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + Interface.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + Interface.objects.using(db_alias).filter(mode='').update(mode=None) + Interface.objects.using(db_alias).filter(poe_mode='').update(poe_mode=None) + Interface.objects.using(db_alias).filter(poe_type='').update(poe_type=None) + Interface.objects.using(db_alias).filter(rf_channel='').update(rf_channel=None) + Interface.objects.using(db_alias).filter(rf_role='').update(rf_role=None) + InterfaceTemplate.objects.using(db_alias).filter(poe_mode='').update(poe_mode=None) + InterfaceTemplate.objects.using(db_alias).filter(poe_type='').update(poe_type=None) + InterfaceTemplate.objects.using(db_alias).filter(rf_role='').update(rf_role=None) + ModuleType.objects.using(db_alias).filter(airflow='').update(airflow=None) + ModuleType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None) + PowerFeed.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + PowerOutlet.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + PowerOutlet.objects.using(db_alias).filter(feed_leg='').update(feed_leg=None) + PowerOutlet.objects.using(db_alias).filter(type='').update(type=None) + PowerOutletTemplate.objects.using(db_alias).filter(feed_leg='').update(feed_leg=None) + PowerOutletTemplate.objects.using(db_alias).filter(type='').update(type=None) + PowerPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + PowerPort.objects.using(db_alias).filter(type='').update(type=None) + PowerPortTemplate.objects.using(db_alias).filter(type='').update(type=None) + Rack.objects.using(db_alias).filter(airflow='').update(airflow=None) + Rack.objects.using(db_alias).filter(form_factor='').update(form_factor=None) + Rack.objects.using(db_alias).filter(outer_unit='').update(outer_unit=None) + Rack.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None) + RackType.objects.using(db_alias).filter(outer_unit='').update(outer_unit=None) + RackType.objects.using(db_alias).filter(weight_unit='').update(weight_unit=None) + RearPort.objects.using(db_alias).filter(cable_end='').update(cable_end=None) + Site.objects.using(db_alias).filter(time_zone='').update(time_zone=None) class Migration(migrations.Migration): diff --git a/netbox/dcim/migrations/0200_populate_mac_addresses.py b/netbox/dcim/migrations/0200_populate_mac_addresses.py index 0cd18d78e..7c44a2cca 100644 --- a/netbox/dcim/migrations/0200_populate_mac_addresses.py +++ b/netbox/dcim/migrations/0200_populate_mac_addresses.py @@ -1,4 +1,6 @@ import django.db.models.deletion +from django.apps import apps +from django.contrib.contenttypes.models import ContentType from django.db import migrations, models @@ -6,19 +8,26 @@ def populate_mac_addresses(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') Interface = apps.get_model('dcim', 'Interface') MACAddress = apps.get_model('dcim', 'MACAddress') + db_alias = schema_editor.connection.alias interface_ct = ContentType.objects.get_for_model(Interface) mac_addresses = [ MACAddress( - mac_address=interface.mac_address, assigned_object_type=interface_ct, assigned_object_id=interface.pk + mac_address=interface.mac_address, + assigned_object_type=interface_ct, + assigned_object_id=interface.pk ) - for interface in Interface.objects.filter(mac_address__isnull=False) + 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 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): @@ -44,3 +53,43 @@ class Migration(migrations.Migration): 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, +} diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py index e3ca7d27a..8f131570f 100644 --- a/netbox/dcim/migrations/0206_load_module_type_profiles.py +++ b/netbox/dcim/migrations/0206_load_module_type_profiles.py @@ -11,6 +11,8 @@ def load_initial_data(apps, schema_editor): Load initial ModuleTypeProfile objects from file. """ ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile') + db_alias = schema_editor.connection.alias + initial_profiles = ( 'cpu', 'fan', @@ -25,7 +27,7 @@ def load_initial_data(apps, schema_editor): with file_path.open('r') as f: data = json.load(f) try: - ModuleTypeProfile.objects.create(**data) + ModuleTypeProfile.objects.using(db_alias).create(**data) except Exception as e: print(f"Error loading data from {file_path}") raise e diff --git a/netbox/dcim/migrations/0208_platform_manufacturer_uniqueness.py b/netbox/dcim/migrations/0208_platform_manufacturer_uniqueness.py new file mode 100644 index 000000000..9659aadf4 --- /dev/null +++ b/netbox/dcim/migrations/0208_platform_manufacturer_uniqueness.py @@ -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.' + ), + ), + ] diff --git a/netbox/dcim/migrations/0209_interface_tx_power_negative.py b/netbox/dcim/migrations/0209_interface_tx_power_negative.py new file mode 100644 index 000000000..70d692413 --- /dev/null +++ b/netbox/dcim/migrations/0209_interface_tx_power_negative.py @@ -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) + ] + ), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4b44c5b4e..e1cd5f491 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -719,10 +719,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd verbose_name=('channel width (MHz)'), help_text=_("Populated by selected channel (if set)") ) - tx_power = models.PositiveSmallIntegerField( + tx_power = models.SmallIntegerField( blank=True, null=True, - validators=(MaxValueValidator(127),), + validators=( + MinValueValidator(-40), + MaxValueValidator(127), + ), verbose_name=_('transmit power (dBm)') ) poe_mode = models.CharField( diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 5988f8241..f85b4440d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -415,6 +415,15 @@ class Platform(OrganizationalModel): null=True, 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( to='extras.ConfigTemplate', on_delete=models.PROTECT, @@ -427,6 +436,28 @@ class Platform(OrganizationalModel): ordering = ('name',) verbose_name = _('platform') 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( diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 127dfb9e5..e9484264c 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -85,7 +85,7 @@ class CachedScopeMixin(models.Model): abstract = True 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() raise ValidationError({ 'scope': _( diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index c5830f1db..f60162fe9 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -144,7 +144,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): super().clean() # Validate any attributes against the assigned profile's schema - if self.profile: + if self.profile and self.profile.schema: try: jsonschema.validate(self.attribute_data, schema=self.profile.schema) except JSONValidationError as e: diff --git a/netbox/dcim/object_actions.py b/netbox/dcim/object_actions.py new file mode 100644 index 000000000..00a409274 --- /dev/null +++ b/netbox/dcim/object_actions.py @@ -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' diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 31ec06100..b263a27cc 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -329,11 +329,9 @@ class CableTraceSVG: # Draw attachment (line) start = (OFFSET + self.center, OFFSET + self.cursor) - height = PADDING * 2 + LINE_HEIGHT + PADDING * 2 - end = (start[0], start[1] + height) + end = (start[0], start[1] + CABLE_HEIGHT) line = Line(start=start, end=end, class_='attachment') group.add(line) - self.cursor += PADDING * 4 return group @@ -358,10 +356,10 @@ class CableTraceSVG: # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!) near_terminations = self.draw_terminations(near_ends, parent_object_nodes) - self.cursor += CABLE_HEIGHT # Connector (a Cable or WirelessLink) if links and far_ends: + self.cursor += CABLE_HEIGHT obj_list = {end.parent_object for end in far_ends} parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends) @@ -449,6 +447,7 @@ class CableTraceSVG: # Attachment attachment = self.draw_attachment() self.connectors.append(attachment) + self.cursor += CABLE_HEIGHT # Object parent_object_nodes = self.draw_parent_objects(far_ends) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d58e4e376..11202d78e 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1091,10 +1091,9 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) - device = tables.TemplateColumn( + device = tables.Column( verbose_name=_('Device'), order_by=('device___name',), - template_code=DEVICE_LINK, linkify=True ) status = columns.ChoiceFieldColumn( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c3ac6053d..8af539b04 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -14,7 +14,7 @@ from ipam.models import ASN, RIR, VLAN, VRF from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant 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 wireless.choices import WirelessChannelChoices from wireless.models import WirelessLAN @@ -1858,7 +1858,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase # Attempt to delete only the parent interface 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 # Attempt to bulk delete parent & child together diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ba8d4203d..2ae178653 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -12,6 +12,7 @@ from users.models import User from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine from wireless.choices import WirelessChannelChoices, WirelessRoleChoices +from wireless.models import WirelessLink class DeviceComponentFilterSetTests: @@ -4496,7 +4497,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil # Cables Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).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): params = {'name': ['Interface 1', 'Interface 2']} @@ -4684,15 +4687,15 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil def test_occupied(self): 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} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_connected(self): 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} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_kind(self): params = {'kind': 'physical'} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 281071ed9..be9f067d4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -954,6 +954,19 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): 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): diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 0931761bf..a03790ea2 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,6 +1,6 @@ from django.apps import apps 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): @@ -53,7 +53,7 @@ def rebuild_paths(terminations): for obj in terminations: 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: cp.delete() create_cablepath(cp.origins) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index faa9f6bb6..94afc2cb2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,7 +1,7 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType 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.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory 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 ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable -from netbox.constants import DEFAULT_ACTION_PERMISSIONS +from netbox.object_actions import * from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -34,6 +34,7 @@ from wireless.models import WirelessLAN from . import filtersets, forms, tables from .choices import DeviceFaceChoices, InterfaceModeChoices from .models import * +from .object_actions import BulkAddComponents, BulkDisconnect CABLE_TERMINATION_TYPES = { 'dcim.consoleport': ConsolePort, @@ -49,11 +50,6 @@ CABLE_TERMINATION_TYPES = { class DeviceComponentsView(generic.ObjectChildrenView): - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - 'bulk_disconnect': {'change'}, - } queryset = Device.objects.all() def get_children(self, request, parent): @@ -61,12 +57,8 @@ class DeviceComponentsView(generic.ObjectChildrenView): class DeviceTypeComponentsView(generic.ObjectChildrenView): - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete) queryset = DeviceType.objects.all() - template_name = 'dcim/devicetype/component_templates.html' viewname = None # Used for return_url resolution 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() - template_name = 'dcim/moduletype/component_templates.html' + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete) viewname = None # Used for return_url resolution def get_children(self, request, parent): @@ -124,7 +116,7 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View) if form.is_valid(): - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(Cable)): count = 0 cable_ids = set() for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): @@ -300,6 +292,11 @@ class RegionBulkEditView(generic.BulkEditView): 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) class RegionBulkDeleteView(generic.BulkDeleteView): queryset = Region.objects.add_related_count( @@ -426,6 +423,11 @@ class SiteGroupBulkEditView(generic.BulkEditView): 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) class SiteGroupBulkDeleteView(generic.BulkDeleteView): queryset = SiteGroup.objects.add_related_count( @@ -511,6 +513,11 @@ class SiteBulkEditView(generic.BulkEditView): 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) class SiteBulkDeleteView(generic.BulkDeleteView): queryset = Site.objects.all() @@ -615,6 +622,11 @@ class LocationBulkEditView(generic.BulkEditView): 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) class LocationBulkDeleteView(generic.BulkDeleteView): queryset = Location.objects.add_related_count( @@ -680,6 +692,11 @@ class RackRoleBulkEditView(generic.BulkEditView): 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) class RackRoleBulkDeleteView(generic.BulkDeleteView): queryset = RackRole.objects.annotate( @@ -739,6 +756,12 @@ class RackTypeBulkEditView(generic.BulkEditView): 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) class RackTypeBulkDeleteView(generic.BulkDeleteView): queryset = RackType.objects.all() @@ -918,6 +941,11 @@ class RackBulkEditView(generic.BulkEditView): 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) class RackBulkDeleteView(generic.BulkDeleteView): queryset = Rack.objects.all() @@ -935,6 +963,7 @@ class RackReservationListView(generic.ObjectListView): filterset = filtersets.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(RackReservation) @@ -1051,6 +1080,11 @@ class ManufacturerBulkEditView(generic.BulkEditView): 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) class ManufacturerBulkDeleteView(generic.BulkDeleteView): queryset = Manufacturer.objects.annotate( @@ -1298,6 +1332,12 @@ class DeviceTypeBulkEditView(generic.BulkEditView): 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) class DeviceTypeBulkDeleteView(generic.BulkDeleteView): queryset = DeviceType.objects.annotate( @@ -1354,6 +1394,11 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView): 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) class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView): queryset = ModuleTypeProfile.objects.annotate( @@ -1564,6 +1609,11 @@ class ModuleTypeBulkEditView(generic.BulkEditView): 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) class ModuleTypeBulkDeleteView(generic.BulkDeleteView): queryset = ModuleType.objects.annotate( @@ -2038,6 +2088,11 @@ class DeviceRoleBulkEditView(generic.BulkEditView): 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) class DeviceRoleBulkDeleteView(generic.BulkDeleteView): queryset = DeviceRole.objects.annotate( @@ -2099,6 +2154,11 @@ class PlatformBulkEditView(generic.BulkEditView): 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) class PlatformBulkDeleteView(generic.BulkDeleteView): queryset = Platform.objects.all() @@ -2116,7 +2176,7 @@ class DeviceListView(generic.ObjectListView): filterset = filtersets.DeviceFilterSet filterset_form = forms.DeviceFilterForm table = tables.DeviceTable - template_name = 'dcim/device_list.html' + actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete) @register_model_view(Device) @@ -2157,7 +2217,7 @@ class DeviceConsolePortsView(DeviceComponentsView): table = tables.DeviceConsolePortTable filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm - template_name = 'dcim/device/consoleports.html', + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Console Ports'), badge=lambda obj: obj.console_port_count, @@ -2173,7 +2233,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView): table = tables.DeviceConsoleServerPortTable filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm - template_name = 'dcim/device/consoleserverports.html' + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Console Server Ports'), badge=lambda obj: obj.console_server_port_count, @@ -2189,7 +2249,7 @@ class DevicePowerPortsView(DeviceComponentsView): table = tables.DevicePowerPortTable filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm - template_name = 'dcim/device/powerports.html' + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Power Ports'), badge=lambda obj: obj.power_port_count, @@ -2205,7 +2265,7 @@ class DevicePowerOutletsView(DeviceComponentsView): table = tables.DevicePowerOutletTable filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm - template_name = 'dcim/device/poweroutlets.html' + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Power Outlets'), badge=lambda obj: obj.power_outlet_count, @@ -2221,6 +2281,7 @@ class DeviceInterfacesView(DeviceComponentsView): table = tables.DeviceInterfaceTable filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) template_name = 'dcim/device/interfaces.html' tab = ViewTab( label=_('Interfaces'), @@ -2243,7 +2304,7 @@ class DeviceFrontPortsView(DeviceComponentsView): table = tables.DeviceFrontPortTable filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm - template_name = 'dcim/device/frontports.html' + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Front Ports'), badge=lambda obj: obj.front_port_count, @@ -2259,7 +2320,7 @@ class DeviceRearPortsView(DeviceComponentsView): table = tables.DeviceRearPortTable filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm - template_name = 'dcim/device/rearports.html' + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete) tab = ViewTab( label=_('Rear Ports'), badge=lambda obj: obj.rear_port_count, @@ -2275,11 +2336,7 @@ class DeviceModuleBaysView(DeviceComponentsView): table = tables.DeviceModuleBayTable filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm - template_name = 'dcim/device/modulebays.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Module Bays'), badge=lambda obj: obj.module_bay_count, @@ -2295,11 +2352,7 @@ class DeviceDeviceBaysView(DeviceComponentsView): table = tables.DeviceDeviceBayTable filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm - template_name = 'dcim/device/devicebays.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Device Bays'), badge=lambda obj: obj.device_bay_count, @@ -2315,11 +2368,7 @@ class DeviceInventoryView(DeviceComponentsView): table = tables.DeviceInventoryItemTable filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm - template_name = 'dcim/device/inventory.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete) tab = ViewTab( label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_count, @@ -2393,16 +2442,16 @@ class DeviceBulkEditView(generic.BulkEditView): form = forms.DeviceBulkEditForm -@register_model_view(Device, 'bulk_delete', path='delete', detail=False) -class DeviceBulkDeleteView(generic.BulkDeleteView): - queryset = Device.objects.prefetch_related('device_type__manufacturer') +@register_model_view(Device, 'bulk_rename', path='rename', detail=False) +class DeviceBulkRenameView(generic.BulkRenameView): + queryset = Device.objects.all() filterset = filtersets.DeviceFilterSet table = tables.DeviceTable -@register_model_view(Device, 'bulk_rename', path='rename', detail=False) -class DeviceBulkRenameView(generic.BulkRenameView): - queryset = Device.objects.all() +@register_model_view(Device, 'bulk_delete', path='delete', detail=False) +class DeviceBulkDeleteView(generic.BulkDeleteView): + queryset = Device.objects.prefetch_related('device_type__manufacturer') filterset = filtersets.DeviceFilterSet table = tables.DeviceTable @@ -2417,6 +2466,7 @@ class ModuleListView(generic.ObjectListView): filterset = filtersets.ModuleFilterSet filterset_form = forms.ModuleFilterForm table = tables.ModuleTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(Module) @@ -2472,11 +2522,6 @@ class ConsolePortListView(generic.ObjectListView): filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(ConsolePort) @@ -2547,11 +2592,6 @@ class ConsoleServerPortListView(generic.ObjectListView): filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(ConsoleServerPort) @@ -2622,11 +2662,6 @@ class PowerPortListView(generic.ObjectListView): filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(PowerPort) @@ -2697,11 +2732,6 @@ class PowerOutletListView(generic.ObjectListView): filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(PowerOutlet) @@ -2772,11 +2802,6 @@ class InterfaceListView(generic.ObjectListView): filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(Interface) @@ -2793,6 +2818,7 @@ class InterfaceView(generic.ObjectView): ), orderable=False ) + vdc_table.configure(request) # Get bridge interfaces bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance) @@ -2801,6 +2827,7 @@ class InterfaceView(generic.ObjectView): exclude=('device', 'parent'), orderable=False ) + bridge_interfaces_table.configure(request) # Get child interfaces child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) @@ -2809,6 +2836,7 @@ class InterfaceView(generic.ObjectView): exclude=('device', 'parent'), orderable=False ) + child_interfaces_table.configure(request) # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] @@ -2823,6 +2851,7 @@ class InterfaceView(generic.ObjectView): data=vlans, orderable=False ) + vlan_table.configure(request) # Get VLAN translation rules vlan_translation_table = None @@ -2831,6 +2860,7 @@ class InterfaceView(generic.ObjectView): data=instance.vlan_translation_policy.rules.all(), orderable=False ) + vlan_translation_table.configure(request) return { 'vdc_table': vdc_table, @@ -2915,11 +2945,6 @@ class FrontPortListView(generic.ObjectListView): filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(FrontPort) @@ -2990,11 +3015,6 @@ class RearPortListView(generic.ObjectListView): filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(RearPort) @@ -3065,11 +3085,6 @@ class ModuleBayListView(generic.ObjectListView): filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(ModuleBay) @@ -3131,11 +3146,6 @@ class DeviceBayListView(generic.ObjectListView): filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(DeviceBay) @@ -3278,11 +3288,6 @@ class InventoryItemListView(generic.ObjectListView): filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - template_name = 'dcim/component_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_rename': {'change'}, - } @register_model_view(InventoryItem) @@ -3405,6 +3410,11 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView): 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) class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView): queryset = InventoryItemRole.objects.annotate( @@ -3602,6 +3612,12 @@ class CableBulkEditView(generic.BulkEditView): 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) class CableBulkDeleteView(generic.BulkDeleteView): queryset = Cable.objects.prefetch_related( @@ -3622,9 +3638,7 @@ class ConsoleConnectionsListView(generic.ObjectListView): filterset_form = forms.ConsoleConnectionFilterForm table = tables.ConsoleConnectionTable template_name = 'dcim/connections_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) def get_extra_context(self, request): return { @@ -3638,9 +3652,7 @@ class PowerConnectionsListView(generic.ObjectListView): filterset_form = forms.PowerConnectionFilterForm table = tables.PowerConnectionTable template_name = 'dcim/connections_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) def get_extra_context(self, request): return { @@ -3654,9 +3666,7 @@ class InterfaceConnectionsListView(generic.ObjectListView): filterset_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable template_name = 'dcim/connections_list.html' - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) def get_extra_context(self, request): return { @@ -3741,7 +3751,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V if vc_form.is_valid() and formset.is_valid(): - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(Device)): # Save the VirtualChassis vc_form.save() @@ -3900,6 +3910,11 @@ class VirtualChassisBulkEditView(generic.BulkEditView): 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) class VirtualChassisBulkDeleteView(generic.BulkDeleteView): queryset = VirtualChassis.objects.all() @@ -3957,6 +3972,11 @@ class PowerPanelBulkEditView(generic.BulkEditView): 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) class PowerPanelBulkDeleteView(generic.BulkDeleteView): queryset = PowerPanel.objects.annotate( @@ -4009,6 +4029,11 @@ class PowerFeedBulkEditView(generic.BulkEditView): 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) class PowerFeedBulkDisconnectView(BulkDisconnectView): queryset = PowerFeed.objects.all() @@ -4037,6 +4062,7 @@ class VirtualDeviceContextListView(generic.ObjectListView): filterset = filtersets.VirtualDeviceContextFilterSet filterset_form = forms.VirtualDeviceContextFilterForm table = tables.VirtualDeviceContextTable + actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete) @register_model_view(VirtualDeviceContext) @@ -4081,6 +4107,11 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView): 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) class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView): queryset = VirtualDeviceContext.objects.all() @@ -4098,6 +4129,7 @@ class MACAddressListView(generic.ObjectListView): filterset = filtersets.MACAddressFilterSet filterset_form = forms.MACAddressFilterForm table = tables.MACAddressTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(MACAddress) diff --git a/netbox/extras/api/serializers_/objecttypes.py b/netbox/extras/api/serializers_/objecttypes.py index 8e4806652..e272509ec 100644 --- a/netbox/extras/api/serializers_/objecttypes.py +++ b/netbox/extras/api/serializers_/objecttypes.py @@ -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 core.models import ObjectType from netbox.api.serializers import BaseModelSerializer +from utilities.views import get_viewname __all__ = ( 'ObjectTypeSerializer', @@ -10,7 +16,32 @@ __all__ = ( class ObjectTypeSerializer(BaseModelSerializer): 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: 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) diff --git a/netbox/extras/api/serializers_/scripts.py b/netbox/extras/api/serializers_/scripts.py index 897ccf966..aa0268ecf 100644 --- a/netbox/extras/api/serializers_/scripts.py +++ b/netbox/extras/api/serializers_/scripts.py @@ -66,11 +66,11 @@ class ScriptInputSerializer(serializers.Serializer): interval = serializers.IntegerField(required=False, allow_null=True) 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.")) return 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.")) return value diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 6e9225f73..3f5bb172a 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -270,6 +270,7 @@ class ScriptViewSet(ModelViewSet): module_name, script_name = pk.split('.', maxsplit=1) except ValueError: raise Http404 + return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name) def retrieve(self, request, pk): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 5c62932e5..cf15495ca 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -238,10 +238,18 @@ class TagImportForm(CSVModelForm): label=_('Weight'), 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: model = Tag - fields = ('name', 'slug', 'color', 'weight', 'description') + fields = ( + 'name', 'slug', 'color', 'weight', 'description', 'object_types', + ) class JournalEntryImportForm(NetBoxModelImportForm): diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index c237b9991..5f9820b44 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -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.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.forms.widgets import DateTimePicker, NumberWithOptions @@ -74,12 +69,7 @@ class ScriptFileForm(ManagedFileForm): storage = storages.create_storage(storages.backends["scripts"]) filename = self.cleaned_data['upload_file'].name - if isinstance(storage, ScriptFileSystemStorage): - full_path = os.path.join(settings.SCRIPTS_ROOT, filename) - else: - full_path = filename - - self.instance.file_path = full_path + self.instance.file_path = filename data = self.cleaned_data['upload_file'] storage.save(filename, data) diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 2798c4896..1712b7056 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -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): name: FilterLookup[str] = strawberry_django.filter_field() 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): name: 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() -@strawberry_django.filter(models.CustomField, lookups=True) +@strawberry_django.filter_type(models.CustomField, lookups=True) class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = ( strawberry_django.filter_field() @@ -164,7 +164,7 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): 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): name: 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() -@strawberry_django.filter(models.CustomLink, lookups=True) +@strawberry_django.filter_type(models.CustomLink, lookups=True) class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): name: FilterLookup[str] | 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() -@strawberry_django.filter(models.ExportTemplate, lookups=True) +@strawberry_django.filter_type(models.ExportTemplate, lookups=True) class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): name: 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() -@strawberry_django.filter(models.ImageAttachment, lookups=True) +@strawberry_django.filter_type(models.ImageAttachment, lookups=True) class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -222,7 +222,7 @@ class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): 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): assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -238,7 +238,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag 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): name: 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() -@strawberry_django.filter(models.SavedFilter, lookups=True) +@strawberry_django.filter_type(models.SavedFilter, lookups=True) class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): name: 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): name: 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() -@strawberry_django.filter(models.Tag, lookups=True) +@strawberry_django.filter_type(models.Tag, lookups=True) class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin): color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | 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): name: 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): name: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index d41901dde..733654198 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -39,6 +39,9 @@ class ScriptJob(JobRunner): 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(): script.output = script.run(data, commit) if not commit: diff --git a/netbox/extras/migrations/0108_convert_reports_to_scripts.py b/netbox/extras/migrations/0108_convert_reports_to_scripts.py index 948bac754..e972b6676 100644 --- a/netbox/extras/migrations/0108_convert_reports_to_scripts.py +++ b/netbox/extras/migrations/0108_convert_reports_to_scripts.py @@ -4,11 +4,12 @@ from django.db import migrations def convert_reportmodule_jobs(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') Job = apps.get_model('core', 'Job') + db_alias = schema_editor.connection.alias # Convert all ReportModule jobs to ScriptModule jobs - if reportmodule_ct := ContentType.objects.filter(app_label='extras', model='reportmodule').first(): - scriptmodule_ct = ContentType.objects.get(app_label='extras', model='scriptmodule') - Job.objects.filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id) + if reportmodule_ct := ContentType.objects.using(db_alias).filter(app_label='extras', model='reportmodule').first(): + scriptmodule_ct = ContentType.objects.using(db_alias).get(app_label='extras', model='scriptmodule') + Job.objects.using(db_alias).filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id) class Migration(migrations.Migration): diff --git a/netbox/extras/migrations/0109_script_model.py b/netbox/extras/migrations/0109_script_model.py index 706a776af..f1f81b3bd 100644 --- a/netbox/extras/migrations/0109_script_model.py +++ b/netbox/extras/migrations/0109_script_model.py @@ -88,24 +88,33 @@ def update_scripts(apps, schema_editor): ScriptModule = apps.get_model('extras', 'ScriptModule') ReportModule = apps.get_model('extras', 'ReportModule') Job = apps.get_model('core', 'Job') + db_alias = schema_editor.connection.alias script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False) scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule, for_concrete_model=False) reportmodule_ct = ContentType.objects.get_for_model(ReportModule, for_concrete_model=False) - for module in ScriptModule.objects.all(): + for module in ScriptModule.objects.using(db_alias).all(): for script_name in get_module_scripts(module): - script = Script.objects.create( + script = Script.objects.using(db_alias).create( name=script_name, module=module, ) # Update all Jobs associated with this ScriptModule & script name to point to the new Script object - Job.objects.filter(object_type_id=scriptmodule_ct.id, object_id=module.pk, name=script_name).update( + Job.objects.using(db_alias).filter( + object_type_id=scriptmodule_ct.id, + object_id=module.pk, + name=script_name + ).update( object_type_id=script_ct.id, object_id=script.pk ) # Update all Jobs associated with this ScriptModule & script name to point to the new Script object - Job.objects.filter(object_type_id=reportmodule_ct.id, object_id=module.pk, name=script_name).update( + Job.objects.using(db_alias).filter( + object_type_id=reportmodule_ct.id, + object_id=module.pk, + name=script_name + ).update( object_type_id=script_ct.id, object_id=script.pk ) @@ -119,16 +128,22 @@ def update_event_rules(apps, schema_editor): Script = apps.get_model('extras', 'Script') ScriptModule = apps.get_model('extras', 'ScriptModule') EventRule = apps.get_model('extras', 'EventRule') + db_alias = schema_editor.connection.alias script_ct = ContentType.objects.get_for_model(Script) scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule) - for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct): + for eventrule in EventRule.objects.using(db_alias).filter(action_object_type=scriptmodule_ct): name = eventrule.action_parameters.get('script_name') - obj, __ = Script.objects.get_or_create( - module_id=eventrule.action_object_id, name=name, defaults={'is_executable': False} + obj, __ = Script.objects.using(db_alias).get_or_create( + module_id=eventrule.action_object_id, + name=name, + defaults={'is_executable': False} + ) + EventRule.objects.using(db_alias).filter(pk=eventrule.pk).update( + action_object_type=script_ct, + action_object_id=obj.id ) - EventRule.objects.filter(pk=eventrule.pk).update(action_object_type=script_ct, action_object_id=obj.id) class Migration(migrations.Migration): diff --git a/netbox/extras/migrations/0115_convert_dashboard_widgets.py b/netbox/extras/migrations/0115_convert_dashboard_widgets.py index 28f6eade9..50f08ff42 100644 --- a/netbox/extras/migrations/0115_convert_dashboard_widgets.py +++ b/netbox/extras/migrations/0115_convert_dashboard_widgets.py @@ -1,12 +1,11 @@ -# Generated by Django 5.0.4 on 2024-04-24 20:09 - from django.db import migrations def update_dashboard_widgets(apps, schema_editor): Dashboard = apps.get_model('extras', 'Dashboard') + db_alias = schema_editor.connection.alias - for dashboard in Dashboard.objects.all(): + for dashboard in Dashboard.objects.using(db_alias).all(): for key, widget in dashboard.config.items(): if models := widget['config'].get('models'): models = list(map(lambda x: x.replace('users.netboxgroup', 'users.group'), models)) diff --git a/netbox/extras/migrations/0116_custom_link_button_color.py b/netbox/extras/migrations/0116_custom_link_button_color.py index ff47eab11..037f53d33 100644 --- a/netbox/extras/migrations/0116_custom_link_button_color.py +++ b/netbox/extras/migrations/0116_custom_link_button_color.py @@ -3,7 +3,9 @@ from django.db import migrations, models def update_link_buttons(apps, schema_editor): CustomLink = apps.get_model('extras', 'CustomLink') - CustomLink.objects.filter(button_class='outline-dark').update(button_class='default') + db_alias = schema_editor.connection.alias + + CustomLink.objects.using(db_alias).filter(button_class='outline-dark').update(button_class='default') class Migration(migrations.Migration): diff --git a/netbox/extras/migrations/0117_move_objectchange.py b/netbox/extras/migrations/0117_move_objectchange.py index 62c7255e7..301fc0846 100644 --- a/netbox/extras/migrations/0117_move_objectchange.py +++ b/netbox/extras/migrations/0117_move_objectchange.py @@ -3,19 +3,21 @@ from django.db import migrations def update_content_types(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') + db_alias = schema_editor.connection.alias # Delete the new ContentTypes effected by the new model in the core app - ContentType.objects.filter(app_label='core', model='objectchange').delete() + ContentType.objects.using(db_alias).filter(app_label='core', model='objectchange').delete() # Update the app labels of the original ContentTypes for extras.ObjectChange to ensure that any # foreign key references are preserved - ContentType.objects.filter(app_label='extras', model='objectchange').update(app_label='core') + ContentType.objects.using(db_alias).filter(app_label='extras', model='objectchange').update(app_label='core') def update_dashboard_widgets(apps, schema_editor): Dashboard = apps.get_model('extras', 'Dashboard') + db_alias = schema_editor.connection.alias - for dashboard in Dashboard.objects.all(): + for dashboard in Dashboard.objects.using(db_alias).all(): for key, widget in dashboard.config.items(): if widget['config'].get('model') == 'extras.objectchange': widget['config']['model'] = 'core.objectchange' diff --git a/netbox/extras/migrations/0120_eventrule_event_types.py b/netbox/extras/migrations/0120_eventrule_event_types.py index 2bcc0a4e6..cdb451a1c 100644 --- a/netbox/extras/migrations/0120_eventrule_event_types.py +++ b/netbox/extras/migrations/0120_eventrule_event_types.py @@ -6,8 +6,9 @@ from core.events import * def set_event_types(apps, schema_editor): EventRule = apps.get_model('extras', 'EventRule') - event_rules = EventRule.objects.all() + db_alias = schema_editor.connection.alias + event_rules = EventRule.objects.using(db_alias).all() for event_rule in event_rules: event_rule.event_types = [] if event_rule.type_create: diff --git a/netbox/extras/migrations/0122_charfield_null_choices.py b/netbox/extras/migrations/0122_charfield_null_choices.py index a32051cb1..c39ecb043 100644 --- a/netbox/extras/migrations/0122_charfield_null_choices.py +++ b/netbox/extras/migrations/0122_charfield_null_choices.py @@ -6,8 +6,9 @@ def set_null_values(apps, schema_editor): Replace empty strings with null values. """ CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet') + db_alias = schema_editor.connection.alias - CustomFieldChoiceSet.objects.filter(base_choices='').update(base_choices=None) + CustomFieldChoiceSet.objects.using(db_alias).filter(base_choices='').update(base_choices=None) class Migration(migrations.Migration): diff --git a/netbox/extras/migrations/0123_journalentry_kind_default.py b/netbox/extras/migrations/0123_journalentry_kind_default.py index b32960469..ae80ab097 100644 --- a/netbox/extras/migrations/0123_journalentry_kind_default.py +++ b/netbox/extras/migrations/0123_journalentry_kind_default.py @@ -8,7 +8,9 @@ def set_kind_default(apps, schema_editor): Set kind to "info" on any entries with no kind assigned. """ JournalEntry = apps.get_model('extras', 'JournalEntry') - JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO) + db_alias = schema_editor.connection.alias + + JournalEntry.objects.using(db_alias).filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO) class Migration(migrations.Migration): diff --git a/netbox/extras/migrations/0129_fix_script_paths.py b/netbox/extras/migrations/0129_fix_script_paths.py new file mode 100644 index 000000000..1ac8af6d8 --- /dev/null +++ b/netbox/extras/migrations/0129_fix_script_paths.py @@ -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, +} diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 3a7273f93..eb017302a 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -131,7 +131,7 @@ class RenderTemplateMixin(models.Model): """ context = self.get_context(context=context, queryset=queryset) 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 output = output.replace('\r\n', '\n') diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py index 7fe03147c..44874a4c8 100644 --- a/netbox/extras/models/notifications.py +++ b/netbox/extras/models/notifications.py @@ -1,7 +1,7 @@ from functools import cached_property 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.db import models from django.urls import reverse @@ -144,6 +144,12 @@ class NotificationGroup(ChangeLoggedModel): blank=True, 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() diff --git a/netbox/extras/search.py b/netbox/extras/search.py index 9203b9144..feb235c29 100644 --- a/netbox/extras/search.py +++ b/netbox/extras/search.py @@ -24,6 +24,17 @@ class JournalEntryIndex(SearchIndex): display_attrs = ('kind', 'created_by') +@register_search +class TagIndex(SearchIndex): + model = models.Tag + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ) + display_attrs = ('description',) + + @register_search class WebhookEntryIndex(SearchIndex): model = models.Webhook diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 6e3fb37fc..29af3f96d 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -2,7 +2,7 @@ import datetime from django.contrib.contenttypes.models import ContentType 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 core.choices import ManagedFileRootPathChoices @@ -991,6 +991,10 @@ class SubscriptionTest(APIViewTestCases.APIViewTestCase): }, ] + cls.bulk_update_data = { + 'user': users[3].pk, + } + class NotificationGroupTest(APIViewTestCases.APIViewTestCase): model = NotificationGroup @@ -1072,6 +1076,9 @@ class NotificationGroupTest(APIViewTestCases.APIViewTestCase): class NotificationTest(APIViewTestCases.APIViewTestCase): model = Notification brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user'] + bulk_update_data = { + 'read': now(), + } @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 089e47c02..6b718569c 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,9 +1,12 @@ -from django.forms import ValidationError -from django.test import TestCase +import tempfile +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 extras.models import ConfigContext, Tag +from extras.models import ConfigContext, ConfigTemplate, Tag from tenancy.models import Tenant, TenantGroup from utilities.exceptions import AbortRequest from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -33,8 +36,8 @@ class TagTest(TestCase): ] site = Site.objects.create(name='Site 1') - for tag in tags: - site.tags.add(tag) + for _tag in tags: + site.tags.add(_tag) site.save() site = Site.objects.first() @@ -540,3 +543,66 @@ class ConfigContextTest(TestCase): device.local_context_data = 'foo' with self.assertRaises(ValidationError): 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({})) diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index bed8f0fc5..17eb5a31a 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -1,3 +1,4 @@ +import logging import tempfile from datetime import date, datetime, timezone @@ -7,6 +8,7 @@ from netaddr import IPAddress, IPNetwork from dcim.models import DeviceRole from extras.scripts import * +from utilities.testing import disable_logging CHOICES = ( ('ff0000', 'Red'), @@ -39,7 +41,8 @@ class ScriptTest(TestCase): datafile.write(bytes(YAML_DATA, 'UTF-8')) datafile.seek(0) - data = Script().load_yaml(datafile.name) + with disable_logging(level=logging.WARNING): + data = Script().load_yaml(datafile.name) self.assertEqual(data, { 'Foo': 123, 'Bar': 456, @@ -51,7 +54,8 @@ class ScriptTest(TestCase): datafile.write(bytes(JSON_DATA, 'UTF-8')) datafile.seek(0) - data = Script().load_json(datafile.name) + with disable_logging(level=logging.WARNING): + data = Script().load_json(datafile.name) self.assertEqual(data, { 'Foo': 123, 'Bar': 456, diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 6378b29b8..fd3ce5453 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -444,6 +444,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + tags = ( Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 2', slug='tag-2', weight=1), @@ -456,14 +458,15 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'tag-x', 'color': 'c0c0c0', 'comments': 'Some comments', + 'object_types': [site_ct.pk], 'weight': 11, } cls.csv_data = ( - "name,slug,color,description,weight", - "Tag 4,tag-4,ff0000,Fourth tag,0", - "Tag 5,tag-5,00ff00,Fifth tag,1111", - "Tag 6,tag-6,0000ff,Sixth tag,0", + "name,slug,color,description,object_types,weight", + "Tag 4,tag-4,ff0000,Fourth tag,dcim.interface,0", + "Tag 5,tag-5,00ff00,Fifth tag,'dcim.device,dcim.site',1111", + "Tag 6,tag-6,0000ff,Sixth tag,dcim.site,0", ) cls.csv_update_data = ( diff --git a/netbox/extras/views.py b/netbox/extras/views.py index ae9337779..43172139c 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,12 +14,13 @@ from jinja2.exceptions import TemplateError from core.choices import ManagedFileRootPathChoices from core.models import Job +from core.object_actions import BulkSync from dcim.models import Device, DeviceRole, Platform from extras.choices import LogLevelChoices from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class 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.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value @@ -96,6 +97,11 @@ class CustomFieldBulkEditView(generic.BulkEditView): 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) class CustomFieldBulkDeleteView(generic.BulkDeleteView): queryset = CustomField.objects.select_related('choice_set') @@ -165,6 +171,11 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView): 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) class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView): queryset = CustomFieldChoiceSet.objects.all() @@ -215,6 +226,11 @@ class CustomLinkBulkEditView(generic.BulkEditView): 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) class CustomLinkBulkDeleteView(generic.BulkDeleteView): queryset = CustomLink.objects.all() @@ -232,11 +248,7 @@ class ExportTemplateListView(generic.ObjectListView): filterset = filtersets.ExportTemplateFilterSet filterset_form = forms.ExportTemplateFilterForm table = tables.ExportTemplateTable - template_name = 'extras/exporttemplate_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_sync': {'sync'}, - } + actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete) @register_model_view(ExportTemplate) @@ -270,6 +282,11 @@ class ExportTemplateBulkEditView(generic.BulkEditView): 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) class ExportTemplateBulkDeleteView(generic.BulkDeleteView): queryset = ExportTemplate.objects.all() @@ -330,6 +347,11 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView): 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) class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): queryset = SavedFilter.objects.all() @@ -347,9 +369,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView): filterset = filtersets.TableConfigFilterSet filterset_form = forms.TableConfigFilterForm table = tables.TableConfigTable - actions = { - 'export': {'view'}, - } + actions = (BulkExport, BulkEdit, BulkRename, BulkDelete) @register_model_view(TableConfig) @@ -389,6 +409,11 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView): 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) class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): queryset = TableConfig.objects.all() @@ -470,6 +495,11 @@ class NotificationGroupBulkEditView(generic.BulkEditView): 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) class NotificationGroupBulkDeleteView(generic.BulkDeleteView): queryset = NotificationGroup.objects.all() @@ -616,6 +646,11 @@ class WebhookBulkEditView(generic.BulkEditView): 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) class WebhookBulkDeleteView(generic.BulkDeleteView): queryset = Webhook.objects.all() @@ -666,6 +701,11 @@ class EventRuleBulkEditView(generic.BulkEditView): 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) class EventRuleBulkDeleteView(generic.BulkDeleteView): queryset = EventRule.objects.all() @@ -740,6 +780,11 @@ class TagBulkEditView(generic.BulkEditView): 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) class TagBulkDeleteView(generic.BulkDeleteView): queryset = Tag.objects.annotate( @@ -758,13 +803,7 @@ class ConfigContextListView(generic.ObjectListView): filterset = filtersets.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm table = tables.ConfigContextTable - template_name = 'extras/configcontext_list.html' - actions = { - 'add': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - 'bulk_sync': {'sync'}, - } + actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete) @register_model_view(ConfigContext) @@ -825,6 +864,11 @@ class ConfigContextBulkEditView(generic.BulkEditView): 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) class ConfigContextBulkDeleteView(generic.BulkDeleteView): queryset = ConfigContext.objects.all() @@ -877,11 +921,7 @@ class ConfigTemplateListView(generic.ObjectListView): filterset = filtersets.ConfigTemplateFilterSet filterset_form = forms.ConfigTemplateFilterForm table = tables.ConfigTemplateTable - template_name = 'extras/configtemplate_list.html' - actions = { - **DEFAULT_ACTION_PERMISSIONS, - 'bulk_sync': {'sync'}, - } + actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete) @register_model_view(ConfigTemplate) @@ -915,6 +955,11 @@ class ConfigTemplateBulkEditView(generic.BulkEditView): 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) class ConfigTemplateBulkDeleteView(generic.BulkDeleteView): queryset = ConfigTemplate.objects.all() @@ -966,7 +1011,7 @@ class ObjectRenderConfigView(generic.ObjectView): # Render the config template rendered_config = None - error_message = None + error_message = '' if config_template := instance.get_config_template(): try: rendered_config = config_template.render(context=context_data) @@ -992,9 +1037,7 @@ class ImageAttachmentListView(generic.ObjectListView): filterset = filtersets.ImageAttachmentFilterSet filterset_form = forms.ImageAttachmentFilterForm table = tables.ImageAttachmentTable - actions = { - 'export': {'view'}, - } + actions = (BulkExport,) @register_model_view(ImageAttachment, 'add', detail=False) @@ -1038,12 +1081,7 @@ class JournalEntryListView(generic.ObjectListView): filterset = filtersets.JournalEntryFilterSet filterset_form = forms.JournalEntryFilterForm table = tables.JournalEntryTable - actions = { - 'export': {'view'}, - 'bulk_import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - } + actions = (BulkImport, BulkEdit, BulkDelete) @register_model_view(JournalEntry) @@ -1476,7 +1514,16 @@ class ScriptResultView(TableMixin, generic.ObjectView): table = None 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) log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO) diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index a6f428343..3eada3193 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -66,7 +66,7 @@ class VLANSerializer(NetBoxModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = RoleSerializer(nested=True, required=False, allow_null=True) - qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False) + qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False, allow_null=True) qinq_svlan = NestedVLANSerializer(required=False, allow_null=True, default=None) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 783d13523..b0a7ad408 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,7 +1,8 @@ from copy import deepcopy +from django.contrib.contenttypes.prefetch import GenericPrefetch from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.db import transaction +from django.db import router, transaction from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ 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.views import APIView +from dcim.models import Interface from ipam import filtersets from ipam.models import * from ipam.utils import get_next_available_prefix @@ -21,6 +23,7 @@ from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.config import get_config from netbox.constants import ADVISORY_LOCK_KEYS from utilities.api import get_serializer_for_model +from virtualization.models import VMInterface from . import serializers @@ -79,7 +82,7 @@ class RoleViewSet(NetBoxModelViewSet): class PrefixViewSet(NetBoxModelViewSet): - queryset = Prefix.objects.all() + queryset = Prefix.objects.prefetch_related("scope") serializer_class = serializers.PrefixSerializer filterset_class = filtersets.PrefixFilterSet @@ -100,7 +103,17 @@ class IPRangeViewSet(NetBoxModelViewSet): class IPAddressViewSet(NetBoxModelViewSet): - queryset = IPAddress.objects.all() + queryset = IPAddress.objects.prefetch_related( + GenericPrefetch( + "assigned_object", + [ + # serializers are taken according to IPADDRESS_ASSIGNMENT_MODELS + FHRPGroup.objects.all(), + Interface.objects.select_related("cable", "device"), + VMInterface.objects.select_related("virtual_machine"), + ], + ), + ) serializer_class = serializers.IPAddressSerializer filterset_class = filtersets.IPAddressFilterSet @@ -282,7 +295,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): # Create the new IP address(es) try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(self.queryset.model)): created = serializer.save() self._validate_objects(created) except ObjectDoesNotExist: diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 0b3ca4b26..9c351a119 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -469,7 +469,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: - return queryset.none + return queryset.none() return queryset.filter( Q(vrf=vrf) | Q(vrf__export_targets__in=vrf.import_targets.all()) @@ -769,7 +769,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: - return queryset.none + return queryset.none() return queryset.filter( Q(vrf=vrf) | Q(vrf__export_targets__in=vrf.import_targets.all()) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index f01f5bdf7..a632e00a5 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -644,7 +644,10 @@ class ServiceImportForm(NetBoxModelImportForm): # triggered parent = self.cleaned_data.get('parent') for ip_address in self.cleaned_data.get('ipaddresses', []): - if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent: + if not (assigned := ip_address.assigned_object) or ( # no assigned object + (isinstance(parent, FHRPGroup) and assigned != parent) # assigned to FHRPGroup + and getattr(assigned, 'parent_object') != parent # assigned to [VM]Interface + ): raise forms.ValidationError( _("{ip} is not assigned to this parent.").format(ip=ip_address) ) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 1df915ca9..96bfae34c 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -849,7 +849,7 @@ class ServiceForm(NetBoxModelForm): except ObjectDoesNotExist: pass - if self.instance and parent_object_type_id != self.instance.parent_object_type_id: + if self.instance and self.instance.pk and parent_object_type_id != self.instance.parent_object_type_id: self.initial['parent'] = None def clean(self): diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 07a301b77..6f39ee310 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -11,10 +11,12 @@ from strawberry_django import FilterLookup, DateFilterLookup from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin from dcim.graphql.filter_mixins import ScopedFilterMixin +from dcim.models import Device from ipam import models from ipam.graphql.filter_mixins import ServiceBaseFilterMixin from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from virtualization.models import VMInterface if TYPE_CHECKING: from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup @@ -46,7 +48,7 @@ __all__ = ( ) -@strawberry_django.filter(models.ASN, lookups=True) +@strawberry_django.filter_type(models.ASN, lookups=True) class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin): rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() rir_id: ID | None = strawberry_django.filter_field() @@ -61,7 +63,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin): ) = strawberry_django.filter_field() -@strawberry_django.filter(models.ASNRange, lookups=True) +@strawberry_django.filter_type(models.ASNRange, lookups=True) class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field() @@ -75,7 +77,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): ) -@strawberry_django.filter(models.Aggregate, lookups=True) +@strawberry_django.filter_type(models.Aggregate, lookups=True) class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() prefix_id: ID | None = strawberry_django.filter_field() @@ -84,7 +86,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter date_added: DateFilterLookup[date] | None = strawberry_django.filter_field() -@strawberry_django.filter(models.FHRPGroup, lookups=True) +@strawberry_django.filter_type(models.FHRPGroup, lookups=True) class FHRPGroupFilter(PrimaryModelFilterMixin): group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() @@ -102,7 +104,7 @@ class FHRPGroupFilter(PrimaryModelFilterMixin): ) -@strawberry_django.filter(models.FHRPGroupAssignment, lookups=True) +@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True) class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -116,8 +118,32 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin) strawberry_django.filter_field() ) + @strawberry_django.filter_field() + def device_id(self, queryset, value: list[str], prefix) -> Q: + return self.filter_device('id', value) -@strawberry_django.filter(models.IPAddress, lookups=True) + @strawberry_django.filter_field() + def device(self, value: list[str], prefix) -> Q: + return self.filter_device('name', value) + + @strawberry_django.filter_field() + def virtual_machine_id(self, value: list[str], prefix) -> Q: + return Q(interface_id__in=VMInterface.objects.filter(virtual_machine_id__in=value)) + + @strawberry_django.filter_field() + def virtual_machine(self, value: list[str], prefix) -> Q: + return Q(interface_id__in=VMInterface.objects.filter(virtual_machine__name__in=value)) + + def filter_device(self, field, value) -> Q: + """Helper to standardize logic for device and device_id filters""" + devices = Device.objects.filter(**{f'{field}__in': value}) + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return Q(interface_id__in=interface_ids) + + +@strawberry_django.filter_type(models.IPAddress, lookups=True) class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() address: FilterLookup[str] | None = strawberry_django.filter_field() @@ -143,6 +169,10 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter nat_outside_id: ID | None = strawberry_django.filter_field() dns_name: FilterLookup[str] | None = strawberry_django.filter_field() + @strawberry_django.filter_field() + def assigned(self, value: bool, prefix) -> Q: + return Q(assigned_object_id__isnull=(not value)) + @strawberry_django.filter_field() def parent(self, value: list[str], prefix) -> Q: if not value: @@ -156,8 +186,16 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter return Q() return q + @strawberry_django.filter_field() + def family( + self, + value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')], + prefix, + ) -> Q: + return Q(**{f"{prefix}address__family": value.value}) -@strawberry_django.filter(models.IPRange, lookups=True) + +@strawberry_django.filter_type(models.IPRange, lookups=True) class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() start_address: FilterLookup[str] | None = strawberry_django.filter_field() @@ -170,9 +208,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( strawberry_django.filter_field() ) - role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) + role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter_field() @@ -189,7 +225,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi return q -@strawberry_django.filter(models.Prefix, lookups=True) +@strawberry_django.filter_type(models.Prefix, lookups=True) class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): aggregate: Annotated['AggregateFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -209,19 +245,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter(models.RIR, lookups=True) +@strawberry_django.filter_type(models.RIR, lookups=True) class RIRFilter(OrganizationalModelFilterMixin): is_private: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter(models.Role, lookups=True) +@strawberry_django.filter_type(models.Role, lookups=True) class RoleFilter(OrganizationalModelFilterMixin): weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) -@strawberry_django.filter(models.RouteTarget, lookups=True) +@strawberry_django.filter_type(models.RouteTarget, lookups=True) class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -238,7 +274,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin): ) -@strawberry_django.filter(models.Service, lookups=True) +@strawberry_django.filter_type(models.Service, lookups=True) class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -250,12 +286,12 @@ class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilt parent_object_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter(models.ServiceTemplate, lookups=True) +@strawberry_django.filter_type(models.ServiceTemplate, lookups=True) class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() -@strawberry_django.filter(models.VLAN, lookups=True) +@strawberry_django.filter_type(models.VLAN, lookups=True) class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin): site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() site_id: ID | None = strawberry_django.filter_field() @@ -285,19 +321,19 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin): ) -@strawberry_django.filter(models.VLANGroup, lookups=True) +@strawberry_django.filter_type(models.VLANGroup, lookups=True) class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin): vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) -@strawberry_django.filter(models.VLANTranslationPolicy, lookups=True) +@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True) class VLANTranslationPolicyFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() -@strawberry_django.filter(models.VLANTranslationRule, lookups=True) +@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True) class VLANTranslationRuleFilter(NetBoxModelFilterMixin): policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -312,7 +348,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilterMixin): ) -@strawberry_django.filter(models.VRF, lookups=True) +@strawberry_django.filter_type(models.VRF, lookups=True) class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() rd: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py b/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py index 133173234..62372a8e2 100644 --- a/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py +++ b/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py @@ -11,7 +11,9 @@ def set_vid_ranges(apps, schema_editor): Convert the min_vid & max_vid fields to a range in the new vid_ranges ArrayField. """ VLANGroup = apps.get_model('ipam', 'VLANGroup') - for group in VLANGroup.objects.all(): + db_alias = schema_editor.connection.alias + + for group in VLANGroup.objects.using(db_alias).all(): group.vid_ranges = [NumericRange(group.min_vid, group.max_vid, bounds='[]')] group._total_vlan_ids = group.max_vid - group.min_vid + 1 group.save() diff --git a/netbox/ipam/migrations/0071_prefix_scope.py b/netbox/ipam/migrations/0071_prefix_scope.py index 47a971750..bf80f9b5e 100644 --- a/netbox/ipam/migrations/0071_prefix_scope.py +++ b/netbox/ipam/migrations/0071_prefix_scope.py @@ -1,4 +1,5 @@ import django.db.models.deletion +from django.contrib.contenttypes.models import ContentType from django.db import migrations, models @@ -9,9 +10,11 @@ def copy_site_assignments(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') Prefix = apps.get_model('ipam', 'Prefix') Site = apps.get_model('dcim', 'Site') + db_alias = schema_editor.connection.alias - Prefix.objects.filter(site__isnull=False).update( - scope_type=ContentType.objects.get_for_model(Site), scope_id=models.F('site_id') + Prefix.objects.using(db_alias).filter(site__isnull=False).update( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=models.F('site_id') ) @@ -42,3 +45,20 @@ class Migration(migrations.Migration): # Copy over existing site assignments migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop), ] + + +def oc_prefix_scope(objectchange, reverting): + site_ct = ContentType.objects.get_by_natural_key('dcim', 'site').pk + for data in (objectchange.prechange_data, objectchange.postchange_data): + if data is None: + continue + if site_id := data.get('site'): + data.update({ + 'scope_type': site_ct, + 'scope_id': site_id, + }) + + +objectchange_migrators = { + 'ipam.prefix': oc_prefix_scope, +} diff --git a/netbox/ipam/migrations/0072_prefix_cached_relations.py b/netbox/ipam/migrations/0072_prefix_cached_relations.py index 58cefb12d..1dcc55a74 100644 --- a/netbox/ipam/migrations/0072_prefix_cached_relations.py +++ b/netbox/ipam/migrations/0072_prefix_cached_relations.py @@ -7,15 +7,16 @@ def populate_denormalized_fields(apps, schema_editor): Copy site ForeignKey values to the scope GFK. """ Prefix = apps.get_model('ipam', 'Prefix') + db_alias = schema_editor.connection.alias - prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site') + prefixes = Prefix.objects.using(db_alias).filter(site__isnull=False).prefetch_related('site') for prefix in prefixes: prefix._region_id = prefix.site.region_id prefix._site_group_id = prefix.site.group_id prefix._site_id = prefix.site_id # Note: Location cannot be set prior to migration - Prefix.objects.bulk_update(prefixes, ['_region', '_site_group', '_site'], batch_size=100) + Prefix.objects.using(db_alias).bulk_update(prefixes, ['_region', '_site_group', '_site'], batch_size=100) class Migration(migrations.Migration): @@ -59,3 +60,14 @@ class Migration(migrations.Migration): name='site', ), ] + + +def oc_prefix_remove_fields(objectchange, reverting): + for data in (objectchange.prechange_data, objectchange.postchange_data): + if data is not None: + data.pop('site', None) + + +objectchange_migrators = { + 'ipam.prefix': oc_prefix_remove_fields, +} diff --git a/netbox/ipam/migrations/0073_charfield_null_choices.py b/netbox/ipam/migrations/0073_charfield_null_choices.py index cfb764b46..82be106a4 100644 --- a/netbox/ipam/migrations/0073_charfield_null_choices.py +++ b/netbox/ipam/migrations/0073_charfield_null_choices.py @@ -7,9 +7,10 @@ def set_null_values(apps, schema_editor): """ FHRPGroup = apps.get_model('ipam', 'FHRPGroup') IPAddress = apps.get_model('ipam', 'IPAddress') + db_alias = schema_editor.connection.alias - FHRPGroup.objects.filter(auth_type='').update(auth_type=None) - IPAddress.objects.filter(role='').update(role=None) + FHRPGroup.objects.using(db_alias).filter(auth_type='').update(auth_type=None) + IPAddress.objects.using(db_alias).filter(role='').update(role=None) class Migration(migrations.Migration): diff --git a/netbox/ipam/migrations/0080_populate_service_parent.py b/netbox/ipam/migrations/0080_populate_service_parent.py index 78f3086fc..bd9d1c8b5 100644 --- a/netbox/ipam/migrations/0080_populate_service_parent.py +++ b/netbox/ipam/migrations/0080_populate_service_parent.py @@ -1,37 +1,40 @@ +from django.contrib.contenttypes.models import ContentType from django.db import migrations from django.db.models import F -def populate_service_parent_gfk(apps, schema_config): +def populate_service_parent_gfk(apps, schema_editor): Service = apps.get_model('ipam', 'Service') ContentType = apps.get_model('contenttypes', 'ContentType') Device = apps.get_model('dcim', 'device') VirtualMachine = apps.get_model('virtualization', 'virtualmachine') + db_alias = schema_editor.connection.alias - Service.objects.filter(device_id__isnull=False).update( + Service.objects.using(db_alias).filter(device_id__isnull=False).update( parent_object_type=ContentType.objects.get_for_model(Device), parent_object_id=F('device_id'), ) - Service.objects.filter(virtual_machine_id__isnull=False).update( + Service.objects.using(db_alias).filter(virtual_machine_id__isnull=False).update( parent_object_type=ContentType.objects.get_for_model(VirtualMachine), parent_object_id=F('virtual_machine_id'), ) -def repopulate_device_and_virtualmachine_relations(apps, schemaconfig): +def repopulate_device_and_virtualmachine_relations(apps, schema_editor): Service = apps.get_model('ipam', 'Service') ContentType = apps.get_model('contenttypes', 'ContentType') Device = apps.get_model('dcim', 'device') VirtualMachine = apps.get_model('virtualization', 'virtualmachine') + db_alias = schema_editor.connection.alias - Service.objects.filter( + Service.objects.using(db_alias).filter( parent_object_type=ContentType.objects.get_for_model(Device), ).update( device_id=F('parent_object_id') ) - Service.objects.filter( + Service.objects.using(db_alias).filter( parent_object_type=ContentType.objects.get_for_model(VirtualMachine), ).update( virtual_machine_id=F('parent_object_id') @@ -52,3 +55,26 @@ class Migration(migrations.Migration): reverse_code=repopulate_device_and_virtualmachine_relations, ) ] + + +def oc_service_parent(objectchange, reverting): + device_ct = ContentType.objects.get_by_natural_key('dcim', 'device').pk + virtual_machine_ct = ContentType.objects.get_by_natural_key('virtualization', 'virtualmachine').pk + for data in (objectchange.prechange_data, objectchange.postchange_data): + if data is None: + continue + if device_id := data.get('device'): + data.update({ + 'parent_object_type': device_ct, + 'parent_object_id': device_id, + }) + elif virtual_machine_id := data.get('virtual_machine'): + data.update({ + 'parent_object_type': virtual_machine_ct, + 'parent_object_id': virtual_machine_id, + }) + + +objectchange_migrators = { + 'ipam.service': oc_service_parent, +} diff --git a/netbox/ipam/migrations/0081_remove_service_device_virtual_machine_add_parent_gfk_index.py b/netbox/ipam/migrations/0081_remove_service_device_virtual_machine_add_parent_gfk_index.py index 03b63cd12..f026fc654 100644 --- a/netbox/ipam/migrations/0081_remove_service_device_virtual_machine_add_parent_gfk_index.py +++ b/netbox/ipam/migrations/0081_remove_service_device_virtual_machine_add_parent_gfk_index.py @@ -37,3 +37,15 @@ class Migration(migrations.Migration): ), ), ] + + +def oc_service_remove_fields(objectchange, reverting): + for data in (objectchange.prechange_data, objectchange.postchange_data): + if data is not None: + data.pop('device', None) + data.pop('virtual_machine', None) + + +objectchange_migrators = { + 'ipam.service': oc_service_remove_fields, +} diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 77ab8194a..89013aa31 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -148,7 +148,7 @@ class VLANQuerySet(RestrictedQuerySet): # Find all relevant VLANGroups q = Q() - site = vm.site or vm.cluster._site + site = vm.site if vm.cluster: # Add VLANGroups scoped to the assigned cluster (or its group) q |= Q( @@ -160,6 +160,30 @@ class VLANQuerySet(RestrictedQuerySet): scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'), scope_id=vm.cluster.group_id ) + # Looking all possible cluster scopes + if vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'location'): + site = site or vm.cluster.scope.site + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'), + scope_id__in=vm.cluster.scope.get_ancestors(include_self=True) + ) + elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'site'): + site = site or vm.cluster.scope + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=vm.cluster.scope.pk + ) + elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'sitegroup'): + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=vm.cluster.scope.get_ancestors(include_self=True) + ) + elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'region'): + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=vm.cluster.scope.get_ancestors(include_self=True) + ) + # VM can be assigned to a site without a cluster so checking assigned site independently if site: # Add VLANGroups scoped to the assigned site (or its group or region) q |= Q( diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 79616ff4e..79d524e87 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1,5 +1,7 @@ import json +import logging +from django.test import tag from django.urls import reverse from netaddr import IPNetwork from rest_framework import status @@ -9,7 +11,7 @@ from ipam.choices import * from ipam.models import * from tenancy.models import Tenant from utilities.data import string_to_ranges -from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings +from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging class AppTest(APITestCase): @@ -383,6 +385,18 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): ) Prefix.objects.bulk_create(prefixes) + @tag('regression') + def test_clean_validates_scope(self): + prefix = Prefix.objects.first() + site = Site.objects.create(name='Test Site', slug='test-site') + + data = {'scope_type': 'dcim.site', 'scope_id': site.id} + url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk}) + self.add_permissions('ipam.change_prefix') + + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + def test_list_available_prefixes(self): """ Test retrieval of all available prefixes within a parent prefix. @@ -1029,7 +1043,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.add_permissions('ipam.delete_vlan') url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk}) - with disable_warnings('netbox.api.views.ModelViewSet'): + with disable_logging(level=logging.WARNING): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_409_CONFLICT) diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 94cb39a51..e068f8e06 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1855,6 +1855,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]), Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], scope=sites[1]), Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], scope=sites[2]), + Cluster(name='Cluster 4', type=cluster_type, group=cluster_groups[0], scope=locations[0]), ) for cluster in clusters: cluster.save() @@ -1863,6 +1864,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]), VirtualMachine(name='Virtual Machine 3', cluster=clusters[2]), + VirtualMachine(name='Virtual Machine 4', cluster=clusters[3]), ) VirtualMachine.objects.bulk_create(virtual_machines) @@ -1870,6 +1872,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): VMInterface(virtual_machine=virtual_machines[0], name='VM Interface 1'), VMInterface(virtual_machine=virtual_machines[1], name='VM Interface 2'), VMInterface(virtual_machine=virtual_machines[2], name='VM Interface 3'), + VMInterface(virtual_machine=virtual_machines[3], name='VM Interface 4'), ) VMInterface.objects.bulk_create(vm_interfaces) @@ -1896,6 +1899,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): VLANGroup(name='Cluster 1', slug='cluster-1', scope=clusters[0]), VLANGroup(name='Cluster 2', slug='cluster-2', scope=clusters[1]), VLANGroup(name='Cluster 3', slug='cluster-3', scope=clusters[2]), + VLANGroup(name='Cluster 4', slug='cluster-4', scope=clusters[3]), # General purpose VLAN groups VLANGroup(name='VLAN Group 1', slug='vlan-group-1'), @@ -1950,11 +1954,12 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): VLAN(vid=19, name='Cluster 1', group=groups[18]), VLAN(vid=20, name='Cluster 2', group=groups[19]), VLAN(vid=21, name='Cluster 3', group=groups[20]), + VLAN(vid=22, name='Cluster 4', group=groups[21]), VLAN( vid=101, name='VLAN 101', site=sites[3], - group=groups[21], + group=groups[22], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE, @@ -1963,7 +1968,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): vid=102, name='VLAN 102', site=sites[3], - group=groups[21], + group=groups[22], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE, @@ -1972,7 +1977,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): vid=201, name='VLAN 201', site=sites[4], - group=groups[22], + group=groups[23], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED, @@ -1981,7 +1986,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): vid=202, name='VLAN 202', site=sites[4], - group=groups[22], + group=groups[23], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED, @@ -1990,7 +1995,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): vid=301, name='VLAN 301', site=sites[5], - group=groups[23], + group=groups[24], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED, @@ -1999,13 +2004,13 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): vid=302, name='VLAN 302', site=sites[5], - group=groups[23], + group=groups[24], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED, ), # Create one globally available VLAN on a VLAN group - VLAN(vid=500, name='VLAN Group 1', group=groups[24]), + VLAN(vid=500, name='VLAN Group 1', group=groups[25]), # Create one globally available VLAN VLAN(vid=1000, name='Global VLAN'), # Create some Q-in-Q service VLANs @@ -2136,6 +2141,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): vm_id = VirtualMachine.objects.first().pk params = {'available_on_virtualmachine': vm_id} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global + vm_id = VirtualMachine.objects.get(name='Virtual Machine 4').pk + params = {'available_on_virtualmachine': vm_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) # 6 scoped + 1 global group + 1 global def test_available_at_site(self): site_id = Site.objects.first().pk diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index ba739779a..ac674d0ea 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1080,6 +1080,9 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role) interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL) + fhrp_group = FHRPGroup.objects.create( + name='Group 1', group_id=1234, protocol=FHRPGroupProtocolChoices.PROTOCOL_CARP + ) services = ( Service(parent=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), @@ -1091,6 +1094,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ip_addresses = ( IPAddress(assigned_object=interface, address='192.0.2.1/24'), IPAddress(assigned_object=interface, address='192.0.2.2/24'), + IPAddress(assigned_object=fhrp_group, address='192.0.2.3/24'), ) IPAddress.objects.bulk_create(ip_addresses) @@ -1112,6 +1116,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): "dcim.device,Device 1,Service 1,tcp,1,192.0.2.1/24,First service", "dcim.device,Device 1,Service 2,tcp,2,192.0.2.2/24,Second service", "dcim.device,Device 1,Service 3,udp,3,,Third service", + "ipam.fhrpgroup,Group 1,Service 4,udp,4,192.0.2.3/24,Fourth service", ) cls.csv_update_data = ( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c69d1594a..c0f250648 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -10,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.forms import InterfaceFilterForm from dcim.models import Device, Interface, Site from ipam.tables import VLANTranslationRuleTable +from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport from netbox.views import generic from utilities.query import count_related from utilities.tables import get_table_ordering @@ -45,10 +46,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView): instance.import_targets.all(), orderable=False ) + import_targets_table.configure(request) + export_targets_table = tables.RouteTargetTable( instance.export_targets.all(), orderable=False ) + export_targets_table.configure(request) return { 'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]), @@ -83,6 +87,11 @@ class VRFBulkEditView(generic.BulkEditView): form = forms.VRFBulkEditForm +@register_model_view(VRF, 'bulk_rename', path='rename', detail=False) +class VRFBulkRenameView(generic.BulkRenameView): + queryset = VRF.objects.all() + + @register_model_view(VRF, 'bulk_delete', path='delete', detail=False) class VRFBulkDeleteView(generic.BulkDeleteView): queryset = VRF.objects.all() @@ -133,6 +142,11 @@ class RouteTargetBulkEditView(generic.BulkEditView): form = forms.RouteTargetBulkEditForm +@register_model_view(RouteTarget, 'bulk_rename', path='rename', detail=False) +class RouteTargetBulkRenameView(generic.BulkRenameView): + queryset = RouteTarget.objects.all() + + @register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False) class RouteTargetBulkDeleteView(generic.BulkDeleteView): queryset = RouteTarget.objects.all() @@ -192,6 +206,11 @@ class RIRBulkEditView(generic.BulkEditView): form = forms.RIRBulkEditForm +@register_model_view(RIR, 'bulk_rename', path='rename', detail=False) +class RIRBulkRenameView(generic.BulkRenameView): + queryset = RIR.objects.all() + + @register_model_view(RIR, 'bulk_delete', path='delete', detail=False) class RIRBulkDeleteView(generic.BulkDeleteView): queryset = RIR.objects.annotate( @@ -265,6 +284,11 @@ class ASNRangeBulkEditView(generic.BulkEditView): form = forms.ASNRangeBulkEditForm +@register_model_view(ASNRange, 'bulk_rename', path='rename', detail=False) +class ASNRangeBulkRenameView(generic.BulkRenameView): + queryset = ASNRange.objects.all() + + @register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False) class ASNRangeBulkDeleteView(generic.BulkDeleteView): queryset = ASNRange.objects.annotate_asn_counts() @@ -332,6 +356,11 @@ class ASNBulkEditView(generic.BulkEditView): form = forms.ASNBulkEditForm +@register_model_view(ASN, 'bulk_rename', path='rename', detail=False) +class ASNBulkRenameView(generic.BulkRenameView): + queryset = ASN.objects.all() + + @register_model_view(ASN, 'bulk_delete', path='delete', detail=False) class ASNBulkDeleteView(generic.BulkDeleteView): queryset = ASN.objects.annotate( @@ -353,6 +382,7 @@ class AggregateListView(generic.ObjectListView): filterset = filtersets.AggregateFilterSet filterset_form = forms.AggregateFilterForm table = tables.AggregateTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(Aggregate) @@ -485,6 +515,11 @@ class RoleBulkEditView(generic.BulkEditView): form = forms.RoleBulkEditForm +@register_model_view(Role, 'bulk_rename', path='rename', detail=False) +class RoleBulkRenameView(generic.BulkRenameView): + queryset = Role.objects.all() + + @register_model_view(Role, 'bulk_delete', path='delete', detail=False) class RoleBulkDeleteView(generic.BulkDeleteView): queryset = Role.objects.all() @@ -503,6 +538,7 @@ class PrefixListView(generic.ObjectListView): filterset_form = forms.PrefixFilterForm table = tables.PrefixTable template_name = 'ipam/prefix_list.html' + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(Prefix) @@ -530,6 +566,7 @@ class PrefixView(generic.ObjectView): exclude=('vrf', 'utilization'), orderable=False ) + parent_prefix_table.configure(request) # Duplicate prefixes table duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter( @@ -544,6 +581,7 @@ class PrefixView(generic.ObjectView): exclude=('vrf', 'utilization'), orderable=False ) + duplicate_prefix_table.configure(request) return { 'aggregate': aggregate, @@ -709,6 +747,7 @@ class IPRangeView(generic.ObjectView): exclude=('vrf', 'utilization'), orderable=False ) + parent_prefixes_table.configure(request) return { 'parent_prefixes_table': parent_prefixes_table, @@ -760,6 +799,11 @@ class IPRangeBulkEditView(generic.BulkEditView): form = forms.IPRangeBulkEditForm +@register_model_view(IPRange, 'bulk_rename', path='rename', detail=False) +class IPRangeBulkRenameView(generic.BulkRenameView): + queryset = IPRange.objects.all() + + @register_model_view(IPRange, 'bulk_delete', path='delete', detail=False) class IPRangeBulkDeleteView(generic.BulkDeleteView): queryset = IPRange.objects.all() @@ -777,6 +821,7 @@ class IPAddressListView(generic.ObjectListView): filterset = filtersets.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm table = tables.IPAddressTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(IPAddress) @@ -796,6 +841,7 @@ class IPAddressView(generic.ObjectView): exclude=('vrf', 'utilization'), orderable=False ) + parent_prefixes_table.configure(request) # Duplicate IPs table duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter( @@ -811,6 +857,7 @@ class IPAddressView(generic.ObjectView): duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST) # Limit to a maximum of 10 duplicates displayed here duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False) + duplicate_ips_table.configure(request) return { 'parent_prefixes_table': parent_prefixes_table, @@ -888,6 +935,7 @@ class IPAddressAssignView(generic.ObjectView): # Limit to 100 results addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100] table = tables.IPAddressAssignTable(addresses) + table.configure(request) return render(request, 'ipam/ipaddress_assign.html', { 'form': form, @@ -997,6 +1045,11 @@ class VLANGroupBulkEditView(generic.BulkEditView): form = forms.VLANGroupBulkEditForm +@register_model_view(VLANGroup, 'bulk_rename', path='rename', detail=False) +class VLANGroupBulkRenameView(generic.BulkRenameView): + queryset = VLANGroup.objects.all() + + @register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False) class VLANGroupBulkDeleteView(generic.BulkDeleteView): queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') @@ -1053,6 +1106,8 @@ class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView): data=instance.rules.all(), orderable=False ) + vlan_translation_table.configure(request) + return { 'vlan_translation_table': vlan_translation_table, } @@ -1084,6 +1139,11 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView): form = forms.VLANTranslationPolicyBulkEditForm +@register_model_view(VLANTranslationPolicy, 'bulk_rename', path='rename', detail=False) +class VLANTranslationPolicyBulkRenameView(generic.BulkRenameView): + queryset = VLANTranslationPolicy.objects.all() + + @register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False) class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView): queryset = VLANTranslationPolicy.objects.all() @@ -1101,6 +1161,7 @@ class VLANTranslationRuleListView(generic.ObjectListView): filterset = filtersets.VLANTranslationRuleFilterSet filterset_form = forms.VLANTranslationRuleFilterForm table = tables.VLANTranslationRuleTable + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete) @register_model_view(VLANTranslationRule) @@ -1170,6 +1231,7 @@ class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView): data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance), orderable=False ) + members_table.configure(request) members_table.columns.hide('group') return { @@ -1232,6 +1294,11 @@ class FHRPGroupBulkEditView(generic.BulkEditView): form = forms.FHRPGroupBulkEditForm +@register_model_view(FHRPGroup, 'bulk_rename', path='rename', detail=False) +class FHRPGroupBulkRenameView(generic.BulkRenameView): + queryset = FHRPGroup.objects.all() + + @register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False) class FHRPGroupBulkDeleteView(generic.BulkDeleteView): queryset = FHRPGroup.objects.all() @@ -1289,6 +1356,7 @@ class VLANView(generic.ObjectView): 'vrf', 'scope', 'role', 'tenant' ) prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False) + prefix_table.configure(request) return { 'prefix_table': prefix_table, @@ -1358,6 +1426,11 @@ class VLANBulkEditView(generic.BulkEditView): form = forms.VLANBulkEditForm +@register_model_view(VLAN, 'bulk_rename', path='rename', detail=False) +class VLANBulkRenameView(generic.BulkRenameView): + queryset = VLAN.objects.all() + + @register_model_view(VLAN, 'bulk_delete', path='delete', detail=False) class VLANBulkDeleteView(generic.BulkDeleteView): queryset = VLAN.objects.all() @@ -1408,6 +1481,11 @@ class ServiceTemplateBulkEditView(generic.BulkEditView): form = forms.ServiceTemplateBulkEditForm +@register_model_view(ServiceTemplate, 'bulk_rename', path='rename', detail=False) +class ServiceTemplateBulkRenameView(generic.BulkRenameView): + queryset = ServiceTemplate.objects.all() + + @register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False) class ServiceTemplateBulkDeleteView(generic.BulkDeleteView): queryset = ServiceTemplate.objects.all() @@ -1475,6 +1553,11 @@ class ServiceBulkEditView(generic.BulkEditView): form = forms.ServiceBulkEditForm +@register_model_view(Service, 'bulk_rename', path='rename', detail=False) +class ServiceBulkRenameView(generic.BulkRenameView): + queryset = Service.objects.all() + + @register_model_view(Service, 'bulk_delete', path='delete', detail=False) class ServiceBulkDeleteView(generic.BulkDeleteView): queryset = Service.objects.prefetch_related('parent') diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 649510239..2039f735b 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -2,7 +2,7 @@ import logging from functools import cached_property from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.db import transaction +from django.db import router, transaction from django.db.models import ProtectedError, RestrictedError from django_pglocks import advisory_lock from netbox.constants import ADVISORY_LOCK_KEYS @@ -170,7 +170,7 @@ class NetBoxModelViewSet( # Enforce object-level permissions on save() try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(model)): instance = serializer.save() self._validate_objects(instance) except ObjectDoesNotExist: @@ -190,7 +190,7 @@ class NetBoxModelViewSet( # Enforce object-level permissions on save() try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(model)): instance = serializer.save() self._validate_objects(instance) except ObjectDoesNotExist: diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index e21be2348..4fedebad5 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -1,5 +1,5 @@ from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction +from django.db import router, transaction from django.http import Http404 from rest_framework import status from rest_framework.response import Response @@ -56,22 +56,22 @@ class SequentialBulkCreatesMixin: which depends on the evaluation of existing objects (such as checking for free space within a rack) functions appropriately. """ - @transaction.atomic def create(self, request, *args, **kwargs): - if not isinstance(request.data, list): - # Creating a single object - return super().create(request, *args, **kwargs) + with transaction.atomic(using=router.db_for_write(self.queryset.model)): + if not isinstance(request.data, list): + # Creating a single object + return super().create(request, *args, **kwargs) - return_data = [] - for data in request.data: - serializer = self.get_serializer(data=data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - return_data.append(serializer.data) + return_data = [] + for data in request.data: + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return_data.append(serializer.data) - headers = self.get_success_headers(serializer.data) + headers = self.get_success_headers(serializer.data) - return Response(return_data, status=status.HTTP_201_CREATED, headers=headers) + return Response(return_data, status=status.HTTP_201_CREATED, headers=headers) class BulkUpdateModelMixin: @@ -113,7 +113,7 @@ class BulkUpdateModelMixin: return Response(data, status=status.HTTP_200_OK) def perform_bulk_update(self, objects, update_data, partial): - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(self.queryset.model)): data_list = [] for obj in objects: data = update_data.get(obj.id) @@ -157,7 +157,7 @@ class BulkDestroyModelMixin: return Response(status=status.HTTP_204_NO_CONTENT) def perform_bulk_destroy(self, objects): - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(self.queryset.model)): for obj in objects: if hasattr(obj, 'snapshot'): obj.snapshot() diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index ada6b1293..612f75a40 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -231,14 +231,19 @@ SESSION_FILE_PATH = None # DISK_BASE_UNIT = 1024 # RAM_BASE_UNIT = 1024 -# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the -# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example: -# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage' -# STORAGE_CONFIG = { -# 'AWS_ACCESS_KEY_ID': 'Key ID', -# 'AWS_SECRET_ACCESS_KEY': 'Secret', -# 'AWS_STORAGE_BUCKET_NAME': 'netbox', -# 'AWS_S3_REGION_NAME': 'eu-west-1', +# Within the STORAGES dictionary, "default" is used for image uploads, "staticfiles" is for static files and "scripts" +# is used for custom scripts. See django-storages and django-storage-swift libraries for more details. By default the +# following configuration is used: +# STORAGES = { +# "default": { +# "BACKEND": "django.core.files.storage.FileSystemStorage", +# }, +# "staticfiles": { +# "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", +# }, +# "scripts": { +# "BACKEND": "extras.storage.ScriptFileSystemStorage", +# }, # } # Time zone (default: UTC) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 8d20fed45..ad96a643d 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -28,7 +28,8 @@ ADVISORY_LOCK_KEYS = { 'job-schedules': 110100, } -# Default view action permission mapping +# TODO: Remove in NetBox v4.6 +# Legacy default view action permission mapping DEFAULT_ACTION_PERMISSIONS = { 'add': {'add'}, 'export': {'view'}, @@ -43,3 +44,10 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***' # Placeholder text for empty tables EMPTY_TABLE_TEXT = 'No results found' + +# CSV delimiters +CSV_DELIMITERS = { + 'comma': ',', + 'semicolon': ';', + 'pipe': '|', +} diff --git a/netbox/netbox/models/deletion.py b/netbox/netbox/models/deletion.py new file mode 100644 index 000000000..10416b748 --- /dev/null +++ b/netbox/netbox/models/deletion.py @@ -0,0 +1,90 @@ +import logging + +from django.contrib.contenttypes.fields import GenericRelation +from django.db import router +from django.db.models.deletion import Collector + +logger = logging.getLogger("netbox.models.deletion") + + +class CustomCollector(Collector): + """ + Custom collector that handles GenericRelations correctly. + """ + + def collect( + self, + objs, + source=None, + nullable=False, + collect_related=True, + source_attr=None, + reverse_dependency=False, + keep_parents=False, + fail_on_restricted=True, + ): + """ + Override collect to first collect standard dependencies, + then add GenericRelations to the dependency graph. + """ + # Call parent collect first to get all standard dependencies + super().collect( + objs, + source=source, + nullable=nullable, + collect_related=collect_related, + source_attr=source_attr, + reverse_dependency=reverse_dependency, + keep_parents=keep_parents, + fail_on_restricted=fail_on_restricted, + ) + + # Track which GenericRelations we've already processed to prevent infinite recursion + processed_relations = set() + + # Now add GenericRelations to the dependency graph + for _, instances in list(self.data.items()): + for instance in instances: + # Get all GenericRelations for this model + for field in instance._meta.private_fields: + if isinstance(field, GenericRelation): + # Create a unique key for this relation + relation_key = f"{instance._meta.model_name}.{field.name}" + if relation_key in processed_relations: + continue + processed_relations.add(relation_key) + + # Add the model that the generic relation points to as a dependency + self.add_dependency(field.related_model, instance, reverse_dependency=True) + + +class DeleteMixin: + """ + Mixin to override the model delete function to use our custom collector. + """ + + def delete(self, using=None, keep_parents=False): + """ + Override delete to use our custom collector. + """ + using = using or router.db_for_write(self.__class__, instance=self) + assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % ( + self._meta.object_name, + self._meta.pk.attname, + ) + + collector = CustomCollector(using=using) + collector.collect([self], keep_parents=keep_parents) + + return collector.delete() + + delete.alters_data = True + + @classmethod + def verify_mro(cls, instance): + """ + Verify that this mixin is first in the MRO. + """ + mro = instance.__class__.__mro__ + if mro.index(cls) != 0: + raise RuntimeError(f"{cls.__name__} must be first in the MRO. Current MRO: {mro}") diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 25f23c9d3..79145ce70 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -16,6 +16,7 @@ from extras.choices import * from extras.constants import CUSTOMFIELD_EMPTY_VALUES from extras.utils import is_taggable from netbox.config import get_config +from netbox.models.deletion import DeleteMixin from netbox.registry import registry from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder @@ -45,7 +46,7 @@ __all__ = ( # Feature mixins # -class ChangeLoggingMixin(models.Model): +class ChangeLoggingMixin(DeleteMixin, models.Model): """ Provides change logging support for a model. Adds the `created` and `last_updated` fields. """ diff --git a/netbox/netbox/models/mixins.py b/netbox/netbox/models/mixins.py index dc706c7c2..13af8aaf5 100644 --- a/netbox/netbox/models/mixins.py +++ b/netbox/netbox/models/mixins.py @@ -1,6 +1,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ + from netbox.choices import * from utilities.conversion import to_grams, to_meters @@ -58,7 +59,7 @@ class DistanceMixin(models.Model): max_digits=8, decimal_places=2, blank=True, - null=True + null=True, ) distance_unit = models.CharField( verbose_name=_('distance unit'), @@ -69,7 +70,7 @@ class DistanceMixin(models.Model): ) # Stores the normalized distance (in meters) for database ordering _abs_distance = models.DecimalField( - max_digits=10, + max_digits=13, decimal_places=4, blank=True, null=True diff --git a/netbox/netbox/object_actions.py b/netbox/netbox/object_actions.py new file mode 100644 index 000000000..73315ce4c --- /dev/null +++ b/netbox/netbox/object_actions.py @@ -0,0 +1,180 @@ +from django.urls import reverse +from django.urls.exceptions import NoReverseMatch +from django.utils.translation import gettext as _ + +from core.models import ObjectType +from extras.models import ExportTemplate +from utilities.querydict import prepare_cloned_fields + +__all__ = ( + 'AddObject', + 'BulkDelete', + 'BulkEdit', + 'BulkExport', + 'BulkImport', + 'BulkRename', + 'CloneObject', + 'DeleteObject', + 'EditObject', + 'ObjectAction', +) + + +class ObjectAction: + """ + Base class for single- and multi-object operations. + + Params: + name: The action name appended to the module for view resolution + label: Human-friendly label for the rendered button + multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table) + permissions_required: The set of permissions a user must have to perform the action + url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL + """ + name = '' + label = None + multi = False + permissions_required = set() + url_kwargs = [] + + @classmethod + def get_url(cls, obj): + viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}' + kwargs = { + kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs + } + try: + return reverse(viewname, kwargs=kwargs) + except NoReverseMatch: + return + + @classmethod + def get_context(cls, context, obj): + return { + 'url': cls.get_url(obj), + 'label': cls.label, + } + + +class AddObject(ObjectAction): + """ + Create a new object. + """ + name = 'add' + label = _('Add') + permissions_required = {'add'} + template_name = 'buttons/add.html' + + +class CloneObject(ObjectAction): + """ + Populate the new object form with select details from an existing object. + """ + name = 'add' + label = _('Clone') + permissions_required = {'add'} + template_name = 'buttons/clone.html' + + @classmethod + def get_context(cls, context, obj): + param_string = prepare_cloned_fields(obj).urlencode() + url = f'{cls.get_url(obj)}?{param_string}' if param_string else None + return { + 'url': url, + 'label': cls.label, + } + + +class EditObject(ObjectAction): + """ + Edit a single object. + """ + name = 'edit' + label = _('Edit') + permissions_required = {'change'} + url_kwargs = ['pk'] + template_name = 'buttons/edit.html' + + +class DeleteObject(ObjectAction): + """ + Delete a single object. + """ + name = 'delete' + label = _('Delete') + permissions_required = {'delete'} + url_kwargs = ['pk'] + template_name = 'buttons/delete.html' + + +class BulkImport(ObjectAction): + """ + Import multiple objects at once. + """ + name = 'bulk_import' + label = _('Import') + permissions_required = {'add'} + template_name = 'buttons/import.html' + + +class BulkExport(ObjectAction): + """ + Export multiple objects at once. + """ + name = 'export' + label = _('Export') + permissions_required = {'view'} + template_name = 'buttons/export.html' + + @classmethod + def get_context(cls, context, model): + object_type = ObjectType.objects.get_for_model(model) + user = context['request'].user + + # Determine if the "all data" export returns CSV or YAML + data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV' + + # Retrieve all export templates for this model + export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type) + + return { + 'label': cls.label, + 'perms': context['perms'], + 'object_type': object_type, + 'url_params': context['request'].GET.urlencode() if context['request'].GET else '', + 'export_templates': export_templates, + 'data_format': data_format, + } + + +class BulkEdit(ObjectAction): + """ + Change the value of one or more fields on a set of objects. + """ + name = 'bulk_edit' + label = _('Edit Selected') + multi = True + permissions_required = {'change'} + template_name = 'buttons/bulk_edit.html' + + +class BulkRename(ObjectAction): + """ + Rename multiple objects at once. + """ + name = 'bulk_rename' + label = _('Rename Selected') + multi = True + permissions_required = {'change'} + template_name = 'buttons/bulk_rename.html' + + +class BulkDelete(ObjectAction): + """ + Delete each of a set of objects. + """ + name = 'bulk_delete' + label = _('Delete Selected') + multi = True + permissions_required = {'delete'} + template_name = 'buttons/bulk_delete.html' diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 4fdb7e31f..b537679b3 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -54,6 +54,14 @@ PREFERENCES = { default='bottom', description=_('Where the paginator controls will be displayed relative to a table') ), + 'ui.tables.striping': UserPreference( + label=_('Striped table rows'), + choices=( + ('', _('Disabled')), + ('true', _('Enabled')), + ), + description=_('Render table rows with alternating colors to increase readability'), + ), # Miscellaneous 'data_format': UserPreference( @@ -64,6 +72,16 @@ PREFERENCES = { ), description=_('The preferred syntax for displaying generic data within the UI') ), + 'csv_delimiter': UserPreference( + label=_('CSV delimiter'), + choices=( + ('comma', 'Comma (,)'), + ('semicolon', 'Semicolon (;)'), + ('pipe', 'Pipe (|)'), + ), + default='comma', + description=_('The character used to separate fields in CSV data') + ), } diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 6f6b30af2..63fd0ea0e 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -66,6 +66,9 @@ class BaseTable(tables.Table): if column.visible: model = getattr(self.Meta, 'model') accessor = column.accessor + if accessor.startswith('custom_field_data__'): + # Ignore custom field references + continue prefetch_path = [] for field_name in accessor.split(accessor.SEPARATOR): try: @@ -163,6 +166,8 @@ class BaseTable(tables.Table): columns = userconfig.get(f"tables.{self.name}.columns") if ordering is None: ordering = userconfig.get(f"tables.{self.name}.ordering") + if userconfig.get("ui.tables.striping"): + self.attrs['class'] += ' table-striped' # Fall back to the default columns & ordering if columns is None and hasattr(settings, 'DEFAULT_USER_PREFERENCES'): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 4fd23e84c..11d7bafee 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -6,7 +6,7 @@ from django.contrib import messages from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError -from django.db import transaction, IntegrityError +from django.db import IntegrityError, router, transaction from django.db.models import ManyToManyField, ProtectedError, RestrictedError from django.db.models.fields.reverse_related import ManyToManyRel from django.forms import ModelMultipleChoiceField, MultipleHiddenInput @@ -15,15 +15,16 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from django_tables2.export import TableExport from mptt.models import MPTTModel from core.models import ObjectType from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate +from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation +from utilities.export import TableExport from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.htmx import htmx_partial @@ -54,12 +55,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): Attributes: filterset: A django-filter FilterSet that is applied to the queryset filterset_form: The form class used to render filter options - actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk - action names must be prefixed with `bulk_`. (See ActionsMixin.) + actions: An iterable of ObjectAction subclasses (see ActionsMixin) """ template_name = 'generic/object_list.html' filterset = None filterset_form = None + actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete) def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -76,7 +77,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): return '---\n'.join(yaml_data) - def export_table(self, table, columns=None, filename=None): + def export_table(self, table, columns=None, filename=None, delimiter=None): """ Export all table data in CSV format. @@ -85,6 +86,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): columns: A list of specific columns to include. If None, all columns will be exported. filename: The name of the file attachment sent to the client. If None, will be determined automatically from the queryset model name. + delimiter: The character used to separate columns (a comma is used by default) """ exclude_columns = {'pk', 'actions'} if columns: @@ -95,7 +97,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): exporter = TableExport( export_format=TableExport.CSV, table=table, - exclude_columns=exclude_columns + exclude_columns=exclude_columns, + delimiter=delimiter, ) return exporter.response( filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' @@ -150,15 +153,16 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Determine the available actions actions = self.get_permitted_actions(request.user) - has_bulk_actions = any([a.startswith('bulk_') for a in actions]) + has_table_actions = any(action.multi for action in actions) if 'export' in request.GET: # Export the current table view if request.GET['export'] == 'table': - table = self.get_table(self.queryset, request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_table_actions) columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) + delimiter = request.user.config.get('csv_delimiter') + return self.export_table(table, columns, delimiter=delimiter) # Render an ExportTemplate elif request.GET['export']: @@ -174,11 +178,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Fall back to default table/YAML export else: - table = self.get_table(self.queryset, request, has_bulk_actions) - return self.export_table(table) + table = self.get_table(self.queryset, request, has_table_actions) + delimiter = request.user.config.get('csv_delimiter') + return self.export_table(table, delimiter=delimiter) # Render the objects table - table = self.get_table(self.queryset, request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_table_actions) # If this is an HTMX request, return only the rendered table HTML if htmx_partial(request): @@ -278,7 +283,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): logger.debug("Form validation was successful") try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(model)): new_objs = self._create_objects(form, request) # Enforce object-level permissions @@ -501,7 +506,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): try: # Iterate through data and bind each record to a new model form instance. - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(model)): new_objs = self.create_and_update_objects(form, request) # Enforce object-level permissions @@ -681,7 +686,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): if form.is_valid(): logger.debug("Form validation was successful") try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(model)): updated_objects = self._update_objects(form, request) # Enforce object-level permissions @@ -729,7 +734,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): """ An extendable view for renaming objects in bulk. + + Attributes: + field_name: The name of the object attribute for which the value is being updated (defaults to "name") """ + field_name = 'name' template_name = 'generic/bulk_rename.html' def __init__(self, *args, **kwargs): @@ -759,12 +768,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): replace = form.cleaned_data['replace'] if form.cleaned_data['use_regex']: try: - obj.new_name = re.sub(find, replace, obj.name or '') + obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, '')) # Catch regex group reference errors except re.error: - obj.new_name = obj.name + obj.new_name = getattr(obj, self.field_name) else: - obj.new_name = (obj.name or '').replace(find, replace) + obj.new_name = getattr(obj, self.field_name, '').replace(find, replace) renamed_pks.append(obj.pk) return renamed_pks @@ -778,12 +787,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): if form.is_valid(): try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(self.queryset.model)): renamed_pks = self._rename_objects(form, selected_objects) if '_apply' in request.POST: for obj in selected_objects: - obj.name = obj.new_name + setattr(obj, self.field_name, obj.new_name) obj.save() # Enforce constrained permissions @@ -813,6 +822,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): selected_objects = self.queryset.filter(pk__in=form.initial['pk']) return render(request, self.template_name, { + 'field_name': self.field_name, 'form': form, 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 'selected_objects': selected_objects, @@ -875,7 +885,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): queryset = self.queryset.filter(pk__in=pk_list) deleted_count = queryset.count() try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(model)): for obj in queryset: # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): @@ -980,7 +990,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): } try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(self.queryset.model)): for obj in data['pk']: diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 9ad14a3d0..d8ba2b475 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -1,7 +1,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.contrib import messages -from django.db import transaction +from django.db import router, transaction from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ @@ -240,7 +240,7 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView): data_file__isnull=False ) - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(self.queryset.model)): for obj in selected_objects: obj.sync(save=True) diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 5f9f62120..079164ed9 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -1,7 +1,7 @@ from django.shortcuts import get_object_or_404 from extras.models import TableConfig -from netbox.constants import DEFAULT_ACTION_PERMISSIONS +from netbox import object_actions from utilities.permissions import get_permission_for_model __all__ = ( @@ -9,6 +9,18 @@ __all__ = ( 'TableMixin', ) +# TODO: Remove in NetBox v4.5 +LEGACY_ACTIONS = { + 'add': object_actions.AddObject, + 'edit': object_actions.EditObject, + 'delete': object_actions.DeleteObject, + 'export': object_actions.BulkExport, + 'bulk_import': object_actions.BulkImport, + 'bulk_edit': object_actions.BulkEdit, + 'bulk_rename': object_actions.BulkRename, + 'bulk_delete': object_actions.BulkDelete, +} + class ActionsMixin: """ @@ -19,7 +31,24 @@ class ActionsMixin: Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map with custom actions, such as bulk_sync. """ - actions = DEFAULT_ACTION_PERMISSIONS + actions = tuple() + + # TODO: Remove in NetBox v4.5 + def _convert_legacy_actions(self): + """ + Convert a legacy dictionary mapping action name to required permissions to a list of ObjectAction subclasses. + """ + if type(self.actions) is not dict: + return + + actions = [] + for name in self.actions.keys(): + try: + actions.append(LEGACY_ACTIONS[name]) + except KeyError: + raise ValueError(f"Unsupported legacy action: {name}") + + self.actions = actions def get_permitted_actions(self, user, model=None): """ @@ -27,11 +56,15 @@ class ActionsMixin: """ model = model or self.queryset.model + # TODO: Remove in NetBox v4.5 + # Handle legacy action sets + self._convert_legacy_actions() + # Resolve required permissions for each action permitted_actions = [] for action in self.actions: required_permissions = [ - get_permission_for_model(model, name) for name in self.actions.get(action, set()) + get_permission_for_model(model, perm) for perm in action.permissions_required ] if not required_permissions or user.has_perms(required_permissions): permitted_actions.append(action) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 0db73b7a6..5bc79d962 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -14,6 +14,9 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from core.signals import clear_events +from netbox.object_actions import ( + AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, CloneObject, DeleteObject, EditObject, +) from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ConfirmationForm, restrict_form_fields @@ -36,7 +39,7 @@ __all__ = ( ) -class ObjectView(BaseObjectView): +class ObjectView(ActionsMixin, BaseObjectView): """ Retrieve a single object for display. @@ -44,8 +47,10 @@ class ObjectView(BaseObjectView): Attributes: tab: A ViewTab instance for the view + actions: An iterable of ObjectAction subclasses (see ActionsMixin) """ tab = None + actions = (CloneObject, EditObject, DeleteObject) def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -72,9 +77,11 @@ class ObjectView(BaseObjectView): request: The current request """ instance = self.get_object(**kwargs) + actions = self.get_permitted_actions(request.user, model=instance) return render(request, self.get_template_name(), { 'object': instance, + 'actions': actions, 'tab': self.tab, **self.get_extra_context(request, instance), }) @@ -90,13 +97,13 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): table: The django-tables2 Table class used to render the child objects list filterset: A django-filter FilterSet that is applied to the queryset filterset_form: The form class used to render filter options - actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk - action names must be prefixed with `bulk_`. (See ActionsMixin.) + actions: An iterable of ObjectAction subclasses (see ActionsMixin) """ child_model = None table = None filterset = None filterset_form = None + actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete) template_name = 'generic/object_children.html' def get_children(self, request, parent): @@ -138,10 +145,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) - has_bulk_actions = any([a.startswith('bulk_') for a in actions]) + has_table_actions = any(action.multi for action in actions) table_data = self.prep_table_data(request, child_objects, instance) - table = self.get_table(table_data, request, has_bulk_actions) + table = self.get_table(table_data, request, has_table_actions) # If this is an HTMX request, return only the rendered table HTML if htmx_partial(request): @@ -282,7 +289,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): logger.debug("Form validation was successful") try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(model)): object_created = form.instance.pk is None obj = form.save() @@ -570,7 +577,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): if not form.errors and not component_form.errors: try: - with transaction.atomic(): + with transaction.atomic(using=router.db_for_write(self.queryset.model)): # Create the new components new_objs = [] for component_form in new_components: diff --git a/netbox/project-static/dist/netbox-external.css b/netbox/project-static/dist/netbox-external.css index a7390f98f..3a1a35589 100644 Binary files a/netbox/project-static/dist/netbox-external.css and b/netbox/project-static/dist/netbox-external.css differ diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 26d652ad0..3cfdc2e5d 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index d51c3ebcc..2f4422fd6 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 1c17e5841..6e4020dcb 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 26ada66cf..bf75a5e38 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -23,14 +23,14 @@ }, "dependencies": { "@mdi/font": "7.4.47", - "@tabler/core": "1.2.0", - "bootstrap": "5.3.5", + "@tabler/core": "1.3.2", + "bootstrap": "5.3.7", "clipboard": "2.0.11", "flatpickr": "4.6.13", - "gridstack": "12.0.0", - "htmx.org": "2.0.4", - "query-string": "9.1.1", - "sass": "1.87.0", + "gridstack": "12.2.1", + "htmx.org": "2.0.5", + "query-string": "9.2.1", + "sass": "1.89.2", "tom-select": "2.4.3", "typeface-inter": "3.18.1", "typeface-roboto-mono": "1.1.13" @@ -41,7 +41,7 @@ "@types/node": "^22.3.0", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", - "esbuild": "^0.24.2", + "esbuild": "^0.25.3", "esbuild-sass-plugin": "^3.3.1", "eslint": "<9.0", "eslint-config-prettier": "^9.1.0", diff --git a/netbox/project-static/src/tableConfig.ts b/netbox/project-static/src/tableConfig.ts index e39dc3bfd..0f19dc486 100644 --- a/netbox/project-static/src/tableConfig.ts +++ b/netbox/project-static/src/tableConfig.ts @@ -106,7 +106,8 @@ function handleSubmit(event: Event): void { const toast = createToast('danger', 'Error Updating Table Configuration', res.error); toast.show(); } else { - location.reload(); + // Strip any URL query parameters & reload the page + window.location.href = window.location.origin + window.location.pathname; } }); } diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 2e3f34e65..4304afddb 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -3,11 +3,9 @@ "@babel/runtime@^7.13.10": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c" - integrity sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g== - dependencies: - regenerator-runtime "^0.14.0" + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.1.tgz#9fce313d12c9a77507f264de74626e87fd0dc541" + integrity sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog== "@emotion/is-prop-valid@^0.8.2": version "0.8.8" @@ -21,130 +19,130 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== -"@esbuild/aix-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" - integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== +"@esbuild/aix-ppc64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz#014180d9a149cffd95aaeead37179433f5ea5437" + integrity sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ== -"@esbuild/android-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" - integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== +"@esbuild/android-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz#649e47e04ddb24a27dc05c395724bc5f4c55cbfe" + integrity sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ== -"@esbuild/android-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" - integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== +"@esbuild/android-arm@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.3.tgz#8a0f719c8dc28a4a6567ef7328c36ea85f568ff4" + integrity sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A== -"@esbuild/android-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" - integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== +"@esbuild/android-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.3.tgz#e2ab182d1fd06da9bef0784a13c28a7602d78009" + integrity sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ== -"@esbuild/darwin-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" - integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== +"@esbuild/darwin-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz#c7f3166fcece4d158a73dcfe71b2672ca0b1668b" + integrity sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w== -"@esbuild/darwin-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" - integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== +"@esbuild/darwin-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz#d8c5342ec1a4bf4b1915643dfe031ba4b173a87a" + integrity sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A== -"@esbuild/freebsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" - integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== +"@esbuild/freebsd-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz#9f7d789e2eb7747d4868817417cc968ffa84f35b" + integrity sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw== -"@esbuild/freebsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" - integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== +"@esbuild/freebsd-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz#8ad35c51d084184a8e9e76bb4356e95350a64709" + integrity sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q== -"@esbuild/linux-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" - integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== +"@esbuild/linux-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz#3af0da3d9186092a9edd4e28fa342f57d9e3cd30" + integrity sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A== -"@esbuild/linux-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" - integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== +"@esbuild/linux-arm@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz#e91cafa95e4474b3ae3d54da12e006b782e57225" + integrity sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ== -"@esbuild/linux-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" - integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== +"@esbuild/linux-ia32@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz#81025732d85b68ee510161b94acdf7e3007ea177" + integrity sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw== -"@esbuild/linux-loong64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" - integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== +"@esbuild/linux-loong64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz#3c744e4c8d5e1148cbe60a71a11b58ed8ee5deb8" + integrity sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g== -"@esbuild/linux-mips64el@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" - integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== +"@esbuild/linux-mips64el@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz#1dfe2a5d63702db9034cc6b10b3087cc0424ec26" + integrity sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag== -"@esbuild/linux-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" - integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== +"@esbuild/linux-ppc64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz#2e85d9764c04a1ebb346dc0813ea05952c9a5c56" + integrity sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg== -"@esbuild/linux-riscv64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" - integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== +"@esbuild/linux-riscv64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz#a9ea3334556b09f85ccbfead58c803d305092415" + integrity sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA== -"@esbuild/linux-s390x@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" - integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== +"@esbuild/linux-s390x@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz#f6a7cb67969222b200974de58f105dfe8e99448d" + integrity sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ== -"@esbuild/linux-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" - integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== +"@esbuild/linux-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz#a237d3578ecdd184a3066b1f425e314ade0f8033" + integrity sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA== -"@esbuild/netbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" - integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== +"@esbuild/netbsd-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz#4c15c68d8149614ddb6a56f9c85ae62ccca08259" + integrity sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA== -"@esbuild/netbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" - integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== +"@esbuild/netbsd-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz#12f6856f8c54c2d7d0a8a64a9711c01a743878d5" + integrity sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g== -"@esbuild/openbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" - integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== +"@esbuild/openbsd-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz#ca078dad4a34df192c60233b058db2ca3d94bc5c" + integrity sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ== -"@esbuild/openbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" - integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== +"@esbuild/openbsd-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz#c9178adb60e140e03a881d0791248489c79f95b2" + integrity sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w== -"@esbuild/sunos-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" - integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== +"@esbuild/sunos-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz#03765eb6d4214ff27e5230af779e80790d1ee09f" + integrity sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA== -"@esbuild/win32-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" - integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== +"@esbuild/win32-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz#f1c867bd1730a9b8dfc461785ec6462e349411ea" + integrity sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ== -"@esbuild/win32-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" - integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== +"@esbuild/win32-ia32@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz#77491f59ef6c9ddf41df70670d5678beb3acc322" + integrity sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew== -"@esbuild/win32-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" - integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== +"@esbuild/win32-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz#b17a2171f9074df9e91bfb07ef99a892ac06412a" + integrity sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg== "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" @@ -759,13 +757,13 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@tabler/core@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.2.0.tgz#cc61cd60d0bc644709bd708f1dd917e760203b4e" - integrity sha512-Zrisg/pMi3c/X8AFbmwY6GNlWS/XPlW/jzt6grMar8ICOT7jO0weU9f/KCVgA49I1jMg2ev0uGxcpI5DP3CNdQ== +"@tabler/core@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.3.2.tgz#85e4a47b661bca4cd50e26039fc25c4bdb4aff34" + integrity sha512-QDVJbv48YJrahBLdxYkLi6NutQv4jGbkUWyzxE2NcNJ3s3GGpRx98JmbAoN92NZKNmf26vZdW6k2Q5haVKlS4A== dependencies: "@popperjs/core" "^2.11.8" - bootstrap "5.3.5" + bootstrap "5.3.6" "@tanstack/react-virtual@^3.0.0-beta.60": version "3.5.0" @@ -1055,10 +1053,15 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -bootstrap@5.3.5: - version "5.3.5" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.5.tgz#be42cfe0d580e97ee1abb7d38ce94f5c393c9bb6" - integrity sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA== +bootstrap@5.3.6: + version "5.3.6" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.6.tgz#fbd91ebaff093f5b191a1c01a8c866d24f9fa6e1" + integrity sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA== + +bootstrap@5.3.7: + version "5.3.7" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.7.tgz#8640065036124d961d885d80b5945745e1154d90" + integrity sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw== brace-expansion@^1.1.7: version "1.1.11" @@ -1428,36 +1431,36 @@ esbuild-sass-plugin@^3.3.1: safe-identifier "^0.4.2" sass "^1.71.1" -esbuild@^0.24.2: - version "0.24.2" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" - integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== +esbuild@^0.25.3: + version "0.25.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.3.tgz#371f7cb41283e5b2191a96047a7a89562965a285" + integrity sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q== optionalDependencies: - "@esbuild/aix-ppc64" "0.24.2" - "@esbuild/android-arm" "0.24.2" - "@esbuild/android-arm64" "0.24.2" - "@esbuild/android-x64" "0.24.2" - "@esbuild/darwin-arm64" "0.24.2" - "@esbuild/darwin-x64" "0.24.2" - "@esbuild/freebsd-arm64" "0.24.2" - "@esbuild/freebsd-x64" "0.24.2" - "@esbuild/linux-arm" "0.24.2" - "@esbuild/linux-arm64" "0.24.2" - "@esbuild/linux-ia32" "0.24.2" - "@esbuild/linux-loong64" "0.24.2" - "@esbuild/linux-mips64el" "0.24.2" - "@esbuild/linux-ppc64" "0.24.2" - "@esbuild/linux-riscv64" "0.24.2" - "@esbuild/linux-s390x" "0.24.2" - "@esbuild/linux-x64" "0.24.2" - "@esbuild/netbsd-arm64" "0.24.2" - "@esbuild/netbsd-x64" "0.24.2" - "@esbuild/openbsd-arm64" "0.24.2" - "@esbuild/openbsd-x64" "0.24.2" - "@esbuild/sunos-x64" "0.24.2" - "@esbuild/win32-arm64" "0.24.2" - "@esbuild/win32-ia32" "0.24.2" - "@esbuild/win32-x64" "0.24.2" + "@esbuild/aix-ppc64" "0.25.3" + "@esbuild/android-arm" "0.25.3" + "@esbuild/android-arm64" "0.25.3" + "@esbuild/android-x64" "0.25.3" + "@esbuild/darwin-arm64" "0.25.3" + "@esbuild/darwin-x64" "0.25.3" + "@esbuild/freebsd-arm64" "0.25.3" + "@esbuild/freebsd-x64" "0.25.3" + "@esbuild/linux-arm" "0.25.3" + "@esbuild/linux-arm64" "0.25.3" + "@esbuild/linux-ia32" "0.25.3" + "@esbuild/linux-loong64" "0.25.3" + "@esbuild/linux-mips64el" "0.25.3" + "@esbuild/linux-ppc64" "0.25.3" + "@esbuild/linux-riscv64" "0.25.3" + "@esbuild/linux-s390x" "0.25.3" + "@esbuild/linux-x64" "0.25.3" + "@esbuild/netbsd-arm64" "0.25.3" + "@esbuild/netbsd-x64" "0.25.3" + "@esbuild/openbsd-arm64" "0.25.3" + "@esbuild/openbsd-x64" "0.25.3" + "@esbuild/sunos-x64" "0.25.3" + "@esbuild/win32-arm64" "0.25.3" + "@esbuild/win32-ia32" "0.25.3" + "@esbuild/win32-x64" "0.25.3" escape-string-regexp@^4.0.0: version "4.0.0" @@ -1905,10 +1908,10 @@ graphql@16.10.0: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c" integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ== -gridstack@12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.0.0.tgz#cb824410436573f480fc9e62c2e3fdf2fa536a9e" - integrity sha512-Wjfu7BtTb4NZqLpSEAJx+b9lBnfnXNgG2jUTVSD2g8NrHITrWgfk9eeHBqtDLVl2vtKQTzsSyy5lSyHAMcW2tA== +gridstack@12.2.1: + version "12.2.1" + resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.2.1.tgz#0e82e3d9d11e5229388d73bd57f8ef1a0e7059c4" + integrity sha512-xU69tThmmVxgMHTuM/z3rIKiiGm0zW4tcB6yRcuwiOUUBiwb3tslzFOrUjWz+PwaxoAW+JChT4fqOLl+oKAxZA== has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" @@ -1956,10 +1959,10 @@ hey-listen@^1.0.8: resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== -htmx.org@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.4.tgz#74fce66b177eb59c6d251ecf1052a2478743bec9" - integrity sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ== +htmx.org@2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.5.tgz#88e8d89078b3059d74ac4eb653d80451c144820c" + integrity sha512-ocgvtHCShWFW0DvSV1NbJC7Y5EzUMy2eo5zeWvGj2Ac4LOr7sv9YKg4jzCZJdXN21fXACmCViwKSy+cm6i2dWQ== ignore@^5.2.0, ignore@^5.3.1: version "5.3.2" @@ -2516,10 +2519,10 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -query-string@9.1.1: - version "9.1.1" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.1.tgz#dbfebb4196aeb2919915f2b2b81b91b965cf03a0" - integrity sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg== +query-string@9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.2.1.tgz#67bd95f6e2cb64eafecfb0504be7cc38bcd4dd11" + integrity sha512-3jTGGLRzlhu/1ws2zlr4Q+GVMLCQTLFOj8CMX5x44cdZG9FQE07x2mQhaNxaKVPNmIDu0mvJ/cEwtY7Pim7hqA== dependencies: decode-uri-component "^0.4.1" filter-obj "^5.1.0" @@ -2590,11 +2593,6 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -regenerator-runtime@^0.14.0: - version "0.14.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" - integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== - regexp.prototype.flags@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" @@ -2667,10 +2665,10 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -sass@1.87.0: - version "1.87.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.87.0.tgz#8cceb36fa63fb48a8d5d7f2f4c13b49c524b723e" - integrity sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw== +sass@1.89.2: + version "1.89.2" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e" + integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA== dependencies: chokidar "^4.0.0" immutable "^5.0.2" diff --git a/netbox/release.yaml b/netbox/release.yaml index bdcd1852e..3d4d74e6a 100644 --- a/netbox/release.yaml +++ b/netbox/release.yaml @@ -1,3 +1,3 @@ -version: "4.3.0-beta2" +version: "4.3.3" edition: "Community" -published: "2025-04-23" +published: "2025-06-26" diff --git a/netbox/templates/account/profile.html b/netbox/templates/account/profile.html index 20f8ad537..442cce9ba 100644 --- a/netbox/templates/account/profile.html +++ b/netbox/templates/account/profile.html @@ -1,12 +1,10 @@ {% extends 'account/base.html' %} -{% load helpers %} -{% load render_table from django_tables2 %} {% load i18n %} {% block title %}{% trans "User Profile" %}{% endblock %} {% block content %} -
+

{% trans "Account Details" %}

@@ -64,12 +62,7 @@ {% if perms.core.view_objectchange %}
-
-

{% trans "Recent Activity" %}

-
- {% render_table changelog_table 'inc/table.html' %} -
-
+ {% include 'users/inc/user_activity.html' with user=user table=changelog_table %}
{% endif %} diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html index 94c4599b0..ea5bab7ae 100644 --- a/netbox/templates/circuits/inc/circuit_termination_fields.html +++ b/netbox/templates/circuits/inc/circuit_termination_fields.html @@ -45,7 +45,7 @@
{% elif perms.dcim.add_cable %} {% include 'inc/panels/tags.html' %} diff --git a/netbox/templates/core/buttons/bulk_sync.html b/netbox/templates/core/buttons/bulk_sync.html new file mode 100644 index 000000000..e92ad15df --- /dev/null +++ b/netbox/templates/core/buttons/bulk_sync.html @@ -0,0 +1,3 @@ + diff --git a/netbox/templates/core/datafile.html b/netbox/templates/core/datafile.html index 175a0e2bc..0747547b1 100644 --- a/netbox/templates/core/datafile.html +++ b/netbox/templates/core/datafile.html @@ -11,12 +11,6 @@ {% endblock %} -{% block control-buttons %} - {% if request.user|can_delete:object %} - {% delete_button object %} - {% endif %} -{% endblock control-buttons %} - {% block content %}
diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index a38c3650a..49fa0231a 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -22,12 +22,6 @@ {% endif %} {% endblock breadcrumbs %} -{% block control-buttons %} - {% if request.user|can_delete:object %} - {% delete_button object %} - {% endif %} -{% endblock control-buttons %} - {% block content %}
diff --git a/netbox/templates/dcim/buttons/bulk_add_components.html b/netbox/templates/dcim/buttons/bulk_add_components.html new file mode 100644 index 000000000..b5eadeeac --- /dev/null +++ b/netbox/templates/dcim/buttons/bulk_add_components.html @@ -0,0 +1,71 @@ +{% load i18n %} +
+ + +
diff --git a/netbox/templates/dcim/buttons/bulk_disconnect.html b/netbox/templates/dcim/buttons/bulk_disconnect.html new file mode 100644 index 000000000..9ab53472b --- /dev/null +++ b/netbox/templates/dcim/buttons/bulk_disconnect.html @@ -0,0 +1,3 @@ + diff --git a/netbox/templates/dcim/component_list.html b/netbox/templates/dcim/component_list.html deleted file mode 100644 index 6f91aff3e..000000000 --- a/netbox/templates/dcim/component_list.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'generic/object_list.html' %} -{% load buttons %} -{% load helpers %} -{% load i18n %} - -{% block bulk_buttons %} -
- {% if 'bulk_edit' in actions %} - {% bulk_edit_button model query_params=request.GET %} - {% endif %} - {% if 'bulk_rename' in actions %} - {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %} - - {% endwith %} - {% endif %} -
- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index bd3c8cc4b..986b38dc8 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -65,7 +65,7 @@ {% trans "Not Connected" %} {% if perms.dcim.add_cable %}