diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b5de9bfee..a9af9c653 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.1 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 138e0f9b4..1fff99f1d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.1 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8099923f..57666417a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,7 +8,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v4 + - uses: actions/stale@v5 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an @@ -27,7 +27,10 @@ jobs: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + issues may receive direct feedback. **Do not** attempt to circumvent this + process by "bumping" the issue; doing so will result in its immediate closure + and you may be barred from participating in any future discussions. Please see + our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). stale-pr-label: 'pending closure' stale-pr-message: > This PR has been automatically marked as stale because it has not had diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d74da64..a6d145951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -The changelog has been moved to the [project release notes](https://netbox.readthedocs.io/en/stable/release-notes/). +The changelog has been moved to the [project release notes](https://docs.netbox.dev/en/stable/release-notes/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee69605c7..1b4733cbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,7 @@ appropriate labels will be applied for categorization. ## Submitting Pull Requests * If you're interested in contributing to NetBox, be sure to check out our -[getting started](https://netbox.readthedocs.io/en/stable/development/getting-started/) +[getting started](https://docs.netbox.dev/en/stable/development/getting-started/) documentation for tips on setting up your development environment. * Be sure to open an issue **before** starting work on a pull request, and @@ -160,9 +160,9 @@ to aid in issue management. It is natural that some new issues get more attention than others. The stale bot helps bring renewed attention to potentially valuable issues that may have -been overlooked. **Do not** comment on an issue that has been marked stale in -an effort to circumvent the bot: Doing so will not remove the stale label. -(Stale labels can be removed only by maintainers.) +been overlooked. **Do not** comment on a stale issue merely to "bump" it in an +effort to circumvent the bot: This will result in the immediate closure of the +issue, and you may be barred from participating in future discussions. ## Maintainer Guidance @@ -171,7 +171,7 @@ an effort to circumvent the bot: Doing so will not remove the stale label. the understanding that all contributions are submitted under the Apache 2.0 license and that your employer may not make claim to any contributions. Contributions include code work, issue management, and community support. All - development must be in accordance with our [development guidance](https://netbox.readthedocs.io/en/stable/development/). + development must be in accordance with our [development guidance](https://docs.netbox.dev/en/stable/development/). * Maintainers are expected to attend (where feasible) our biweekly ~30-minute sync to review agenda items. This meeting provides opportunity to present and diff --git a/README.md b/README.md index 8429cd4b3..60f007946 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). -The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). A public demo instance is available at https://demo.netbox.dev. +The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/). A public demo instance is available at https://demo.netbox.dev.

Thank you to our sponsors!

@@ -60,6 +60,8 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne            [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
+ [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/) +            [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
@@ -71,7 +73,7 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne ### Installation -Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for +Please see [the documentation](https://docs.netbox.dev/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases) and run `upgrade.sh`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..b389dd2b3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## No Warranty + +Per the terms of the Apache 2 license, NetBox is offered "as is" and without any guarantee or warranty pertaining to its operation. While every reasonable effort is made by its maintainers to ensure the product remains free of security vulnerabilities, users are ultimately responsible for conducting their own evaluations of each software release. + +## Recommendations + +Administrators are encouraged to adhere to industry best practices concerning the secure operation of software, such as: + +* Do not expose your NetBox installation to the public Internet +* Do not permit multiple users to share an account +* Enforce minimum password complexity requirements for local accounts +* Prohibit access to your database from clients other than the NetBox application +* Keep your deployment updated to the most recent stable release + +## Reporting a Suspected Vulnerability + +If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions: + +* Affects the most recent stable release of NetBox, or a current beta release +* Affects a NetBox instance installed and configured per the official documentation +* Is reproducible following a prescribed set of instructions + +Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous. + +If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project. + +### Bug Bounties + +As NetBox is provided as free open source software, we do not offer any monetary compensation for vulnerability or bug reports, however your contributions are greatly appreciated. diff --git a/base_requirements.txt b/base_requirements.txt index 4b814dbc7..10e8af3ba 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -30,10 +30,14 @@ django-pglocks # https://github.com/korfuri/django-prometheus django-prometheus -# Django chaching backend using Redis +# Django caching backend using Redis # https://github.com/jazzband/django-redis django-redis +# Django extensions for Rich (terminal text rendering) +# https://github.com/adamchainz/django-rich +django-rich + # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq django-rq @@ -68,8 +72,7 @@ gunicorn # Platform-agnostic template rendering engine # https://github.com/pallets/jinja -# Pin to v3.0 for mkdocstrings -Jinja2<3.1 +Jinja2 # Simple markup language for rendering HTML # https://github.com/Python-Markdown/markdown @@ -85,7 +88,7 @@ mkdocs-material # Introspection for embedded code # https://github.com/mkdocstrings/mkdocstrings -mkdocstrings<=0.17.0 +mkdocstrings[python-legacy] # Library for manipulating IP prefixes and addresses # https://github.com/netaddr/netaddr @@ -103,6 +106,10 @@ psycopg2-binary # https://github.com/yaml/pyyaml PyYAML +# Sentry SDK +# https://github.com/getsentry/sentry-python +sentry-sdk + # Social authentication framework # https://github.com/python-social-auth/social-core social-auth-core diff --git a/contrib/netbox-rq.service b/contrib/netbox-rq.service index 5b03777ed..6d2f42743 100644 --- a/contrib/netbox-rq.service +++ b/contrib/netbox-rq.service @@ -1,6 +1,6 @@ [Unit] Description=NetBox Request Queue Worker -Documentation=https://netbox.readthedocs.io/en/stable/ +Documentation=https://docs.netbox.dev/ After=network-online.target Wants=network-online.target diff --git a/contrib/netbox.service b/contrib/netbox.service index 18eb0457c..3cd02d988 100644 --- a/contrib/netbox.service +++ b/contrib/netbox.service @@ -1,6 +1,6 @@ [Unit] Description=NetBox WSGI Service -Documentation=https://netbox.readthedocs.io/en/stable/ +Documentation=https://docs.netbox.dev/ After=network-online.target Wants=network-online.target diff --git a/docs/administration/authentication/microsoft-azure-ad.md b/docs/administration/authentication/microsoft-azure-ad.md index b2de148ac..ee24e8232 100644 --- a/docs/administration/authentication/microsoft-azure-ad.md +++ b/docs/administration/authentication/microsoft-azure-ad.md @@ -75,5 +75,14 @@ If successful, you will be redirected back to the NetBox UI, and will be logged This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI. -!!! note "Troubleshooting" - If you are redirected to the NetBox UI after authenticating, but are _not_ logged in, double-check the configured backend and app registration. The instructions in this guide pertain only to the `azuread.AzureADOAuth2` backend using a single-tenant app registration. +## Troubleshooting + +### Redirect URI does not Match + +Azure requires that the authenticating client request a redirect URI that matches what you've configured for the app in step two. This URI **must** begin with `https://` (unless using `localhost` for the domain). + +If Azure complains that the requested URI starts with `http://` (not HTTPS), it's likely that your HTTP server is misconfigured or sitting behind a load balancer, so NetBox is not aware that HTTPS is being use. To force the use of an HTTPS redirect URI, set `SOCIAL_AUTH_REDIRECT_IS_HTTPS = True` in `configuration.py` per the [python-social-auth docs](https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html#processing-redirects-and-urlopen). + +### Not Logged in After Authenticating + +If you are redirected to the NetBox UI after authenticating successfully, but are _not_ logged in, double-check the configured backend and app registration. The instructions in this guide pertain only to the `azuread.AzureADOAuth2` backend using a single-tenant app registration. diff --git a/docs/administration/authentication/okta.md b/docs/administration/authentication/okta.md new file mode 100644 index 000000000..ff552d730 --- /dev/null +++ b/docs/administration/authentication/okta.md @@ -0,0 +1,70 @@ +# Okta + +This guide explains how to configure single sign-on (SSO) support for NetBox using [Okta](https://www.okta.com/) as an authentication backend. + +## Okta Configuration + +!!! tip "Okta developer account" + Okta offers free developer accounts at . + +### 1. Create a test user (optional) + +Create a new user in the Okta admin portal to be used for testing. You can skip this step if you already have a suitable account created. + +### 2. Create an app registration + +Within the Okta administration dashboard, navigate to **Applications > Applications**, and click the "Create App Integration" button. Select "OIDC" as the sign-in method, and "Web application" for the application type. + +![Create an app registration](../../media/authentication/okta_create_app_registration.png) + +On the next page, give the app integration a name (e.g. "NetBox") and specify the sign-in and sign-out URIs. These URIs should follow the formats below: + +* Sign-in URI: `https://{netbox}/oauth/complete/okta-openidconnect/` +* Sign-out URI: `https://{netbox}/oauth/disconnect/okta-openidconnect/` + +![Web app integration](../../media/authentication/okta_web_app_integration.png) + +Under "Assignments," select the controlled access setting most appropriate for your organization. Click "Save" to complete the creation. + +Once finished, note the following parameters. These will be used to configured NetBox. + +* Client ID +* Client secret +* Okta domain + +![Okta integration parameters](../../media/authentication/okta_integration_parameters.png) + +## NetBox Configuration + +### 1. Enter configuration parameters + +Enter the following configuration parameters in `configuration.py`, substituting your own values: + +```python +REMOTE_AUTH_BACKEND = 'social_core.backends.okta_openidconnect.OktaOpenIdConnect' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_KEY = '{Client ID}' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_SECRET = '{Client secret}' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL = 'https://{Okta domain}/oauth2/' +``` + +### 2. Restart NetBox + +Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below: + +```no-highlight +sudo systemctl restart netbox +``` + +## Testing + +Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Okta. Click that link. + +![NetBox Okta login form](../../media/authentication/netbox_okta_login.png) + +You should be redirected to Okta's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account. + +![Okta login portal](../../media/authentication/okta_login_portal.png) + +If successful, you will be redirected back to the NetBox UI, and will be logged in as the Okta user. You can verify this by navigating to your profile (using the button at top right). + +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI. diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md new file mode 100644 index 000000000..e04372338 --- /dev/null +++ b/docs/administration/error-reporting.md @@ -0,0 +1,46 @@ +# Error Reporting + +## Sentry + +### Enabling Error Reporting + +NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis. + +```python +SENTRY_ENABLED = True +``` + +### Using a Custom DSN + +If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below: + +``` +https://examplePublicKey@o0.ingest.sentry.io/0 +``` + +Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters: + +```python +SENTRY_ENABLED = True +SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" +``` + +### Assigning Tags + +You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter: + +```python +SENTRY_TAGS = { + "custom.foo": "123", + "custom.bar": "abc", +} +``` + +!!! warning "Reserved tag prefixes" + Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application. + +### Testing + +Once the configuration has been saved, restart the NetBox service. + +To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. (Be sure that debug mode has been disabled.) After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry. diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md new file mode 100644 index 000000000..d1c47e2fb --- /dev/null +++ b/docs/configuration/error-reporting.md @@ -0,0 +1,54 @@ +# Error Reporting Settings + +## SENTRY_DSN + +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: + +``` +SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" +``` + +--- + +## SENTRY_ENABLED + +Default: False + +Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). + +--- + +## SENTRY_SAMPLE_RATE + +Default: 1.0 (all) + +The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors). + +--- + +## SENTRY_TAGS + +An optional dictionary of tag names and values to apply to Sentry error reports.For example: + +``` +SENTRY_TAGS = { + "custom.foo": "123", + "custom.bar": "abc", +} +``` + +!!! warning "Reserved tag prefixes" + Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application. + +--- + +## SENTRY_TRACES_SAMPLE_RATE + +Default: 0 (disabled) + +The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions). + +!!! warning "Consider performance implications" + A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2). diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 76fd0a12c..670cf524b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [ --- +## CSRF_COOKIE_NAME + +Default: `csrftoken` + +The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail. + +--- + ## CSRF_TRUSTED_ORIGINS Default: `[]` diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index 9e01f8bb6..f88cd309b 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -105,11 +105,11 @@ from my_validators import Validator1, Validator2, Validator3 CUSTOM_VALIDATORS = { 'dcim.site': ( - Validator1, - Validator2, + Validator1(), + Validator2(), ), 'dcim.device': ( - Validator3, + Validator3(), ) } ``` diff --git a/docs/installation/4-gunicorn.md b/docs/installation/4-gunicorn.md index 4fc73a58b..21d1f1211 100644 --- a/docs/installation/4-gunicorn.md +++ b/docs/installation/4-gunicorn.md @@ -40,7 +40,7 @@ You should see output similar to the following: ● netbox.service - NetBox WSGI Service Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago - Docs: https://netbox.readthedocs.io/en/stable/ + Docs: https://docs.netbox.dev/ Main PID: 1140492 (gunicorn) Tasks: 19 (limit: 4683) Memory: 666.2M diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index 51508392f..a71b748fd 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -39,7 +39,7 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic ● netbox.service - NetBox WSGI Service Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago - Docs: https://netbox.readthedocs.io/en/stable/ + Docs: https://docs.netbox.dev/ Main PID: 11993 (gunicorn) Tasks: 6 (limit: 2362) CGroup: /system.slice/netbox.service diff --git a/docs/media/authentication/netbox_okta_login.png b/docs/media/authentication/netbox_okta_login.png new file mode 100644 index 000000000..34df39cba Binary files /dev/null and b/docs/media/authentication/netbox_okta_login.png differ diff --git a/docs/media/authentication/okta_create_app_registration.png b/docs/media/authentication/okta_create_app_registration.png new file mode 100644 index 000000000..7a62d3d9e Binary files /dev/null and b/docs/media/authentication/okta_create_app_registration.png differ diff --git a/docs/media/authentication/okta_integration_parameters.png b/docs/media/authentication/okta_integration_parameters.png new file mode 100644 index 000000000..b5d7794ec Binary files /dev/null and b/docs/media/authentication/okta_integration_parameters.png differ diff --git a/docs/media/authentication/okta_login_portal.png b/docs/media/authentication/okta_login_portal.png new file mode 100644 index 000000000..48b62d3e0 Binary files /dev/null and b/docs/media/authentication/okta_login_portal.png differ diff --git a/docs/media/authentication/okta_web_app_integration.png b/docs/media/authentication/okta_web_app_integration.png new file mode 100644 index 000000000..6587127c6 Binary files /dev/null and b/docs/media/authentication/okta_web_app_integration.png differ diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 7fc9bfc06..3e3516cd6 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,5 @@ # Clusters -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index de9b5f214..4ddffb99a 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,6 +1,6 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster. +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster. Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index 77e258def..6dccb4ee2 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl ::: netbox.tables.TemplateColumn selection: - members: false + members: + - __init__ diff --git a/docs/release-notes/version-2.1.md b/docs/release-notes/version-2.1.md index d4804661f..7ec172b1f 100644 --- a/docs/release-notes/version-2.1.md +++ b/docs/release-notes/version-2.1.md @@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes//available-ips/`. A #### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348)) -The [NAPALM automation](https://github.com/napalm-automation/napalm) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. +The [NAPALM automation](https://github.com/napalm-automation/napalm) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://docs.netbox.dev/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. ### Enhancements diff --git a/docs/release-notes/version-2.2.md b/docs/release-notes/version-2.2.md index e13c4fe69..4f75fb25a 100644 --- a/docs/release-notes/version-2.2.md +++ b/docs/release-notes/version-2.2.md @@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre #### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511)) -Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. +Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://docs.netbox.dev/en/stable/miscellaneous/reports/) for more info. ### Enhancements diff --git a/docs/release-notes/version-2.5.md b/docs/release-notes/version-2.5.md index 666ecb6f1..01c5bf57c 100644 --- a/docs/release-notes/version-2.5.md +++ b/docs/release-notes/version-2.5.md @@ -295,7 +295,7 @@ This release upgrades the Django framework to version 2.2. #### Python 3 Required -As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://netbox.readthedocs.io/en/stable/installation/migrating-to-python3/) for assistance with upgrading. +As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://docs.netbox.dev/en/stable/installation/migrating-to-python3/) for assistance with upgrading. #### Removed Deprecated User Activity Log diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index 7e9e8fea3..1f56ea889 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -218,7 +218,7 @@ #### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415)) -Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/customization/custom-scripts/) for more detail. +Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://docs.netbox.dev/en/stable/customization/custom-scripts/) for more detail. Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release. diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index e0297a692..ebc14d63c 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -67,7 +67,7 @@ ## v2.7.9 (2020-03-06) -**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://netbox.readthedocs.io/en/stable/installation/upgrading/). +**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://docs.netbox.dev/en/stable/installation/upgrading/). ### Enhancements @@ -418,7 +418,7 @@ to another source before upgrading NetBox to v2.7, as any existing topology maps #### Supervisor Replaced with systemd ([#2902](https://github.com/netbox-community/netbox/issues/2902)) -The NetBox [installation documentation](https://netbox.readthedocs.io/en/stable/installation/) has been updated to +The NetBox [installation documentation](https://docs.netbox.dev/en/stable/installation/) has been updated to provide instructions for managing the WSGI and RQ services using systemd instead of supervisor. This removes the need to install supervisor and simplifies administration of the processes. diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index af758f928..ba395793a 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -235,14 +235,14 @@ This release introduces support for custom plugins, which can be used to extend * Introduce new API endpoints * Add custom request/response middleware -For NetBox plugins to be recognized, they must be installed and added by name to the `PLUGINS` configuration parameter. (Plugin support is disabled by default.) Plugins can be configured under the `PLUGINS_CONFIG` parameter. More information can be found the in the [plugins documentation](https://netbox.readthedocs.io/en/stable/plugins/). +For NetBox plugins to be recognized, they must be installed and added by name to the `PLUGINS` configuration parameter. (Plugin support is disabled by default.) Plugins can be configured under the `PLUGINS_CONFIG` parameter. More information can be found the in the [plugins documentation](https://docs.netbox.dev/en/stable/plugins/). ### Enhancements * [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups * [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups * [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models -* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#logging)) +* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://docs.netbox.dev/en/stable/configuration/optional-settings/#logging)) ### Bug Fixes diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 97cedf0f5..339081902 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,10 +1,100 @@ # NetBox v3.2 -## v3.2.2 (FUTURE) +## v3.2.5 (FUTURE) + +### Enhancements + +* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes +* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view ### Bug Fixes +* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers +* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view +* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view +* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column + +--- + +## v3.2.4 (2022-05-31) + +### Enhancements + +* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated +* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view +* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports +* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter +* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search +* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device +* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn + +### Bug Fixes + +* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters +* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields +* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view +* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis +* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list +* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance +* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields +* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields + +--- + +## v3.2.3 (2022-05-12) + +### Enhancements + +* [#8805](https://github.com/netbox-community/netbox/issues/8805) - Add "mixed" option for device airflow indication +* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users +* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group +* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade +* [#9221](https://github.com/netbox-community/netbox/issues/9221) - Add definition list support for Markdown +* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views +* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list +* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module +* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments +* [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry +* [#9343](https://github.com/netbox-community/netbox/issues/9343) - Add Ubiquiti SmartPower power outlet type + +### Bug Fixes + +* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device +* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices +* [#9296](https://github.com/netbox-community/netbox/issues/9296) - Improve Markdown link sanitization +* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface +* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API +* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships +* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates + +--- + +## v3.2.2 (2022-04-28) + +### Enhancements + +* [#9060](https://github.com/netbox-community/netbox/issues/9060) - Add device type filters for device bays, module bays, and inventory items +* [#9152](https://github.com/netbox-community/netbox/issues/9152) - Annotate related object type under custom field view +* [#9192](https://github.com/netbox-community/netbox/issues/9192) - Add Ubiquiti SmartPower connector type +* [#9214](https://github.com/netbox-community/netbox/issues/9214) - Linkify cluster counts in cluster type & group tables + +### Bug Fixes + +* [#4264](https://github.com/netbox-community/netbox/issues/4264) - Treat 0th IP as unusable for IPv6 prefixes (excluding /127s) +* [#8941](https://github.com/netbox-community/netbox/issues/8941) - Fix dynamic dropdown behavior when browser is zoomed +* [#8959](https://github.com/netbox-community/netbox/issues/8959) - Prevent exception when refreshing scripts list (avoid race condition) +* [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later +* [#9138](https://github.com/netbox-community/netbox/issues/9138) - Avoid inadvertent form submission when utilizing quick search field on object lists +* [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view +* [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures +* [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment +* [#9194](https://github.com/netbox-community/netbox/issues/9194) - Support position assignment when add module bays to multiple devices +* [#9206](https://github.com/netbox-community/netbox/issues/9206) - Show header for comments field under module & module type creation views +* [#9222](https://github.com/netbox-community/netbox/issues/9222) - Fix circuit ID display under cable view +* [#9227](https://github.com/netbox-community/netbox/issues/9227) - Fix related object assignment when recording change record for interfaces --- diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 294f8f4d7..177b300e0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,13 +2,36 @@ ## v3.3.0 (FUTURE) +### Breaking Changes + +* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). + ### Enhancements +* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster +* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster +* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results * [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP +* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields + +### Other Changes + +* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset +* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output ### REST API Changes * extras.CustomField - * Added `group_name` field + * Added `group_name` and `ui_visibility` fields +* ipam.IPAddress + * The `nat_inside` field no longer requires a unique value + * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses +* virtualization.Cluster + * Added required `status` field (default value: `active`) +* virtualization.VirtualMachine + * Added `device` field + * The `site` field is now directly writable (rather than being inferred from the assigned cluster) + * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned. diff --git a/mkdocs.yml b/mkdocs.yml index 7db4d24d7..5c973e0d6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: NetBox Documentation site_dir: netbox/project-static/docs -site_url: https://netbox.readthedocs.io/ +site_url: https://docs.netbox.dev/ repo_name: netbox-community/netbox repo_url: https://github.com/netbox-community/netbox theme: @@ -73,6 +73,7 @@ nav: - Required Settings: 'configuration/required-settings.md' - Optional Settings: 'configuration/optional-settings.md' - Dynamic Settings: 'configuration/dynamic-settings.md' + - Error Reporting: 'configuration/error-reporting.md' - Remote Authentication: 'configuration/remote-authentication.md' - Core Functionality: - IP Address Management: 'core-functionality/ipam.md' @@ -121,7 +122,9 @@ nav: - Authentication: - Overview: 'administration/authentication/overview.md' - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md' + - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' + - Error Reporting: 'administration/error-reporting.md' - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index ca3b003b9..46d3824bb 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ('Attributes', ('type_id', 'status', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index cb8c940b0..40f8918ae 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -59,7 +59,7 @@ class CircuitTable(NetBoxTable): ) commit_rate = CommitRateColumn() comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index e97ade7d8..0ec6d439d 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable): name = tables.Column( linkify=True ) - asns = tables.ManyToManyColumn( + asns = columns.ManyToManyColumn( linkify_item=True, verbose_name='ASNs' ) @@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable): verbose_name='Circuits' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index c05aa31df..f3b1269f9 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -32,7 +32,7 @@ class ProviderView(generic.ObjectView): ).prefetch_related( 'type', 'tenant', 'terminations__site' ) - circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) + circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',)) circuits_table.configure(request) return { @@ -93,7 +93,7 @@ class ProviderNetworkView(generic.ObjectView): ).prefetch_related( 'type', 'tenant', 'terminations__site' ) - circuits_table = tables.CircuitTable(circuits) + circuits_table = tables.CircuitTable(circuits, user=request.user) circuits_table.configure(request) return { @@ -147,7 +147,7 @@ class CircuitTypeView(generic.ObjectView): def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) - circuits_table = tables.CircuitTable(circuits, exclude=('type',)) + circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',)) circuits_table.configure(request) return { diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 813c946a3..7fcab6ba3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -315,7 +315,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer): class ConsolePortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -325,13 +334,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', ] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -341,13 +360,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', ] class PowerPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, @@ -357,14 +386,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', - 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', + 'allocated_draw', 'description', 'created', 'last_updated', ] class PowerOutletTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, @@ -383,48 +421,75 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'created', 'last_updated', ] class InterfaceTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=InterfaceTypeChoices) class Meta: model = InterfaceTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', - 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'created', 'last_updated', ] class RearPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=PortTypeChoices) class Meta: model = RearPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', + 'description', 'created', 'last_updated', ] class FrontPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=PortTypeChoices) rear_port = NestedRearPortTemplateSerializer() class Meta: model = FrontPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'created', 'last_updated', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index b0aa1c60c..2e96f9c67 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -159,6 +159,7 @@ class DeviceAirflowChoices(ChoiceSet): AIRFLOW_RIGHT_TO_LEFT = 'right-to-left' AIRFLOW_SIDE_TO_REAR = 'side-to-rear' AIRFLOW_PASSIVE = 'passive' + AIRFLOW_MIXED = 'mixed' CHOICES = ( (AIRFLOW_FRONT_TO_REAR, 'Front to rear'), @@ -167,6 +168,7 @@ class DeviceAirflowChoices(ChoiceSet): (AIRFLOW_RIGHT_TO_LEFT, 'Right to left'), (AIRFLOW_SIDE_TO_REAR, 'Side to rear'), (AIRFLOW_PASSIVE, 'Passive'), + (AIRFLOW_MIXED, 'Mixed'), ) @@ -349,8 +351,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32' TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' + TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -464,9 +468,11 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), + (TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'), )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -573,8 +579,10 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a' TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' + TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -681,9 +689,11 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), + (TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'), )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -1041,6 +1051,7 @@ class PortTypeChoices(ChoiceSet): TYPE_URM_P2 = 'urm-p2' TYPE_URM_P4 = 'urm-p4' TYPE_URM_P8 = 'urm-p8' + TYPE_OTHER = 'other' CHOICES = ( ( @@ -1093,6 +1104,12 @@ class PortTypeChoices(ChoiceSet): (TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P8, 'URM-P8'), (TYPE_SPLICE, 'Splice'), + ), + ), + ( + 'Other', + ( + (TYPE_OTHER, 'Other'), ) ) ) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 45844b049..38bf16f0b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -62,6 +62,8 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage # Device components # +MODULE_TOKEN = '{module}' + MODULAR_COMPONENT_TEMPLATE_MODELS = Q( app_label='dcim', model__in=( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0f4e7cf7e..d57d0a59b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -346,6 +346,32 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='rack__site__region', + lookup_expr='in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='rack__site__region', + lookup_expr='in', + to_field_name='slug', + label='Region (slug)', + ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='rack__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='rack__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='rack__location', @@ -435,6 +461,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): method='_device_bays', label='Has device bays', ) + inventory_items = django_filters.BooleanFilter( + method='_inventory_items', + label='Has inventory items', + ) class Meta: model = DeviceType @@ -479,6 +509,9 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): def _device_bays(self, queryset, name, value): return queryset.exclude(devicebaytemplates__isnull=value) + def _inventory_items(self, queryset, name, value): + return queryset.exclude(inventoryitemtemplates__isnull=value) + class ModuleTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 4d73fcc2a..314a7a75f 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -3,7 +3,7 @@ from django import forms from dcim.models import * from extras.forms import CustomFieldsMixin from extras.models import Tag -from utilities.forms import DynamicModelMultipleChoiceField, form_from_model +from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model from .object_create import ComponentCreateForm __all__ = ( @@ -98,7 +98,13 @@ class RearPortBulkCreateForm( class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): model = ModuleBay - field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags') + + position_pattern = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + ) class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d5335947a..1535e5718 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte (None, ('q', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) status = MultipleChoiceField( choices=SiteStatusChoices, @@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF (None, ('q', 'tag')), ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -210,11 +210,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte model = Rack fieldsets = ( (None, ('q', 'tag')), - ('Location', ('region_id', 'site_id', 'location_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -229,6 +229,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte }, label=_('Site') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), required=False, @@ -282,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('User', ('user_id',)), - ('Rack', ('region_id', 'site_id', 'location_id')), + ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) region_id = DynamicModelMultipleChoiceField( @@ -298,6 +303,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('Site') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.prefetch_related('site'), required=False, @@ -319,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( (None, ('q', 'tag')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -331,7 +341,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', + 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', )), ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -392,6 +402,27 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + device_bays = forms.NullBooleanField( + required=False, + label='Has device bays', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + module_bays = forms.NullBooleanField( + required=False, + label='Has module bays', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + inventory_items = forms.NullBooleanField( + required=False, + label='Has inventory items', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) @@ -487,7 +518,7 @@ class DeviceFilterForm( ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), @@ -757,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -1071,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), + ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) role_id = DynamicModelMultipleChoiceField( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index fe9daf938..179893219 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -385,6 +385,12 @@ class ModuleTypeForm(NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + ('Module Type', ( + 'manufacturer', 'model', 'part_number', 'tags', + )), + ) + class Meta: model = ModuleType fields = [ @@ -627,11 +633,26 @@ class ModuleForm(NetBoxModelForm): help_text="Automatically populate components associated with this module type" ) + adopt_components = forms.BooleanField( + required=False, + initial=False, + help_text="Adopt already existing components" + ) + + fieldsets = ( + ('Module', ( + 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', + )), + ('Hardware', ( + 'serial', 'asset_tag', 'replicate_components', 'adopt_components', + )), + ) + class Meta: model = Module fields = [ 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'comments', + 'replicate_components', 'adopt_components', 'comments', ] def __init__(self, *args, **kwargs): @@ -640,6 +661,8 @@ class ModuleForm(NetBoxModelForm): if self.instance.pk: self.fields['replicate_components'].initial = False self.fields['replicate_components'].disabled = True + self.fields['adopt_components'].initial = False + self.fields['adopt_components'].disabled = True def save(self, *args, **kwargs): @@ -647,8 +670,62 @@ class ModuleForm(NetBoxModelForm): if self.instance.pk or not self.cleaned_data['replicate_components']: self.instance._disable_replication = True + if self.cleaned_data['adopt_components']: + self.instance._adopt_components = True + return super().save(*args, **kwargs) + def clean(self): + super().clean() + + replicate_components = self.cleaned_data.get("replicate_components") + adopt_components = self.cleaned_data.get("adopt_components") + device = self.cleaned_data['device'] + module_type = self.cleaned_data['module_type'] + module_bay = self.cleaned_data['module_bay'] + + # Bail out if we are not installing a new module or if we are not replicating components + if self.instance.pk or not replicate_components: + return + + for templates, component_attribute in [ + ("consoleporttemplates", "consoleports"), + ("consoleserverporttemplates", "consoleserverports"), + ("interfacetemplates", "interfaces"), + ("powerporttemplates", "powerports"), + ("poweroutlettemplates", "poweroutlets"), + ("rearporttemplates", "rearports"), + ("frontporttemplates", "frontports") + ]: + # Prefetch installed components + installed_components = { + component.name: component for component in getattr(device, component_attribute).all() + } + + # Get the templates for the module type. + for template in getattr(module_type, templates).all(): + # Installing modules with placeholders require that the bay has a position value + if MODULE_TOKEN in template.name and not module_bay.position: + raise forms.ValidationError( + "Cannot install module with placeholder values in a module bay with no position defined" + ) + + resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position) + existing_item = installed_components.get(resolved_name) + + # It is not possible to adopt components already belonging to a module + if adopt_components and existing_item and existing_item.module: + raise forms.ValidationError( + f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs " + f"to a module" + ) + + # If we are not adopting components we error if the component exists + if not adopt_components and resolved_name in installed_components: + raise forms.ValidationError( + f"{template.component_model.__name__} - {resolved_name} already exists" + ) + class CableForm(TenancyForm, NetBoxModelForm): @@ -1269,6 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'rf_channel_width': "Populated by selected channel (if set)", } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Restrict LAG/bridge interface assignment by device/VC + device_id = self.data['device'] if self.is_bound else self.initial.get('device') + device = Device.objects.filter(pk=device_id).first() + if device and device.virtual_chassis and device.virtual_chassis.master: + self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + class FrontPortForm(NetBoxModelForm): module = DynamicModelChoiceField( diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index e3e9c1179..8c9ddab19 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm): ] def clean(self): + super().clean() + if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: raise forms.ValidationError({ 'initial_position': "A position must be specified for the first VC member." diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 647abe148..92658d310 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -121,12 +121,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel): def resolve_name(self, module): if module: - return self.name.replace('{module}', module.module_bay.position) + return self.name.replace(MODULE_TOKEN, module.module_bay.position) return self.name def resolve_label(self, module): if module: - return self.label.replace('{module}', module.module_bay.position) + return self.label.replace(MODULE_TOKEN, module.module_bay.position) return self.label diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3ed786000..9a0609c12 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -77,7 +77,7 @@ class ComponentModel(NetBoxModel): def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.device - return super().to_objectchange(action) + return objectchange @property def parent_object(self): @@ -543,7 +543,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo ) speed = models.PositiveIntegerField( blank=True, - null=True + null=True, + verbose_name='Speed (Kbps)' ) duplex = models.CharField( max_length=50, diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6ed7b349f..e88af2d05 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel): return f'{self.name} ({self.asset_tag})' elif self.name: return self.name + elif self.virtual_chassis and self.asset_tag: + return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})' elif self.virtual_chassis: return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})' + elif self.device_type and self.asset_tag: + return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})' elif self.device_type: return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return super().__str__() @@ -1065,30 +1069,52 @@ class Module(NetBoxModel, ConfigContextModel): super().save(*args, **kwargs) - # If this is a new Module and component replication has not been disabled, instantiate all its - # related components per the ModuleType definition - if is_new and not getattr(self, '_disable_replication', False): - ConsolePort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] - ) - ConsoleServerPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()] - ) - PowerPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] - ) - PowerOutlet.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] - ) - Interface.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] - ) - RearPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] - ) - FrontPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] - ) + adopt_components = getattr(self, '_adopt_components', False) + disable_replication = getattr(self, '_disable_replication', False) + + # We skip adding components if the module is being edited or + # both replication and component adoption is disabled + if not is_new or (disable_replication and not adopt_components): + return + + # Iterate all component types + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort) + ]: + create_instances = [] + update_instances = [] + + # Prefetch installed components + installed_components = { + component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True) + } + + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) + + if adopt_components: + existing_item = installed_components.get(template_instance.name) + + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + update_instances.append(existing_item) + continue + + # Only create new components if replication is enabled + if not disable_replication: + create_instances.append(template_instance) + + component_model.objects.bulk_create(create_instances) + component_model.objects.bulk_update(update_instances, ['module']) # diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 25ad1415d..0f015b7f3 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -190,7 +190,7 @@ class DeviceTable(NetBoxTable): verbose_name='VC Priority' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f5f5ed7bf..2da9daee7 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -31,7 +31,9 @@ class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) - devicetype_count = tables.Column( + devicetype_count = columns.LinkedCountColumn( + viewname='dcim:devicetype_list', + url_params={'manufacturer_id': 'pk'}, verbose_name='Device Types' ) inventoryitem_count = tables.Column( @@ -41,7 +43,7 @@ class ManufacturerTable(NetBoxTable): verbose_name='Platforms' ) slug = tables.Column() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index cab95bb02..92c4bb0aa 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -26,7 +26,7 @@ class PowerPanelTable(NetBoxTable): url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index e5a1c8488..e6368cb74 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -69,7 +69,7 @@ class RackTable(NetBoxTable): orderable=False, verbose_name='Power' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 84522480f..fa3c73e12 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -26,7 +26,7 @@ class RegionTable(NetBoxTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -55,7 +55,7 @@ class SiteGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -86,7 +86,7 @@ class SiteTable(NetBoxTable): group = tables.Column( linkify=True ) - asns = tables.ManyToManyColumn( + asns = columns.ManyToManyColumn( linkify_item=True, verbose_name='ASNs' ) @@ -98,7 +98,7 @@ class SiteTable(NetBoxTable): ) tenant = TenantColumn() comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -137,7 +137,7 @@ class LocationTable(NetBoxTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5c7d22955..22537abe0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -523,6 +523,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) console_port_templates = ( ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'), @@ -541,9 +544,13 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Console Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Console Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Console Port Template 7', + }, ] @@ -560,6 +567,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) console_server_port_templates = ( ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'), @@ -578,9 +588,13 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Console Server Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Console Server Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Console Server Port Template 7', + }, ] @@ -597,6 +611,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) power_port_templates = ( PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), @@ -615,9 +632,13 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Power Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Power Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Power Port Template 7', + }, ] @@ -634,6 +655,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) power_port_templates = ( PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), @@ -664,6 +688,14 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Power Outlet Template 6', 'power_port': None, }, + { + 'module_type': moduletype.pk, + 'name': 'Power Outlet Template 7', + }, + { + 'module_type': moduletype.pk, + 'name': 'Power Outlet Template 8', + }, ] @@ -680,6 +712,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) interface_templates = ( InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'), @@ -700,10 +735,15 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): 'type': '1000base-t', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Interface Template 6', 'type': '1000base-t', }, + { + 'module_type': moduletype.pk, + 'name': 'Interface Template 7', + 'type': '1000base-t', + }, ] @@ -720,14 +760,19 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) rear_port_templates = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_port_templates) @@ -745,15 +790,28 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): rear_port=rear_port_templates[1] ), FrontPortTemplate( - device_type=devicetype, - name='Front Port Template 3', + module_type=moduletype, + name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port_templates[2] + rear_port=rear_port_templates[4] + ), + FrontPortTemplate( + module_type=moduletype, + name='Front Port Template 6', + type=PortTypeChoices.TYPE_8P8C, + rear_port=rear_port_templates[5] ), ) FrontPortTemplate.objects.bulk_create(front_port_templates) cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Front Port Template 3', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rear_port_templates[2].pk, + 'rear_port_position': 1, + }, { 'device_type': devicetype.pk, 'name': 'Front Port Template 4', @@ -762,17 +820,17 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): 'rear_port_position': 1, }, { - 'device_type': devicetype.pk, - 'name': 'Front Port Template 5', + 'module_type': moduletype.pk, + 'name': 'Front Port Template 7', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[4].pk, + 'rear_port': rear_port_templates[6].pk, 'rear_port_position': 1, }, { - 'device_type': devicetype.pk, - 'name': 'Front Port Template 6', + 'module_type': moduletype.pk, + 'name': 'Front Port Template 8', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[5].pk, + 'rear_port': rear_port_templates[7].pk, 'rear_port_position': 1, }, ] @@ -791,6 +849,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) rear_port_templates = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), @@ -811,10 +872,15 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Rear Port Template 6', 'type': PortTypeChoices.TYPE_8P8C, }, + { + 'module_type': moduletype.pk, + 'name': 'Rear Port Template 7', + 'type': PortTypeChoices.TYPE_8P8C, + }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2e2c3baf7..273ee6570 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -521,10 +521,26 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2'), - Site(name='Site 3', slug='site-3'), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -572,6 +588,20 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): ) RackReservation.objects.bulk_create(reservations) + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -698,6 +728,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'), DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), )) + # Assigned DeviceType must have parent subdevice_role + inventory_item = InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 1') + inventory_item.save() def test_model(self): params = {'model': ['Model 1', 'Model 2']} @@ -784,6 +817,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'module_bays': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_inventory_items(self): + params = {'inventory_items': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'inventory_items': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 70eb4b659..e17f94682 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1869,6 +1869,44 @@ class ModuleTestCase( self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(Interface.objects.filter(device=device).count(), 5) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_module_component_adoption(self): + self.add_permissions('dcim.add_module') + + interface_name = "Interface-1" + + # Add an interface to the ModuleType + module_type = ModuleType.objects.first() + InterfaceTemplate(module_type=module_type, name=interface_name).save() + + form_data = self.form_data.copy() + device = Device.objects.get(pk=form_data['device']) + + # Create an interface to be adopted + interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED) + interface.save() + + # Ensure that interface is created with no module + self.assertIsNone(interface.module) + + # Create a module with adopted components + form_data['module_bay'] = ModuleBay.objects.filter(device=device).first() + form_data['module_type'] = module_type + form_data['replicate_components'] = False + form_data['adopt_components'] = True + request = { + 'path': self._get_url('add'), + 'data': post_data(form_data), + } + + self.assertHttpStatus(self.client.post(**request), 302) + + # Re-retrieve interface to get new module id + interface.refresh_from_db() + + # Check that the Interface now has a module + self.assertIsNotNone(interface.module) + class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2622a1405..57e8b1c79 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -166,7 +166,7 @@ class RegionView(generic.ObjectView): sites = Site.objects.restrict(request.user, 'view').filter( region=instance ) - sites_table = tables.SiteTable(sites, exclude=('region',)) + sites_table = tables.SiteTable(sites, user=request.user, exclude=('region',)) sites_table.configure(request) return { @@ -251,7 +251,7 @@ class SiteGroupView(generic.ObjectView): sites = Site.objects.restrict(request.user, 'view').filter( group=instance ) - sites_table = tables.SiteTable(sites, exclude=('group',)) + sites_table = tables.SiteTable(sites, user=request.user, exclude=('group',)) sites_table.configure(request) return { @@ -435,7 +435,7 @@ class LocationView(generic.ObjectView): 'rack_count', cumulative=True ).filter(pk__in=location_ids).exclude(pk=instance.pk) - child_locations_table = tables.LocationTable(child_locations) + child_locations_table = tables.LocationTable(child_locations, user=request.user) child_locations_table.configure(request) nonracked_devices = Device.objects.filter( @@ -514,7 +514,9 @@ class RackRoleView(generic.ObjectView): role=instance ) - racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) + racks_table = tables.RackTable(racks, user=request.user, exclude=( + 'role', 'get_utilization', 'get_power_utilization', + )) racks_table.configure(request) return { @@ -767,7 +769,7 @@ class ManufacturerView(generic.ObjectView): manufacturer=instance ) - devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',)) + devicetypes_table = tables.DeviceTypeTable(device_types, user=request.user, exclude=('manufacturer',)) devicetypes_table.configure(request) return { @@ -1480,7 +1482,7 @@ class DeviceRoleView(generic.ObjectView): devices = Device.objects.restrict(request.user, 'view').filter( device_role=instance ) - devices_table = tables.DeviceTable(devices, exclude=('device_role',)) + devices_table = tables.DeviceTable(devices, user=request.user, exclude=('device_role',)) devices_table.configure(request) return { @@ -1544,7 +1546,7 @@ class PlatformView(generic.ObjectView): devices = Device.objects.restrict(request.user, 'view').filter( platform=instance ) - devices_table = tables.DeviceTable(devices, exclude=('platform',)) + devices_table = tables.DeviceTable(devices, user=request.user, exclude=('platform',)) devices_table.configure(request) return { diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index eed7f7603..cb317d6c7 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() + ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) class Meta: model = CustomField fields = [ 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', - 'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', - 'validation_regex', 'choices', 'created', 'last_updated', + 'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] def get_data_type(self, obj): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index f14368d3d..123fd2cd4 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet): ) +class CustomFieldVisibilityChoices(ChoiceSet): + + VISIBILITY_READ_WRITE = 'read-write' + VISIBILITY_READ_ONLY = 'read-only' + VISIBILITY_HIDDEN = 'hidden' + + CHOICES = ( + (VISIBILITY_READ_WRITE, 'Read/Write'), + (VISIBILITY_READ_ONLY, 'Read-only'), + (VISIBILITY_HIDDEN, 'Hidden'), + ) + + # # CustomLinks # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 467ae23af..b59e28018 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -62,7 +62,10 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description'] + fields = [ + 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight', + 'description', + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b722bd751..b1d8a6c21 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -37,6 +37,13 @@ class CustomFieldBulkEditForm(BulkEditForm): weight = forms.IntegerField( required=False ) + ui_visibility = forms.ChoiceField( + label="UI visibility", + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + initial='', + widget=StaticSelect() + ) nullable_fields = ('group_name', 'description',) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index dabf2f811..d9148a5c3 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm): choices=CustomFieldTypeChoices, help_text='Field data type (e.g. text, integer, etc.)' ) + object_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False, + help_text="Object type (for object or multi-object fields)" + ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, @@ -36,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', - 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', + 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', + 'validation_regex', 'ui_visibility', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index bb8028eec..4cf8b5e0a 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from extras.models import * +from extras.choices import CustomFieldVisibilityChoices __all__ = ( 'CustomFieldsMixin', @@ -42,8 +43,18 @@ class CustomFieldsMixin: Append form fields for all CustomFields assigned to this object type. """ for customfield in self._get_custom_fields(self._get_content_type()): + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + self.fields[field_name].disabled = True + if self.fields[field_name].help_text: + self.fields[field_name].help_text += '
' + self.fields[field_name].help_text += ' ' \ + 'Field is set to read-only.' + # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 1710ecb89..aaeb45dbe 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -32,7 +32,7 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')), + ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), @@ -56,6 +56,12 @@ class CustomFieldFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + ui_visibility = forms.ChoiceField( + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + label=_('UI visibility'), + widget=StaticSelect() + ) class CustomLinkFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index b07853f86..ab423e2fb 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -43,7 +43,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): ('Custom Field', ( 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', )), - ('Behavior', ('filter_logic',)), + ('Behavior', ('filter_logic', 'ui_visibility')), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) @@ -58,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): widgets = { 'type': StaticSelect(), 'filter_logic': StaticSelect(), + 'ui_visibility': StaticSelect(), } diff --git a/netbox/extras/management/commands/clearcache.py b/netbox/extras/management/commands/clearcache.py new file mode 100644 index 000000000..22843c490 --- /dev/null +++ b/netbox/extras/management/commands/clearcache.py @@ -0,0 +1,11 @@ +from django.core.cache import cache +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Command to clear the entire cache.""" + help = 'Clears the cache.' + + def handle(self, *args, **kwargs): + cache.clear() + self.stdout.write('Cache has been cleared.', ending="\n") diff --git a/netbox/extras/migrations/0075_customfield_ui_visibility.py b/netbox/extras/migrations/0075_customfield_ui_visibility.py new file mode 100644 index 000000000..29ee65516 --- /dev/null +++ b/netbox/extras/migrations/0075_customfield_ui_visibility.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-23 20:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0074_customfield_group_name'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='ui_visibility', + field=models.CharField(default='read-write', max_length=50), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 55caa4a70..c91f96c15 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -136,6 +136,13 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) + ui_visibility = models.CharField( + max_length=50, + choices=CustomFieldVisibilityChoices, + default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + verbose_name='UI visibility', + help_text='Specifies the visibility of custom field in the UI' + ) objects = CustomFieldManager() class Meta: diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4eacddbeb..29fab5be8 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -5,6 +5,7 @@ import os import pkgutil import sys import traceback +import threading from collections import OrderedDict import yaml @@ -13,11 +14,9 @@ from django.conf import settings from django.core.validators import RegexValidator from django.db import transaction from django.utils.functional import classproperty -from django_rq import job from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices -from extras.models import JobResult from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from utilities.exceptions import AbortTransaction @@ -42,6 +41,8 @@ __all__ = [ 'TextVar', ] +lock = threading.Lock() + # # Script variables @@ -305,9 +306,16 @@ class BaseScript: @classmethod def _get_vars(cls): vars = {} - for name, attr in cls.__dict__.items(): - if name not in vars and issubclass(attr.__class__, ScriptVariable): - vars[name] = attr + + # Iterate all base classes looking for ScriptVariables + for base_class in inspect.getmro(cls): + # When object is reached there's no reason to continue + if base_class is object: + break + + for name, attr in base_class.__dict__.items(): + if name not in vars and issubclass(attr.__class__, ScriptVariable): + vars[name] = attr # Order variables according to field_order field_order = getattr(cls.Meta, 'field_order', None) @@ -491,11 +499,14 @@ def get_scripts(use_names=False): # Iterate through all modules within the scripts path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): - # Remove cached module to ensure consistency with filesystem - if module_name in sys.modules: - del sys.modules[module_name] + # Use a lock as removing and loading modules is not thread safe + with lock: + # Remove cached module to ensure consistency with filesystem + if module_name in sys.modules: + del sys.modules[module_name] + + module = importer.find_module(module_name).load_module(module_name) - module = importer.find_module(module_name).load_module(module_name) if use_names and hasattr(module, 'name'): module_name = module.name module_scripts = OrderedDict() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 1a0f5d58a..540034696 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -28,12 +28,13 @@ class CustomFieldTable(NetBoxTable): ) content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() + ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', 'created', 'last_updated', + 'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ea3a952d6..936213cbf 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -36,13 +36,15 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'default': None, 'weight': 200, 'required': True, + 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, } cls.csv_data = ( - 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', - 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', - 'field5,Field 5,integer,dcim.site,100,exact,,1,100,', - 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', + 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write', + 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write', + 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write', + 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write', ) cls.bulk_edit_data = { diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3fa1bcc7e..ea5c37f91 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -360,7 +360,7 @@ class IPAddressSerializer(NetBoxModelSerializer): ) assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) - nat_outside = NestedIPAddressSerializer(required=False, read_only=True) + nat_outside = NestedIPAddressSerializer(many=True, read_only=True) class Meta: model = IPAddress @@ -369,7 +369,6 @@ class IPAddressSerializer(NetBoxModelSerializer): 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family', 'nat_outside'] @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_assigned_object(self, obj): diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 152d8b726..a364d3c6a 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -91,7 +91,7 @@ class IPAddressRoleChoices(ChoiceSet): (ROLE_VRRP, 'VRRP', 'green'), (ROLE_HSRP, 'HSRP', 'green'), (ROLE_GLBP, 'GLBP', 'green'), - (ROLE_CARP, 'CARP'), 'green', + (ROLE_CARP, 'CARP', 'green'), ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 53c589bb3..d9cf6eefc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -460,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): field_name='address', lookup_expr='family' ) - parent = django_filters.CharFilter( + parent = MultiValueCharFilter( method='search_by_parent', label='Parent prefix', ) @@ -567,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) def search_by_parent(self, queryset, name, value): - value = value.strip() if not value: return queryset - try: - query = str(netaddr.IPNetwork(value.strip()).cidr) - return queryset.filter(address__net_host_contained=query) - except (AddrFormatError, ValueError): - return queryset.none() + q = Q() + for prefix in value: + try: + query = str(netaddr.IPNetwork(prefix.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + return queryset.filter(q) def filter_address(self, queryset, name, value): try: @@ -681,11 +687,53 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): queryset=FHRPGroup.objects.all(), label='Group (ID)', ) + device = MultiValueCharFilter( + method='filter_device', + field_name='name', + label='Device (name)', + ) + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label='Device (ID)', + ) + virtual_machine = MultiValueCharFilter( + method='filter_virtual_machine', + field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = MultiValueNumberFilter( + method='filter_virtual_machine', + field_name='pk', + label='Virtual machine (ID)', + ) class Meta: model = FHRPGroupAssignment fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{f'{name}__in': value}) + if not devices.exists(): + return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return queryset.filter( + Q(interface_type=ContentType.objects.get_for_model(Interface), interface_id__in=interface_ids) + ) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value}) + if not virtual_machines.exists(): + return queryset.none() + interface_ids = [] + for vm in virtual_machines: + interface_ids.extend(vm.interfaces.values_list('id', flat=True)) + return queryset.filter( + Q(interface_type=ContentType.objects.get_for_model(VMInterface), interface_id__in=interface_ids) + ) + class VLANGroupFilterSet(OrganizationalModelFilterSet): scope_type = ContentTypeFilter() diff --git a/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py new file mode 100644 index 000000000..63e93d137 --- /dev/null +++ b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.ipaddress'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 9aec0cff8..db662f49c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -507,16 +507,20 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): child_ranges.add(iprange.range) available_ips = prefix - child_ips - child_ranges - # IPv6, pool, or IPv4 /31-/32 sets are fully usable - if self.family == 6 or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31): + # IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable + if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31): return available_ips - # For "normal" IPv4 prefixes, omit first and last addresses - available_ips -= netaddr.IPSet([ - netaddr.IPAddress(self.prefix.first), - netaddr.IPAddress(self.prefix.last), - ]) - + if self.family == 4: + # For "normal" IPv4 prefixes, omit first and last addresses + available_ips -= netaddr.IPSet([ + netaddr.IPAddress(self.prefix.first), + netaddr.IPAddress(self.prefix.last), + ]) + else: + # For IPv6 prefixes, omit the Subnet-Router anycast address + # per RFC 4291 + available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)]) return available_ips def get_first_available_ip(self): @@ -809,7 +813,7 @@ class IPAddress(NetBoxModel): ct_field='assigned_object_type', fk_field='assigned_object_id' ) - nat_inside = models.OneToOneField( + nat_inside = models.ForeignKey( to='self', on_delete=models.SET_NULL, related_name='nat_outside', diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 244bcee8e..558631585 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -118,7 +118,7 @@ class ASNTable(NetBoxTable): url_params={'asn_id': 'pk'}, verbose_name='Provider Count' ) - sites = tables.ManyToManyColumn( + sites = columns.ManyToManyColumn( linkify_item=True, verbose_name='Sites' ) @@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(NetBoxTable): - prefix = tables.TemplateColumn( + prefix = columns.TemplateColumn( template_code=PREFIX_LINK, + export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) prefix_flat = tables.TemplateColumn( diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8c81a28c2..58d0a9aff 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable): linkify=True ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:servicetemplate_list' @@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable): order_by=('device', 'virtual_machine') ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:service_list' diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 4bb72dce2..d98fe889e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - params = {'parent': '10.0.0.0/24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'parent': '2001:db8::/64'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'parent': ['10.0.0.0/30', '2001:db8::/126']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_filter_address(self): # Check IPv4 and IPv6, with and without a mask @@ -1024,6 +1022,20 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'priority': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_device(self): + device = Device.objects.first() + params = {'device': [device.name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'device_id': [device.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_virtual_machine(self): + vm = VirtualMachine.objects.first() + params = {'virtual_machine': [vm.name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'virtual_machine_id': [vm.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANGroup.objects.all() diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index a664b34f4..09bc95799 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -185,6 +185,18 @@ class TestPrefix(TestCase): IPAddress.objects.create(address=IPNetwork('10.0.0.4/24')) self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24') + def test_get_first_available_ip_ipv6(self): + parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500::/64')) + self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500::1/64') + + def test_get_first_available_ip_ipv6_rfc3627(self): + parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:4::/126')) + self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:4::1/126') + + def test_get_first_available_ip_ipv6_rfc6164(self): + parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:5::/127')) + self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:5::/127') + def test_get_utilization_container(self): prefixes = ( Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 41bef2527..6682fc920 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -4,15 +4,15 @@ from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from circuits.models import Provider +from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet -from dcim.models import Interface, Site +from dcim.models import Interface, Site, Device from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VMInterface +from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables from .constants import * from .models import * @@ -158,10 +158,10 @@ class RIRView(generic.ObjectView): queryset = RIR.objects.all() def get_extra_context(self, request, instance): - aggregates = Aggregate.objects.restrict(request.user, 'view').filter( - rir=instance + aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate( + child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) - aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) + aggregates_table = tables.AggregateTable(aggregates, user=request.user, exclude=('rir', 'utilization')) aggregates_table.configure(request) return { @@ -221,12 +221,14 @@ class ASNView(generic.ObjectView): def get_extra_context(self, request, instance): # Gather assigned Sites sites = instance.sites.restrict(request.user, 'view') - sites_table = SiteTable(sites) + sites_table = SiteTable(sites, user=request.user) sites_table.configure(request) # Gather assigned Providers - providers = instance.providers.restrict(request.user, 'view') - providers_table = ProviderTable(providers) + providers = instance.providers.restrict(request.user, 'view').annotate( + count_circuits=count_related(Circuit, 'provider') + ) + providers_table = ProviderTable(providers, user=request.user) providers_table.configure(request) return { @@ -366,7 +368,7 @@ class RoleView(generic.ObjectView): role=instance ) - prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) + prefixes_table = tables.PrefixTable(prefixes, user=request.user, exclude=('role', 'utilization')) prefixes_table.configure(request) return { @@ -585,7 +587,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', + 'vrf', 'tenant', ) def get_extra_context(self, request, instance): @@ -674,11 +676,26 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) + # Find services belonging to the IP + service_filter = Q(ipaddresses=instance) + + # Find services listening on all IPs on the assigned device/vm + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object + + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + + services = Service.objects.restrict(request.user, 'view').filter(service_filter) + return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, 'more_duplicate_ips': duplicate_ips.count() > 10, 'related_ips_table': related_ips_table, + 'services': services, } @@ -805,7 +822,7 @@ class VLANGroupView(generic.ObjectView): vlans_count = vlans.count() vlans = add_available_vlans(vlans, vlan_group=instance) - vlans_table = tables.VLANTable(vlans, exclude=('group',)) + vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',)) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') vlans_table.configure(request) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 6367d6d70..a13e8d192 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -39,6 +39,7 @@ AUTH_BACKEND_ATTRS = { 'keycloak': ('Keycloak', None), 'microsoft-graph': ('Microsoft Graph', 'microsoft'), 'okta': ('Okta', None), + 'okta-openidconnect': ('Okta (OIDC)', None), 'salesforce-oauth2': ('Salesforce', 'salesforce'), } diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index c82749e3f..ad0dcc7c3 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300 # this setting is derived from the installed location. # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' +# The name to use for the csrf token cookie. +CSRF_COOKIE_NAME = 'csrftoken' + # The name to use for the session cookie. SESSION_COOKIE_NAME = 'sessionid' diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e054dc9da..8ca0d98c1 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,32 +1,24 @@ from collections import OrderedDict from typing import Dict -from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet +import circuits.filtersets +import circuits.tables +import dcim.filtersets +import dcim.tables +import ipam.filtersets +import ipam.tables +import tenancy.filtersets +import tenancy.tables +import virtualization.filtersets +import virtualization.tables from circuits.models import Circuit, ProviderNetwork, Provider -from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable -from dcim.filtersets import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet, - PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet, -) from dcim.models import ( Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, ) -from dcim.tables import ( - CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable, - RackReservationTable, SiteTable, VirtualChassisTable, -) -from ipam.filtersets import ( - AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet, -) -from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF -from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable -from tenancy.filtersets import ContactFilterSet, TenantFilterSet +from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF from tenancy.models import Contact, Tenant, ContactAssignment -from tenancy.tables import ContactTable, TenantTable from utilities.utils import count_related -from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine -from virtualization.tables import ClusterTable, VirtualMachineTable SEARCH_MAX_RESULTS = 15 @@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict( 'queryset': Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ), - 'filterset': ProviderFilterSet, - 'table': ProviderTable, + 'filterset': circuits.filtersets.ProviderFilterSet, + 'table': circuits.tables.ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' ), - 'filterset': CircuitFilterSet, - 'table': CircuitTable, + 'filterset': circuits.filtersets.CircuitFilterSet, + 'table': circuits.tables.CircuitTable, 'url': 'circuits:circuit_list', }), ('providernetwork', { 'queryset': ProviderNetwork.objects.prefetch_related('provider'), - 'filterset': ProviderNetworkFilterSet, - 'table': ProviderNetworkTable, + 'filterset': circuits.filtersets.ProviderNetworkFilterSet, + 'table': circuits.tables.ProviderNetworkTable, 'url': 'circuits:providernetwork_list', }), ) @@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict( ( ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), - 'filterset': SiteFilterSet, - 'table': SiteTable, + 'filterset': dcim.filtersets.SiteFilterSet, + 'table': dcim.tables.SiteTable, 'url': 'dcim:site_list', }), ('rack', { 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( device_count=count_related(Device, 'rack') ), - 'filterset': RackFilterSet, - 'table': RackTable, + 'filterset': dcim.filtersets.RackFilterSet, + 'table': dcim.tables.RackTable, 'url': 'dcim:rack_list', }), ('rackreservation', { 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), - 'filterset': RackReservationFilterSet, - 'table': RackReservationTable, + 'filterset': dcim.filtersets.RackReservationFilterSet, + 'table': dcim.tables.RackReservationTable, 'url': 'dcim:rackreservation_list', }), ('location', { @@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict( 'rack_count', cumulative=True ).prefetch_related('site'), - 'filterset': LocationFilterSet, - 'table': LocationTable, + 'filterset': dcim.filtersets.LocationFilterSet, + 'table': dcim.tables.LocationTable, 'url': 'dcim:location_list', }), ('devicetype', { 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ), - 'filterset': DeviceTypeFilterSet, - 'table': DeviceTypeTable, + 'filterset': dcim.filtersets.DeviceTypeFilterSet, + 'table': dcim.tables.DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { 'queryset': Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), - 'filterset': DeviceFilterSet, - 'table': DeviceTable, + 'filterset': dcim.filtersets.DeviceFilterSet, + 'table': dcim.tables.DeviceTable, 'url': 'dcim:device_list', }), ('moduletype', { 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Module, 'module_type') ), - 'filterset': ModuleTypeFilterSet, - 'table': ModuleTypeTable, + 'filterset': dcim.filtersets.ModuleTypeFilterSet, + 'table': dcim.tables.ModuleTypeTable, 'url': 'dcim:moduletype_list', }), ('module', { 'queryset': Module.objects.prefetch_related( 'module_type__manufacturer', 'device', 'module_bay', ), - 'filterset': ModuleFilterSet, - 'table': ModuleTable, + 'filterset': dcim.filtersets.ModuleFilterSet, + 'table': dcim.tables.ModuleTable, 'url': 'dcim:module_list', }), ('virtualchassis', { 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( member_count=count_related(Device, 'virtual_chassis') ), - 'filterset': VirtualChassisFilterSet, - 'table': VirtualChassisTable, + 'filterset': dcim.filtersets.VirtualChassisFilterSet, + 'table': dcim.tables.VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), ('cable', { 'queryset': Cable.objects.all(), - 'filterset': CableFilterSet, - 'table': CableTable, + 'filterset': dcim.filtersets.CableFilterSet, + 'table': dcim.tables.CableTable, 'url': 'dcim:cable_list', }), ('powerfeed', { 'queryset': PowerFeed.objects.all(), - 'filterset': PowerFeedFilterSet, - 'table': PowerFeedTable, + 'filterset': dcim.filtersets.PowerFeedFilterSet, + 'table': dcim.tables.PowerFeedTable, 'url': 'dcim:powerfeed_list', }), ) @@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict( ( ('vrf', { 'queryset': VRF.objects.prefetch_related('tenant'), - 'filterset': VRFFilterSet, - 'table': VRFTable, + 'filterset': ipam.filtersets.VRFFilterSet, + 'table': ipam.tables.VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { 'queryset': Aggregate.objects.prefetch_related('rir'), - 'filterset': AggregateFilterSet, - 'table': AggregateTable, + 'filterset': ipam.filtersets.AggregateFilterSet, + 'table': ipam.tables.AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), - 'filterset': PrefixFilterSet, - 'table': PrefixTable, + 'filterset': ipam.filtersets.PrefixFilterSet, + 'table': ipam.tables.PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), - 'filterset': IPAddressFilterSet, - 'table': IPAddressTable, + 'filterset': ipam.filtersets.IPAddressFilterSet, + 'table': ipam.tables.IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), - 'filterset': VLANFilterSet, - 'table': VLANTable, + 'filterset': ipam.filtersets.VLANFilterSet, + 'table': ipam.tables.VLANTable, 'url': 'ipam:vlan_list', }), ('asn', { 'queryset': ASN.objects.prefetch_related('rir', 'tenant'), - 'filterset': ASNFilterSet, - 'table': ASNTable, + 'filterset': ipam.filtersets.ASNFilterSet, + 'table': ipam.tables.ASNTable, 'url': 'ipam:asn_list', }), + ('service', { + 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), + 'filterset': ipam.filtersets.ServiceFilterSet, + 'table': ipam.tables.ServiceTable, + 'url': 'ipam:service_list', + }), ) ) @@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict( ( ('tenant', { 'queryset': Tenant.objects.prefetch_related('group'), - 'filterset': TenantFilterSet, - 'table': TenantTable, + 'filterset': tenancy.filtersets.TenantFilterSet, + 'table': tenancy.tables.TenantTable, 'url': 'tenancy:tenant_list', }), ('contact', { 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( assignment_count=count_related(ContactAssignment, 'contact')), - 'filterset': ContactFilterSet, - 'table': ContactTable, + 'filterset': tenancy.filtersets.ContactFilterSet, + 'table': tenancy.tables.ContactTable, 'url': 'tenancy:contact_list', }), ) @@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict( device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ), - 'filterset': ClusterFilterSet, - 'table': ClusterTable, + 'filterset': virtualization.filtersets.ClusterFilterSet, + 'table': virtualization.tables.ClusterTable, 'url': 'virtualization:cluster_list', }), ('virtualmachine', { 'queryset': VirtualMachine.objects.prefetch_related( 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', ), - 'filterset': VirtualMachineFilterSet, - 'table': VirtualMachineTable, + 'filterset': virtualization.filtersets.VirtualMachineFilterSet, + 'table': virtualization.tables.VirtualMachineTable, 'url': 'virtualization:virtualmachine_list', }), ) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index c842c6c06..0e232af1d 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -61,6 +61,8 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ + tags = None # Temporary fix in lieu of tag import support (see #9158) + def _get_form_field(self, customfield): return customfield.to_form_field(for_csv_import=True) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 4bd1b0e9c..817da526b 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -9,7 +9,7 @@ from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager -from extras.choices import ObjectChangeActionChoices +from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import register_features from netbox.signals import post_clean from utilities.utils import serialize_object @@ -100,7 +100,7 @@ class CustomFieldsMixin(models.Model): """ return self.custom_field_data - def get_custom_fields(self): + def get_custom_fields(self, omit_hidden=False): """ Return a dictionary of custom fields for a single object in the form `{field: value}`. @@ -114,6 +114,10 @@ class CustomFieldsMixin(models.Model): data = {} for field in CustomField.objects.get_for_model(self): + # Skip fields that are hidden if 'omit_hidden' is set + if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + value = self.custom_field_data.get(field.name) data[field] = field.deserialize(value) @@ -121,10 +125,10 @@ class CustomFieldsMixin(models.Model): def get_custom_fields_by_group(self): """ - Return a dictionary of custom field/value mappings organized by group. + Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted. """ grouped_custom_fields = defaultdict(dict) - for cf, value in self.get_custom_fields().items(): + for cf, value in self.get_custom_fields(omit_hidden=True).items(): grouped_custom_fields[cf.group_name][cf] = value return dict(grouped_custom_fields) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0db41373f..f9f4728a9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,3 +1,4 @@ +import hashlib import importlib import logging import os @@ -8,9 +9,11 @@ import sys import warnings from urllib.parse import urlsplit +import sentry_sdk from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from sentry_sdk.integrations.django import DjangoIntegration from netbox.config import PARAMS @@ -40,6 +43,7 @@ if sys.version_info < (3, 8): f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})" ) +DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485' # # Configuration import @@ -68,6 +72,9 @@ DATABASE = getattr(configuration, 'DATABASE') REDIS = getattr(configuration, 'REDIS') SECRET_KEY = getattr(configuration, 'SECRET_KEY') +# Calculate a unique deployment ID from the secret key +DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] + # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) @@ -77,6 +84,7 @@ if BASE_PATH: CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) +CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') @@ -113,6 +121,11 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') +SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN) +SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) +SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) +SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) +SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') @@ -410,6 +423,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +TEST_RUNNER = "django_rich.test.RichRunner" + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( @@ -428,6 +443,36 @@ EXEMPT_PATHS = ( ) +# +# Sentry +# + +if SENTRY_ENABLED: + if not SENTRY_DSN: + raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.") + # If using the default DSN, force sampling rates + if SENTRY_DSN == DEFAULT_SENTRY_DSN: + SENTRY_SAMPLE_RATE = 1.0 + SENTRY_TRACES_SAMPLE_RATE = 0 + # Initialize the SDK + sentry_sdk.init( + dsn=SENTRY_DSN, + release=VERSION, + integrations=[DjangoIntegration()], + sample_rate=SENTRY_SAMPLE_RATE, + traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, + send_default_pii=True, + http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None, + https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None + ) + # Assign any configured tags + for k, v in SENTRY_TAGS.items(): + sentry_sdk.set_tag(k, v) + # If using the default DSN, append a unique deployment ID tag for error correlation + if SENTRY_DSN == DEFAULT_SENTRY_DSN: + sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID) + + # # Django social auth # diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index ba5583a2e..e82e8a1ea 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db.models import DateField, DateTimeField from django.template import Context, Template -from django.urls import NoReverseMatch, reverse +from django.urls import reverse from django.utils.formats import date_format from django.utils.safestring import mark_safe from django_tables2.columns import library @@ -27,6 +27,7 @@ __all__ = ( 'CustomLinkColumn', 'LinkedCountColumn', 'MarkdownColumn', + 'ManyToManyColumn', 'MPTTColumn', 'TagColumn', 'TemplateColumn', @@ -35,6 +36,10 @@ __all__ = ( ) +# +# Django-tables2 overrides +# + @library.register class DateColumn(tables.DateColumn): """ @@ -42,7 +47,6 @@ class DateColumn(tables.DateColumn): tables and null when exporting data. It is registered in the tables library to use this class instead of the default, making this behavior consistent in all fields of type DateField. """ - def value(self, value): return value @@ -59,7 +63,6 @@ class DateTimeColumn(tables.DateTimeColumn): tables and null when exporting data. It is registered in the tables library to use this class instead of the default, making this behavior consistent in all fields of type DateTimeField. """ - def value(self, value): if value: return date_format(value, format="SHORT_DATETIME_FORMAT") @@ -71,6 +74,52 @@ class DateTimeColumn(tables.DateTimeColumn): return cls(**kwargs) +class ManyToManyColumn(tables.ManyToManyColumn): + """ + Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data. + """ + def value(self, value): + items = [self.transform(item) for item in self.filter(value)] + return self.separator.join(items) + + +class TemplateColumn(tables.TemplateColumn): + """ + Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value + is an empty string. + """ + PLACEHOLDER = mark_safe('—') + + def __init__(self, export_raw=False, **kwargs): + """ + Args: + export_raw: If true, data export returns the raw field value rather than the rendered template. (Default: + False) + """ + super().__init__(**kwargs) + self.export_raw = export_raw + + def render(self, *args, **kwargs): + ret = super().render(*args, **kwargs) + if not ret.strip(): + return self.PLACEHOLDER + return ret + + def value(self, **kwargs): + if self.export_raw: + # Skip template rendering and export raw value + return kwargs.get('value') + + ret = super().value(**kwargs) + if ret == self.PLACEHOLDER: + return '' + return ret + + +# +# Custom columns +# + class ToggleColumn(tables.CheckBoxColumn): """ Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. @@ -112,26 +161,6 @@ class BooleanColumn(tables.Column): return str(value) -class TemplateColumn(tables.TemplateColumn): - """ - Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value - is an empty string. - """ - PLACEHOLDER = mark_safe('—') - - def render(self, *args, **kwargs): - ret = super().render(*args, **kwargs) - if not ret.strip(): - return self.PLACEHOLDER - return ret - - def value(self, **kwargs): - ret = super().value(**kwargs) - if ret == self.PLACEHOLDER: - return '' - return ret - - @dataclass class ActionsItem: title: str @@ -176,32 +205,35 @@ class ActionsColumn(tables.Column): model = table.Meta.model request = getattr(table, 'context', {}).get('request') url_appendix = f'?return_url={request.path}' if request else '' + html = '' + # Compile actions menu links = [] user = getattr(request, 'user', AnonymousUser()) for action, attrs in self.actions.items(): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append(f'
  • ' - f' {attrs.title}
  • ') - - if not links: - return '' - - menu = f'' \ - f'' \ - f'' \ - f'' + links.append( + f'
  • ' + f' {attrs.title}
  • ' + ) + if links: + html += ( + f'' + f'' + f'' + f'' + ) # Render any extra buttons from template code if self.extra_buttons: template = Template(self.extra_buttons) context = getattr(table, "context", Context()) context.update({'record': record}) - menu = template.render(context) + menu + html = template.render(context) + html - return mark_safe(menu) + return mark_safe(html) class ChoiceFieldColumn(tables.Column): diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8c5fb039c..38399b5fe 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink +from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -97,7 +98,7 @@ class BaseTable(tables.Table): break if prefetch_path: prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + self.data.data = self.data.data.prefetch_related(*prefetch_fields) def _get_columns(self, visible=True): columns = [] @@ -178,7 +179,10 @@ class NetBoxTable(BaseTable): # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(content_types=content_type) + custom_fields = CustomField.objects.filter( + content_types=content_type + ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) + extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index e76efe0fe..e8ee4b7b6 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -100,4 +100,5 @@ urlpatterns = [ path('{}'.format(settings.BASE_PATH), include(_patterns)) ] +handler404 = 'netbox.views.handler_404' handler500 = 'netbox.views.server_error' diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index fad347c36..f159ee637 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -2,7 +2,6 @@ import platform import sys from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db.models import F from django.http import HttpResponseServerError @@ -11,9 +10,10 @@ from django.template import loader from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse from django.views.decorators.csrf import requires_csrf_token -from django.views.defaults import ERROR_500_TEMPLATE_NAME +from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.generic import View from packaging import version +from sentry_sdk import capture_message from circuits.models import Circuit, Provider from dcim.models import ( @@ -190,13 +190,21 @@ class StaticMediaFailureView(View): """ Display a user-friendly error message with troubleshooting tips when a static media file fails to load. """ - def get(self, request): return render(request, 'media_failure.html', { 'filename': request.GET.get('filename') }) +def handler_404(request, exception): + """ + Wrap Django's default 404 handler to enable Sentry reporting. + """ + capture_message("Page not found", level="error") + + return page_not_found(request, exception) + + @requires_csrf_token def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): """ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 1a7581a6c..acd1abbf2 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 3eeaf8b3d..ebf3e0a39 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/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index be8a86631..88b35a0e9 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -570,8 +570,9 @@ export class APISelect { * additional paginated options. */ private handleScroll(): void { + // Floor scrollTop as chrome can return fractions on some zoom levels. const atBottom = - this.slim.slim.list.scrollTop + this.slim.slim.list.offsetHeight === + Math.floor(this.slim.slim.list.scrollTop) + this.slim.slim.list.offsetHeight === this.slim.slim.list.scrollHeight; if (this.atBottom && !atBottom) { diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index f96854ca8..afc306bd4 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %} +
    {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
    diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index eb27b4ab0..5f244cdc7 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
    diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 672cb192a..5e33bdae0 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
    diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 816d193de..0d0f9577c 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
    diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index d7f8dff55..22f6d8be5 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -4,44 +4,46 @@ {% load static %} {% block content %} - - {% csrf_token %} -
    -
    -
    - -
    -
    -
    -
    - {% if request.user.is_authenticated %} - - {% endif %} - - -
    +
    +
    +
    +
    +
    +
    + {% if request.user.is_authenticated %} + + {% endif %} + + +
    +
    +
    + + + {% csrf_token %} +
    diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index c6452cf78..18a0712f3 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
    diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html index e9c672b57..fc1c9a60d 100644 --- a/netbox/templates/dcim/device/modulebays.html +++ b/netbox/templates/dcim/device/modulebays.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %}
    diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index 19d8298af..d312fbbd0 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
    diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index 82c088392..cf71e81ba 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %}
    diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index 868def466..73341990f 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -4,9 +4,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %}
    diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index f44c3b9d1..6d75aee85 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -32,7 +32,11 @@ Circuit - {{ termination.|linkify }} ({{ termination }}) + {{ termination.circuit|linkify }} + + + Termination + {{ termination }} {% endif %} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 130cd046f..f2dac38f2 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -18,25 +18,25 @@
    diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 4683b775b..1ff9f2e9a 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -15,74 +15,70 @@ {% block content %}
    -
    -
    - Virtual Chassis -
    -
    - - - - - - - - - -
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    -
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    Virtual Chassis
    +
    + + + + + + + + + +
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    -
    - Members -
    -
    - - - - - - - - {% for vc_member in members %} - - - - - - - {% endfor %} -
    DevicePositionMasterPriority
    - {{ vc_member|linkify }} - - {% badge vc_member.vc_position show_empty=True %} - - {% if object.master == vc_member %} - {% checkmark True %} - {% endif %} - - {{ vc_member.vc_priority|placeholder }} -
    -
    - {% if perms.dcim.change_virtualchassis %} - - {% endif %} +
    +
    Members
    +
    + + + + + + + + {% for vc_member in members %} + + + + + + + {% endfor %} +
    DevicePositionMasterPriority
    + {{ vc_member|linkify }} + + {% badge vc_member.vc_position show_empty=True %} + + {% if object.master == vc_member %} + {% checkmark True %} + {% endif %} + + {{ vc_member.vc_priority|placeholder }} +
    - {% plugin_right_page object %} + {% if perms.dcim.change_virtualchassis %} + + {% endif %} +
    + {% plugin_right_page object %}
    -
    - {% plugin_full_width_page object %} -
    +
    + {% plugin_full_width_page object %} +
    {% endblock %} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 0d9856938..aca0b5012 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -25,7 +25,10 @@ Type - {{ object.get_type_display }} + + {{ object.get_type_display }} + {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} + Description @@ -39,6 +42,14 @@ Weight {{ object.weight }} + + Filter Logic + {{ object.get_filter_logic_display }} + + + UI Visibility + {{ object.get_ui_visibility_display }} +
    @@ -62,10 +73,6 @@ {% endif %} - - Filter Logic - {{ object.get_filter_logic_display }} -
    diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index ab730410e..2a7003b8d 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -43,7 +43,7 @@
    {{ context.weight }}
    - {{ context|linkify:"name" }}"> + {{ context|linkify:"name" }} {% if context.description %}
    {{ context.description }} {% endif %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 25ccbf181..a12ec9277 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -13,7 +13,7 @@ NetBox v{{ new_release.version }} is available.
    - Upgrade Instructions + Upgrade Instructions
    diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index bb574ebf0..d1b48429a 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -12,9 +12,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
    diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7867e829b..7981ea0fe 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -91,8 +91,14 @@ - NAT (outside) - {{ object.nat_outside|linkify|placeholder }} + Outside NAT IPs + + {% for ip in object.nat_outside.all %} + {{ ip|linkify }}
    + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
    @@ -128,6 +134,24 @@
    {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
    +
    +
    + Services +
    +
    + {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
    + {% else %} +
    + None +
    + {% endif %} +
    +
    {% plugin_right_page object %}
    diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index a13910406..d9ac77fd0 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -10,9 +10,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
    diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index b26375ebe..d734b825f 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -10,9 +10,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
    diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index b262be821..268c290a1 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -10,9 +10,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %}
    diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 039b1ca3e..5d42596ba 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -12,9 +12,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
    diff --git a/netbox/templates/ipam/vlan/interfaces.html b/netbox/templates/ipam/vlan/interfaces.html index 51df17edc..5707d5364 100644 --- a/netbox/templates/ipam/vlan/interfaces.html +++ b/netbox/templates/ipam/vlan/interfaces.html @@ -2,9 +2,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %}
    {% include 'htmx/table.html' %} diff --git a/netbox/templates/ipam/vlan/vminterfaces.html b/netbox/templates/ipam/vlan/vminterfaces.html index f12e9df86..ef4a0730a 100644 --- a/netbox/templates/ipam/vlan/vminterfaces.html +++ b/netbox/templates/ipam/vlan/vminterfaces.html @@ -2,9 +2,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %}
    {% include 'htmx/table.html' %} diff --git a/netbox/templates/media_failure.html b/netbox/templates/media_failure.html index e3b7ef309..971b3cbc5 100644 --- a/netbox/templates/media_failure.html +++ b/netbox/templates/media_failure.html @@ -29,7 +29,7 @@
  • The HTTP service (e.g. nginx or Apache) is configured to serve files from the STATIC_ROOT path. - Refer to the installation + Refer to the installation documentation for further guidance.
      {% if request.user.is_staff or request.user.is_superuser %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e4c1db006..52c13e1aa 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -77,6 +77,10 @@

      {{ stats.prefix_count }}

      Prefixes

  • +
    +

    {{ stats.iprange_count }}

    +

    IP Ranges

    +

    {{ stats.ipaddress_count }}

    IP addresses

    diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html index 075f34c7e..cb4a1b3ee 100644 --- a/netbox/templates/virtualization/cluster/devices.html +++ b/netbox/templates/virtualization/cluster/devices.html @@ -3,9 +3,10 @@ {% load render_table from django_tables2 %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
    {% include 'htmx/table.html' %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html index 8b4191259..953d9f940 100644 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -3,9 +3,10 @@ {% load render_table from django_tables2 %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
    {% include 'htmx/table.html' %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0dec4968c..2831a452a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -78,31 +78,39 @@
    -
    - Cluster -
    +
    Cluster
    + + + + + + + +
    Site + {{ object.site|linkify|placeholder }} +
    Cluster {% if object.cluster.group %} {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster|linkify }} + {{ object.cluster|linkify|placeholder }}
    Cluster Type {{ object.cluster.type }}
    Device + {{ object.device|linkify|placeholder }} +
    -
    - Resources -
    +
    Resources
    diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index de657b3b3..e3ffb84d4 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -3,9 +3,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %}
    diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 8749dc63f..a2286efed 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -97,7 +97,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): object = serializers.SerializerMethodField(read_only=True) contact = NestedContactSerializer() role = NestedContactRoleSerializer(required=False, allow_null=True) - priority = ChoiceField(choices=ContactPriorityChoices, required=False) + priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default='') class Meta: model = ContactAssignment diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 8ca4ae29c..dd14a412b 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet): queryset=ContactRole.objects.all(), label='Contact Role' ) + contact_group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='contacts__contact__group', + lookup_expr='in', + label='Contact group', + ) # diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 15d7773b7..02589d733 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( (None, ('q', 'tag', 'group_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 5dcad1d43..5e78bc540 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form): required=False, label=_('Contact Role') ) + contact_group = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Contact Group') + ) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 17abc5a5b..234dc2ad7 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable): ) contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', - url_params={'role_id': 'pk'}, + url_params={'group_id': 'pk'}, verbose_name='Contacts' ) tags = columns.TagColumn( diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index 5577d90e0..8f18423be 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -38,7 +38,7 @@ class TenantTable(NetBoxTable): linkify=True ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 195871813..f6f95b123 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster @@ -35,7 +35,7 @@ class TenantGroupView(generic.ObjectView): tenants = Tenant.objects.restrict(request.user, 'view').filter( group=instance ) - tenants_table = tables.TenantTable(tenants, exclude=('group',)) + tenants_table = tables.TenantTable(tenants, user=request.user, exclude=('group',)) tenants_table.configure(request) return { @@ -104,8 +104,9 @@ class TenantView(generic.ObjectView): 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), @@ -184,7 +185,7 @@ class ContactGroupView(generic.ObjectView): contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance ) - contacts_table = tables.ContactTable(contacts, exclude=('group',)) + contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',)) contacts_table.configure(request) return { @@ -250,7 +251,7 @@ class ContactRoleView(generic.ObjectView): contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( role=instance ) - contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) contacts_table.columns.hide('role') contacts_table.configure(request) @@ -307,7 +308,7 @@ class ContactView(generic.ObjectView): contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( contact=instance ) - assignments_table = tables.ContactAssignmentTable(contact_assignments) + assignments_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) assignments_table.columns.hide('contact') assignments_table.configure(request) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index df9af0f19..51e0c5b26 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -28,6 +28,11 @@ class NestedUserSerializer(WritableNestedSerializer): model = User fields = ['id', 'url', 'display', 'username'] + def get_display(self, obj): + if full_name := obj.get_full_name(): + return f"{obj.username} ({full_name})" + return obj.username + class NestedTokenSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 4b1f5bff3..b48a14d5c 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -45,6 +45,11 @@ class UserSerializer(ValidatedModelSerializer): return user + def get_display(self, obj): + if full_name := obj.get_full_name(): + return f"{obj.username} ({full_name})" + return obj.username + class GroupSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail') diff --git a/netbox/users/models.py b/netbox/users/models.py index 40ff78b98..5372353c0 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -176,11 +176,11 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) -def create_userconfig(instance, created, **kwargs): +def create_userconfig(instance, created, raw=False, **kwargs): """ - Automatically create a new UserConfig when a new User is created. + Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture. """ - if created: + if created and not raw: config = get_config() UserConfig(user=instance, data=config.DEFAULT_USER_PREFERENCES).save() diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index f83fc6a7c..68e71610c 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -88,7 +88,12 @@ class DynamicModelChoiceMixin: # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() + if data: + # When the field is multiple choice pass the data as a list if it's not already + if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list: + data = [data] + field_name = getattr(self, 'to_field_name') or 'pk' filter = self.filter(field_name=field_name) try: @@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip widget = widgets.APISelectMultiple def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ + value = value or [] + + # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] return [None, *value] + return super().clean(value) diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 4a3db0a3c..44ad5ac47 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -150,15 +150,15 @@ def render_markdown(value): value = strip_tags(value) # Sanitize Markdown links - pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)' + pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) # Sanitize Markdown reference links - pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)' + pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)' value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown - html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()]) + html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) # If the string is not empty wrap it in rendered-markdown to style tables if html: diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 466b5e22b..52ccd002d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -34,15 +34,16 @@ def post_data(data): return ret -def create_test_device(name): +def create_test_device(name, site=None, **attrs): """ Convenience method for creating a Device (e.g. for component testing). """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + if site is None: + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs) return device diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index afdf50b96..bd01b5533 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,7 +1,9 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import ( + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, +) from dcim.choices import InterfaceModeChoices from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer from ipam.models import VLAN @@ -45,6 +47,7 @@ class ClusterSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) + status = ChoiceField(choices=ClusterStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) @@ -53,8 +56,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -65,8 +68,9 @@ class ClusterSerializer(NetBoxModelSerializer): class VirtualMachineSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) - site = NestedSiteSerializer(read_only=True) - cluster = NestedClusterSerializer() + site = NestedSiteSerializer(required=False, allow_null=True) + cluster = NestedClusterSerializer(required=False, allow_null=True) + device = NestedDeviceSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) @@ -77,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -89,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 665114881..d2a90ae34 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 693e53df6..2cf6357e1 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -1,6 +1,28 @@ from utilities.choices import ChoiceSet +# +# Clusters +# + +class ClusterStatusChoices(ChoiceSet): + key = 'Cluster.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] + + # # VirtualMachines # diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 5a2aa8b42..00d3e2313 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet @@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label='Cluster type (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=ClusterStatusChoices, + null_value=None + ) class Meta: model = Cluster @@ -146,39 +150,48 @@ class VirtualMachineFilterSet( to_field_name='name', label='Cluster', ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device', + ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', label='Site group (ID)', ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', to_field_name='slug', label='Site group (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d5d33df2a..88dee3978 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import VLAN, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant @@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=ClusterGroup.objects.all(), required=False ) + status = forms.ChoiceField( + choices=add_blank_choice(ClusterStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'tenant',)), + (None, ('type', 'group', 'status', 'tenant',)), ('Site', ('region', 'site_group', 'site',)), ) nullable_fields = ( @@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect(), ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' + } ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( @@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('cluster', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) @@ -223,8 +243,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): # See 5643 if 'pk' in self.initial: site = None - interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( - 'virtual_machine__cluster__site' + interfaces = VMInterface.objects.filter( + pk__in=self.initial['pk'] + ).prefetch_related( + 'virtual_machine__site' ) # Check interface sites. First interface should set site, further interfaces will either continue the diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index eab6fc9e7..2d7ee52e2 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,5 +1,5 @@ from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Platform, Site +from dcim.models import Device, DeviceRole, Platform, Site from ipam.models import VRF from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant @@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm): required=False, help_text='Assigned cluster group' ) + status = CSVChoiceField( + choices=ClusterStatusChoices, + help_text='Operational status' + ) site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'comments') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): choices=VirtualMachineStatusChoices, help_text='Operational status' ) + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned site' + ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', + required=False, help_text='Assigned cluster' ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned device within cluster' + ) role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True @@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'comments', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 2f386e889..e15a76a43 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm from ipam.models import VRF from netbox.forms import NetBoxModelFilterSetForm @@ -29,16 +29,20 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm): class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) + fieldsets = ( + (None, ('q', 'tag')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), + ) class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('group_id', 'type_id')), + ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), @@ -50,6 +54,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi required=False, label=_('Region') ) + status = MultipleChoiceField( + choices=ClusterStatusChoices, + required=False + ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -83,11 +91,11 @@ class VirtualMachineFilterForm( model = VirtualMachine fieldsets = ( (None, ('q', 'tag')), - ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')), + ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), @@ -106,6 +114,11 @@ class VirtualMachineFilterForm( required=False, label=_('Cluster') ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 314b0bddf..cfafd7e39 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) + widgets = { + 'status': StaticSelect(), + } class ClusterAddDevicesForm(BootstrapMixin, forms.Form): @@ -161,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm): class VirtualMachineForm(TenancyForm, NetBoxModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -172,7 +179,15 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), query_params={ - 'group_id': '$cluster_group' + 'site_id': '$site', + 'group_id': '$cluster_group', + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' } ) role = DynamicModelChoiceField( @@ -193,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), + ('Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), @@ -203,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py new file mode 100644 index 000000000..e836bb914 --- /dev/null +++ b/netbox/virtualization/migrations/0030_cluster_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-19 19:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0029_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py new file mode 100644 index 000000000..85ea24455 --- /dev/null +++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ('virtualization', '0030_cluster_status'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'), + ), + migrations.AddField( + model_name='virtualmachine', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), + ), + migrations.AlterField( + model_name='virtualmachine', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'), + ), + ] diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py new file mode 100644 index 000000000..e9c52bfde --- /dev/null +++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def update_virtualmachines_site(apps, schema_editor): + """ + Automatically set the site for all virtual machines. + """ + VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') + + virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False) + for vm in virtual_machines: + vm.site = vm.cluster.site + VirtualMachine.objects.bulk_update(virtual_machines, ['site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0031_virtualmachine_site_device'), + ] + + operations = [ + migrations.RunPython( + code=update_virtualmachines_site, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 586bb8a9e..02560a962 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -119,6 +119,11 @@ class Cluster(NetBoxModel): blank=True, null=True ) + status = models.CharField( + max_length=50, + choices=ClusterStatusChoices, + default=ClusterStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -165,6 +170,9 @@ class Cluster(NetBoxModel): def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) + def get_status_color(self): + return ClusterStatusChoices.colors.get(self.status) + def clean(self): super().clean() @@ -187,10 +195,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) cluster = models.ForeignKey( to='virtualization.Cluster', on_delete=models.PROTECT, - related_name='virtual_machines' + related_name='virtual_machines', + blank=True, + null=True + ) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -276,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): objects = ConfigContextModelQuerySet.as_manager() clone_fields = [ - 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', + 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', ] class Meta: @@ -308,6 +332,28 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def clean(self): super().clean() + # Must be assigned to a site and/or cluster + if not self.site and not self.cluster: + raise ValidationError({ + 'cluster': f'A virtual machine must be assigned to a site and/or cluster.' + }) + + # Validate site for cluster & device + if self.cluster and self.cluster.site != self.site: + raise ValidationError({ + 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).' + }) + if self.device and self.device.site != self.site: + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).' + }) + + # Validate assigned cluster device + if self.device and self.device not in self.cluster.devices.all(): + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).' + }) + # Validate primary IP addresses interfaces = self.interfaces.all() for field in ['primary_ip4', 'primary_ip6']: @@ -336,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): else: return None - @property - def site(self): - return self.cluster.site - # # Interfaces diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index 893d3c641..dfcae052a 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -14,7 +14,9 @@ class ClusterTypeTable(NetBoxTable): name = tables.Column( linkify=True ) - cluster_count = tables.Column( + cluster_count = columns.LinkedCountColumn( + viewname='virtualization:cluster_list', + url_params={'type_id': 'pk'}, verbose_name='Clusters' ) tags = columns.TagColumn( @@ -33,10 +35,12 @@ class ClusterGroupTable(NetBoxTable): name = tables.Column( linkify=True ) - cluster_count = tables.Column( + cluster_count = columns.LinkedCountColumn( + viewname='virtualization:cluster_list', + url_params={'group_id': 'pk'}, verbose_name='Clusters' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -62,6 +66,7 @@ class ClusterTable(NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = tables.Column( linkify=True ) @@ -79,7 +84,7 @@ class ClusterTable(NetBoxTable): verbose_name='VMs' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -89,7 +94,7 @@ class ClusterTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count', + 'contacts', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') + default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index d5017eb53..0fe2571b1 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -30,9 +30,15 @@ class VirtualMachineTable(NetBoxTable): linkify=True ) status = columns.ChoiceFieldColumn() + site = tables.Column( + linkify=True + ) cluster = tables.Column( linkify=True ) + device = tables.Column( + linkify=True + ) role = columns.ColoredLabelColumn() tenant = TenantColumn() comments = columns.MarkdownColumn() @@ -56,11 +62,11 @@ class VirtualMachineTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', + 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', ) @@ -78,7 +84,7 @@ class VMInterfaceTable(BaseInterfaceTable): vrf = tables.Column( linkify=True ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index f6c07fa54..b2ae68860 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,8 +2,10 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices +from dcim.models import Site from ipam.models import VLAN, VRF -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device +from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { + 'status': 'offline', 'comments': 'New comment', } @@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): ClusterGroup.objects.bulk_create(cluster_groups) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), ) Cluster.objects.bulk_create(clusters) @@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): 'name': 'Cluster 4', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 5', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 6', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, ] @@ -141,31 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + clusters = ( - Cluster(name='Cluster 1', type=clustertype, group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, group=clustergroup), + Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup), + Cluster(name='Cluster 3', type=clustertype), ) Cluster.objects.bulk_create(clusters) + device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) + device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) + virtual_machines = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}), ) VirtualMachine.objects.bulk_create(virtual_machines) cls.create_data = [ { 'name': 'Virtual Machine 4', + 'site': sites[1].pk, 'cluster': clusters[1].pk, + 'device': device2.pk, }, { 'name': 'Virtual Machine 5', + 'site': sites[1].pk, 'cluster': clusters[1].pk, }, { 'name': 'Virtual Machine 6', - 'cluster': clusters[1].pk, + 'site': sites[1].pk, + }, + { + 'name': 'Virtual Machine 7', + 'cluster': clusters[2].pk, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 9e264ac5c..d3ff12887 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,9 +1,9 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress, VRF from tenancy.models import Tenant, TenantGroup -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * from virtualization.filtersets import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]), - Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]), - Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]), + Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]), + Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]), ) Cluster.objects.bulk_create(clusters) @@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'group': [groups[0].slug, groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): types = ClusterType.objects.all()[:2] params = {'type_id': [types[0].pk, types[1].pk]} @@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ) DeviceRole.objects.bulk_create(roles) + devices = ( + create_test_device('device1', cluster=clusters[0]), + create_test_device('device2', cluster=clusters[1]), + create_test_device('device3', cluster=clusters[2]), + ) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), + VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), + VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) VirtualMachine.objects.bulk_create(vms) @@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cluster': [clusters[0].name, clusters[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index 3b4d73a30..df5816efa 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -1,21 +1,19 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from dcim.models import Site from virtualization.models import * from tenancy.models import Tenant class VirtualMachineTestCase(TestCase): - def setUp(self): - - cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1') - self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type) - def test_vm_duplicate_name_per_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) vm1 = VirtualMachine( - cluster=self.cluster, + cluster=cluster, name='Test VM 1' ) vm1.save() @@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase): # Two VMs assigned to the same Cluster and different Tenants should pass validation vm2.full_clean() vm2.save() + + def test_vm_mismatched_site_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + clusters = ( + Cluster(name='Cluster 1', type=cluster_type, site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, site=None), + ) + Cluster.objects.bulk_create(clusters) + + # VM with site only should pass + VirtualMachine(name='vm1', site=sites[0]).full_clean() + + # VM with non-site cluster only should pass + VirtualMachine(name='vm1', cluster=clusters[2]).full_clean() + + # VM with mismatched site & cluster should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean() + + # VM with cluster site but no direct site should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 8edc14f00..01d4394f3 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -5,7 +5,7 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN, VRF -from utilities.testing import ViewTestCases, create_tags +from utilities.testing import ViewTestCases, create_tags, create_test_device from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ClusterType.objects.bulk_create(clustertypes) Cluster.objects.bulk_create([ - Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]), + Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Cluster X', 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'Some comments', @@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,type", - "Cluster 4,Cluster Type 1", - "Cluster 5,Cluster Type 1", - "Cluster 6,Cluster Type 1", + "name,type,status", + "Cluster 4,Cluster Type 1,active", + "Cluster 5,Cluster Type 1,active", + "Cluster 6,Cluster Type 1,active", ) cls.bulk_edit_data = { 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'New comments', @@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Platform.objects.bulk_create(platforms) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=clustertype), - Cluster(name='Cluster 2', type=clustertype), + Cluster(name='Cluster 1', type=clustertype, site=sites[0]), + Cluster(name='Cluster 2', type=clustertype, site=sites[1]), ) Cluster.objects.bulk_create(clusters) + devices = ( + create_test_device('device1', site=sites[0], cluster=clusters[0]), + create_test_device('device2', site=sites[1], cluster=clusters[1]), + ) + VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'cluster': clusters[1].pk, + 'device': devices[1].pk, + 'site': sites[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'name': 'Virtual Machine X', @@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,cluster", - "Virtual Machine 4,active,Cluster 1", - "Virtual Machine 5,active,Cluster 1", - "Virtual Machine 6,active,Cluster 1", + "name,status,site,cluster,device", + "Virtual Machine 4,active,Site 1,Cluster 1,device1", + "Virtual Machine 5,active,Site 1,Cluster 1,device1", + "Virtual Machine 6,active,Site 1,Cluster 1,", ) cls.bulk_edit_data = { + 'site': sites[1].pk, 'cluster': clusters[1].pk, + 'device': devices[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'status': VirtualMachineStatusChoices.STATUS_STAGED, @@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site) virtualmachines = ( - VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole), - VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole), ) VirtualMachine.objects.bulk_create(virtualmachines) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 850cb6388..0b593289b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -39,7 +39,7 @@ class ClusterTypeView(generic.ObjectView): device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ) - clusters_table = tables.ClusterTable(clusters, exclude=('type',)) + clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',)) clusters_table.configure(request) return { @@ -101,7 +101,7 @@ class ClusterGroupView(generic.ObjectView): device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ) - clusters_table = tables.ClusterTable(clusters, exclude=('group',)) + clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('group',)) clusters_table.configure(request) return { diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 6d7dc84a9..d1012ba59 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -105,6 +105,9 @@ class WirelessLinkForm(NetBoxModelForm): ) location_a = DynamicModelChoiceField( queryset=Location.objects.all(), + query_params={ + 'site_id': '$site_a', + }, required=False, label='Location', initial_params={ @@ -142,6 +145,9 @@ class WirelessLinkForm(NetBoxModelForm): ) location_b = DynamicModelChoiceField( queryset=Location.objects.all(), + query_params={ + 'site_id': '$site_b', + }, required=False, label='Location', initial_params={ diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index eee7fe1ed..988aa1b6d 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -29,7 +29,7 @@ class WirelessLANGroupView(generic.ObjectView): wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter( group=instance ) - wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) + wirelesslans_table = tables.WirelessLANTable(wirelesslans, user=request.user, exclude=('group',)) wirelesslans_table.configure(request) return { @@ -97,7 +97,7 @@ class WirelessLANView(generic.ObjectView): attached_interfaces = Interface.objects.restrict(request.user, 'view').filter( wireless_lans=instance ) - interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) + interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces, user=request.user) interfaces_table.configure(request) return { diff --git a/requirements.txt b/requirements.txt index 35867410b..1def8e23e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Django==4.0.4 -django-cors-headers==3.11.0 +django-cors-headers==3.12.0 django-debug-toolbar==3.2.4 django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 @@ -7,6 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 +django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 @@ -15,15 +16,16 @@ djangorestframework==3.13.1 drf-yasg[validation]==1.20.0 graphene-django==2.15.0 gunicorn==20.1.0 -Jinja2==3.0.3 -Markdown==3.3.6 +Jinja2==3.1.2 +Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.9 -mkdocstrings==0.17.0 +mkdocs-material==8.2.16 +mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.1.0 +Pillow==9.1.1 psycopg2-binary==2.9.3 PyYAML==6.0 +sentry-sdk==1.5.12 social-auth-app-django==5.0.0 social-auth-core==4.2.0 svgwrite==1.4.2 diff --git a/upgrade.sh b/upgrade.sh index 61e6106cd..161d65e32 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -108,6 +108,11 @@ COMMAND="python3 netbox/manage.py clearsessions" echo "Removing expired user sessions ($COMMAND)..." eval $COMMAND || exit 1 +# Clear the cache +COMMAND="python3 netbox/manage.py clearcache" +echo "Clearing the cache ($COMMAND)..." +eval $COMMAND || exit 1 + if [ -v WARN_MISSING_VENV ]; then echo "--------------------------------------------------------------------" echo "WARNING: No existing virtual environment was detected. A new one has"