diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 3af825d30..2d6ca5700 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.1.9 + placeholder: v3.2.2 validations: required: true - type: dropdown @@ -22,9 +22,9 @@ body: label: Python version description: What version of Python are you currently running? options: - - "3.7" - "3.8" - "3.9" + - "3.10" validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index f5bf198b8..13b162741 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.1.9 + placeholder: v3.2.2 validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba75118b..67f5028cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,12 @@ on: [push, pull_request] jobs: build: runs-on: ubuntu-latest + env: + NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: [3.7, 3.8, 3.9] - node-version: [14.x] + python-version: ['3.8', '3.9', '3.10'] + node-version: ['14.x'] services: redis: image: redis @@ -56,8 +58,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pycodestyle coverage - ln -s configuration.testing.py netbox/netbox/configuration.py + pip install pycodestyle coverage tblib - name: Build documentation run: mkdocs build diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..bb42ca19f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 +build: + os: ubuntu-20.04 + tools: + python: "3.9" +mkdocs: + configuration: mkdocs.yml +python: + install: + - requirements: requirements.txt 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..c01adf4c9 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 @@ -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 42bf8b619..d75c2c1a5 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ NetBox logo -:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses! - ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) NetBox is an infrastructure resource modeling (IRM) tool designed to empower @@ -51,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!

@@ -73,7 +71,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/base_requirements.txt b/base_requirements.txt index 0b8365e0e..095906914 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # The Python web framework on which NetBox is built # https://github.com/django/django -Django<4.0 +Django # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers @@ -82,8 +82,12 @@ markdown-include # https://github.com/squidfunk/mkdocs-material mkdocs-material +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings[python-legacy] + # Library for manipulating IP prefixes and addresses -# https://github.com/drkjam/netaddr +# https://github.com/netaddr/netaddr netaddr # Fork of PIL (Python Imaging Library) for image processing @@ -113,3 +117,7 @@ svgwrite # Tabular dataset library (for table-based exports) # https://github.com/jazzband/tablib tablib + +# Timezone data (required by django-timezone-field on Python 3.9+) +# https://github.com/python/tzdata +tzdata 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 new file mode 100644 index 000000000..ee24e8232 --- /dev/null +++ b/docs/administration/authentication/microsoft-azure-ad.md @@ -0,0 +1,88 @@ +# Microsoft Azure AD + +This guide explains how to configure single sign-on (SSO) support for NetBox using [Microsoft Azure Active Directory (AD)](https://azure.microsoft.com/en-us/services/active-directory/) as an authentication backend. + +## Azure AD Configuration + +### 1. Create a test user (optional) + +Create a new user in AD to be used for testing. You can skip this step if you already have a suitable account created. + +### 2. Create an app registration + +Under the Azure Active Directory dashboard, navigate to **Add > App registration**. + +![Add an app registration](../../media/authentication/azure_ad_add_app_registration.png) + +Enter a name for the registration (e.g. "NetBox") and ensure that the "single tenant" option is selected. + +Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/azuread-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes). + +![App registration parameters](../../media/authentication/azure_ad_app_registration.png) + +Once finished, make note of the application (client) ID; this will be used when configuring NetBox. + +![Completed app registration](../../media/authentication/azure_ad_app_registration_created.png) + +!!! tip "Multitenant authentication" + NetBox also supports multitenant authentication via Azure AD, however it requires a different backend and an additional configuration parameter. Please see the [`python-social-auth` documentation](https://python-social-auth.readthedocs.io/en/latest/backends/azuread.html#tenant-support) for details concerning multitenant authentication. + +### 3. Create a secret + +When viewing the newly-created app registration, click the "Add a certificate or secret" link under "Client credentials". Under the "Client secrets" tab, click the "New client secret" button. + +![Add a client secret](../../media/authentication/azure_ad_add_client_secret.png) + +You can optionally specify a description and select a lifetime for the secret. + +![Client secret parameters](../../media/authentication/azure_ad_client_secret.png) + +Once finished, make note of the secret value (not the secret ID); this will be used when configuring NetBox. + +![Client secret parameters](../../media/authentication/azure_ad_client_secret_created.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.azuread.AzureADOAuth2' +SOCIAL_AUTH_AZUREAD_OAUTH2_KEY = '{APPLICATION_ID}' +SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET = '{SECRET_VALUE}' +``` + +### 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 Azure AD. Click that link. + +![NetBox Azure AD login form](../../media/authentication/netbox_azure_ad_login.png) + +You should be redirected to Microsoft'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. + +![NetBox Azure AD login form](../../media/authentication/azure_ad_login_portal.png) + +If successful, you will be redirected back to the NetBox UI, and will be logged in as the AD 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. + +## 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/authentication.md b/docs/administration/authentication/overview.md similarity index 91% rename from docs/administration/authentication.md rename to docs/administration/authentication/overview.md index 31983be0b..b405ed09a 100644 --- a/docs/administration/authentication.md +++ b/docs/administration/authentication/overview.md @@ -4,7 +4,7 @@ Local user accounts and groups can be created in NetBox under the "Authentication and Authorization" section of the administrative user interface. This interface is available only to users with the "staff" permission enabled. -At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](./permissions.md) may also be assigned to users and/or groups within the admin UI. +At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](../permissions.md) may also be assigned to users and/or groups within the admin UI. ## Remote Authentication @@ -16,7 +16,7 @@ NetBox may be configured to provide user authenticate via a remote backend in ad REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' ``` -NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../installation/6-ldap.md) for more detail about this backend. +NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../../installation/6-ldap.md) for more detail about this backend. ### HTTP Header Authentication diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md index bbb03dc27..1989e41c0 100644 --- a/docs/administration/housekeeping.md +++ b/docs/administration/housekeeping.md @@ -4,6 +4,7 @@ NetBox includes a `housekeeping` management command that should be run nightly. * Clearing expired authentication sessions from the database * Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention) +* Deleting job result records older than the configured [retention time](../configuration/dynamic-settings.md#jobresult_retention) This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index a222272c2..2fa046fcf 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -43,6 +43,18 @@ changes in the database indefinitely. --- +## JOBRESULT_RETENTION + +Default: 90 + +The number of days to retain job results (scripts and reports). Set this to `0` to retain +job results in the database indefinitely. + +!!! warning + If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + ## CUSTOM_VALIDATORS This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: @@ -66,6 +78,22 @@ CUSTOM_VALIDATORS = { --- +## DEFAULT_USER_PREFERENCES + +This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: + +```python +DEFAULT_USER_PREFERENCES = { + "pagination": { + "per_page": 100 + } +} +``` + +For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. + +--- + ## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 95ed3fc37..a863ef3dc 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -1,6 +1,11 @@ # NetBox Configuration -NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below. +NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided as `configuration_example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below. + +!!! info "Customizing the Configuration Module" + A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`. + + For the sake of brevity, the NetBox documentation refers to the configuration file simply as `configuration.py`. Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index d8d79b6ec..76fd0a12c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -13,6 +13,23 @@ ADMINS = [ --- +## AUTH_PASSWORD_VALIDATORS + +This parameter acts as a pass-through for configuring Django's built-in password validators for local user accounts. If configured, these will be applied whenever a user's password is updated to ensure that it meets minimum criteria such as length or complexity. An example is provided below. For more detail on the available options, please see [the Django documentation](https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation). + +```python +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 10, + } + }, +] +``` + +--- + ## BASE_PATH Default: None @@ -49,6 +66,21 @@ CORS_ORIGIN_WHITELIST = [ --- +## CSRF_TRUSTED_ORIGINS + +Default: `[]` + +Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). + +```python +CSRF_TRUSTED_ORIGINS = ( + 'http://netbox.local', + 'https://netbox.local', +) +``` + +--- + ## DEBUG Default: False @@ -140,6 +172,66 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- +## FIELD_CHOICES + +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) + +The choices provided can either replace the stock choices provided by NetBox, or append to them. To _replace_ the available choices, specify the app, model, and field name separated by dots. For example, the site model would be referenced as `dcim.Site.status`. To _extend_ the available choices, append a plus sign to the end of this string (e.g. `dcim.Site.status+`). + +For example, the following configuration would replace the default site status choices with the options Foo, Bar, and Baz: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), + ) +} +``` + +Appending a plus sign to the field identifier would instead _add_ these choices to the ones already offered: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ... + ) +} +``` + +The following model fields support configurable choices: + +* `circuits.Circuit.status` +* `dcim.Device.status` +* `dcim.PowerFeed.status` +* `dcim.Rack.status` +* `dcim.Site.status` +* `extras.JournalEntry.kind` +* `ipam.IPAddress.status` +* `ipam.IPRange.status` +* `ipam.Prefix.status` +* `ipam.VLAN.status` +* `virtualization.VirtualMachine.status` + +The following colors are supported: + +* `blue` +* `indigo` +* `purple` +* `pink` +* `red` +* `orange` +* `yellow` +* `green` +* `teal` +* `cyan` +* `gray` +* `black` +* `white` + +--- + ## HTTP_PROXIES Default: None diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 73d29415b..2c3a7002f 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote Default: `False` -NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled) +NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is disabled) --- diff --git a/docs/core-functionality/device-types.md b/docs/core-functionality/device-types.md index 037d3cfd0..ec5cbacdb 100644 --- a/docs/core-functionality/device-types.md +++ b/docs/core-functionality/device-types.md @@ -37,4 +37,5 @@ Once component templates have been created, every new device that you create as {!models/dcim/interfacetemplate.md!} {!models/dcim/frontporttemplate.md!} {!models/dcim/rearporttemplate.md!} +{!models/dcim/modulebaytemplate.md!} {!models/dcim/devicebaytemplate.md!} diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 982ee3071..35c978210 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -17,6 +17,7 @@ Device components represent discrete objects within a device which are used to t {!models/dcim/interface.md!} {!models/dcim/frontport.md!} {!models/dcim/rearport.md!} +{!models/dcim/modulebay.md!} {!models/dcim/devicebay.md!} {!models/dcim/inventoryitem.md!} diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 9fa5e0eb4..01bb3c76d 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -21,6 +21,7 @@ --- {!models/ipam/fhrpgroup.md!} +{!models/ipam/fhrpgroupassignment.md!} --- diff --git a/docs/core-functionality/modules.md b/docs/core-functionality/modules.md new file mode 100644 index 000000000..4d32fe18c --- /dev/null +++ b/docs/core-functionality/modules.md @@ -0,0 +1,4 @@ +# Modules + +{!models/dcim/moduletype.md!} +{!models/dcim/module.md!} diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md index 2e7aaf65a..316c7fe00 100644 --- a/docs/core-functionality/services.md +++ b/docs/core-functionality/services.md @@ -1,3 +1,4 @@ # Service Mapping +{!models/ipam/servicetemplate.md!} {!models/ipam/service.md!} diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 02af19726..230b003c6 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -89,6 +89,12 @@ The checkbox to commit database changes when executing a script is checked by de commit_default = False ``` +### `job_timeout` + +Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used. + +!!! info "This feature was introduced in v3.2.1" + ## Accessing Request Data Details of the current HTTP request (the one being made to execute the script) are available as the instance attribute `self.request`. This can be used to infer, for example, the user executing the script and the client IP address: 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/customization/reports.md b/docs/customization/reports.md index ed4faf371..ae4ceb9aa 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -85,6 +85,20 @@ As you can see, reports are completely customizable. Validation logic can be as !!! warning Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. +## Report Attributes + +### `description` + +A human-friendly description of what your report does. + +### `job_timeout` + +Set the maximum allowed runtime for the report. If not set, `RQ_DEFAULT_TIMEOUT` will be used. + +!!! info "This feature was introduced in v3.2.1" + +## Logging + The following methods are available to log results within a report: * log(message) @@ -95,7 +109,7 @@ The following methods are available to log results within a report: The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page. -To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. +To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`. By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last. diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index d55afb2f2..f4d171f48 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -2,7 +2,7 @@ ## 1. Define the model class -Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. +Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be NetBoxModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. Each model should define, at a minimum: diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 0e892bd4a..dbbe8378d 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -11,17 +11,25 @@ Getting started with NetBox development is pretty straightforward, and should fe ### Fork the Repo -Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) You can then clone your GitHub fork locally for development: +Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) Click the "fork" button at top right (be sure that you've logged into GitHub first). + +![GitHub fork button](../media/development/github_fork_button.png) + +Copy the URL provided in the dialog box. + +![GitHub fork dialog](../media/development/github_fork_dialog.png) + +You can then clone your GitHub fork locally for development: ```no-highlight -$ git clone https://github.com/youruseraccount/netbox.git +$ git clone https://github.com/$username/netbox.git Cloning into 'netbox'... -remote: Enumerating objects: 231, done. -remote: Counting objects: 100% (231/231), done. -remote: Compressing objects: 100% (147/147), done. -remote: Total 56705 (delta 134), reused 145 (delta 84), pack-reused 56474 -Receiving objects: 100% (56705/56705), 27.96 MiB | 34.92 MiB/s, done. -Resolving deltas: 100% (44177/44177), done. +remote: Enumerating objects: 85949, done. +remote: Counting objects: 100% (4672/4672), done. +remote: Compressing objects: 100% (1224/1224), done. +remote: Total 85949 (delta 3538), reused 4332 (delta 3438), pack-reused 81277 +Receiving objects: 100% (85949/85949), 55.16 MiB | 44.90 MiB/s, done. +Resolving deltas: 100% (68008/68008), done. $ ls netbox/ base_requirements.txt contrib docs mkdocs.yml NOTICE requirements.txt upgrade.sh CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts @@ -33,7 +41,7 @@ The NetBox project utilizes three persistent git branches to track work: * `develop` - All development on the upcoming stable release occurs here * `feature` - Tracks work on an upcoming major release -Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch. +Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release. For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0). @@ -60,7 +68,7 @@ $ python3 -m venv ~/.venv/netbox This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`. !!! info "Where to Create Your Virtual Environments" - Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. + Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. Also consider using [`virtualenvwrapper`](https://virtualenvwrapper.readthedocs.io/en/stable/) to simplify the management of multiple venvs. Once created, activate the virtual environment: @@ -85,7 +93,7 @@ Collecting Django==3.1 (from -r requirements.txt (line 1)) ### Configure NetBox -Within the `netbox/netbox/` directory, copy `configuration.example.py` to `configuration.py` and update the following parameters: +Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: * `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes * `DATABASE`: PostgreSQL database connection parameters @@ -99,12 +107,13 @@ Within the `netbox/netbox/` directory, copy `configuration.example.py` to `confi Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command: ```no-highlight -$ python netbox/manage.py runserver +$ ./manage.py runserver +Watching for file changes with StatReloader Performing system checks... System check identified no issues (0 silenced). -November 18, 2020 - 15:52:31 -Django version 3.1, using settings 'netbox.settings' +February 18, 2022 - 20:29:57 +Django version 4.0.2, using settings 'netbox.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. ``` @@ -122,24 +131,36 @@ The demo data is provided in JSON format and loaded into an empty database using ## Running Tests -Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository. +Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command, which employs Python's [`unittest`](https://docs.python.org/3/library/unittest.html#module-unittest) library. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `netbox/` directory, not the root directory of the repository. + +To avoid potential issues with your local configuration file, set the `NETBOX_CONFIGURATION` to point to the packaged test configuration at `netbox/configuration_testing.py`. This will handle things like ensuring that the dummy plugin is enabled for comprehensive testing. ```no-highlight +$ export NETBOX_CONFIGURATION=netbox.configuration_testing +$ cd netbox/ $ python manage.py test ``` -In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.) +In cases where you haven't made any changes to the database schema (which is typical), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.) ```no-highlight $ python manage.py test --keepdb ``` -You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests: +You can also reduce testing time by enabling parallel test execution with the `--parallel` flag. (By default, this will run as many parallel tests as you have processors. To avoid sluggishness, it's a good idea to specify a lower number of parallel tests.) This flag can be combined with `--keepdb`, although if you encounter any strange errors, try running the test suite again with parallelization disabled. + +```no-highlight +$ python manage.py test --parallel +``` + +Finally, it's possible to limit the run to a specific set of tests, specified by their Python path. For example, to run only IPAM and DCIM view tests: ```no-highlight $ python manage.py test dcim.tests.test_views ipam.tests.test_views ``` +This is handy for instances where just a few tests are failing and you want to re-run them individually. + ## Submitting Pull Requests Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 8fbec84f9..17c27948d 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -8,7 +8,7 @@ Check `base_requirements.txt` for any dependencies pinned to a specific version, ### Link to the Release Notes Page -Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and point `index.md` to the new file. +Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and add a summary of the major changes to `index.md`. ### Manually Perform a New Install diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index 0595bc358..ceb5321a9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,8 +4,11 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -| ---- | ----------- | -| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table | +| Name | Description | +|--------------------------|---------------------------------------------------------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| pagination.placement | Where to display the paginator controls relative to the table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| tables.${table}.ordering | A list of column names by which the table should be ordered | +| ui.colormode | Light or dark mode in the user interface | diff --git a/docs/index.md b/docs/index.md index 5742c0ab6..81c899387 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,5 @@ ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} -:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses! - # What is NetBox? NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management: @@ -56,7 +54,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and ## Supported Python Versions -NetBox supports Python 3.7, 3.8, and 3.9 environments currently. (Support for Python 3.6 was removed in NetBox v3.0.) +NetBox supports Python 3.8, 3.9, and 3.10 environments. ## Getting Started diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index b1e1e832e..50b350d3a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -6,8 +6,8 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. -!!! warning "Python 3.7 or later required" - NetBox v3.0 and v3.1 require Python 3.7, 3.8, or 3.9. It is recommended to install at least Python v3.8, as this will become the minimum supported Python version in NetBox v3.2. +!!! warning "Python 3.8 or later required" + NetBox v3.2 requires Python 3.8, 3.9, or 3.10. === "Ubuntu" @@ -17,16 +17,11 @@ Begin by installing all system packages required by NetBox and its dependencies. === "CentOS" - !!! warning - CentOS 8 does not provide Python 3.7 or later via its native package manager. You will need to install it via some other means. [Here is an example](https://tecadmin.net/install-python-3-7-on-centos-8/) of installing Python 3.7 from source. - - Once you have Python 3.7 or later installed, install the remaining system packages: - ```no-highlight sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config ``` -Before continuing, check that your installed Python version is at least 3.7: +Before continuing, check that your installed Python version is at least 3.8: ```no-highlight python3 -V @@ -117,11 +112,11 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s ## Configuration -Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. This file will hold all of your local configuration parameters. +Move into the NetBox configuration directory and make a copy of `configuration_example.py` named `configuration.py`. This file will hold all of your local configuration parameters. ```no-highlight cd /opt/netbox/netbox/netbox/ -sudo cp configuration.example.py configuration.py +sudo cp configuration_example.py configuration.py ``` Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: @@ -234,10 +229,10 @@ Once NetBox has been configured, we're ready to proceed with the actual installa sudo /opt/netbox/upgrade.sh ``` -Note that **Python 3.7 or later is required** for NetBox v3.0 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) +Note that **Python 3.8 or later is required** for NetBox v3.2 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) ```no-highlight -sudo PYTHON=/usr/bin/python3.7 /opt/netbox/upgrade.sh +sudo PYTHON=/usr/bin/python3.8 /opt/netbox/upgrade.sh ``` !!! note 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/index.md b/docs/installation/index.md index a06a60bf6..905add7ab 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -15,7 +15,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Minimum Version | |------------|-----------------| -| Python | 3.7 | +| Python | 3.8 | | PostgreSQL | 10 | | Redis | 4.0 | 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/installation/upgrading.md b/docs/installation/upgrading.md index d7f7156c8..014dffaf8 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -6,11 +6,11 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas ## Update Dependencies to Required Versions -NetBox v3.0 and later requires the following: +NetBox v3.0 and later require the following: | Dependency | Minimum Version | |------------|-----------------| -| Python | 3.7 | +| Python | 3.8 | | PostgreSQL | 10 | | Redis | 4.0 | @@ -67,6 +67,11 @@ sudo git checkout master sudo git pull origin master ``` +!!! info "Checking out an older release" + If you need to upgrade to an older version rather than the current stable release, you can check out any valid [git tag](https://github.com/netbox-community/netbox/tags), each of which represents a release. For example, to checkout the code for NetBox v2.11.11, do: + + sudo git checkout v2.11.11 + ## Run the Upgrade Script Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script: @@ -76,10 +81,10 @@ sudo ./upgrade.sh ``` !!! warning - If the default version of Python is not at least 3.7, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: + If the default version of Python is not at least 3.8, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: ```no-highlight - sudo PYTHON=/usr/bin/python3.7 ./upgrade.sh + sudo PYTHON=/usr/bin/python3.8 ./upgrade.sh ``` This script performs the following actions: diff --git a/docs/media/authentication/azure_ad_add_app_registration.png b/docs/media/authentication/azure_ad_add_app_registration.png new file mode 100644 index 000000000..4cc4fc81e Binary files /dev/null and b/docs/media/authentication/azure_ad_add_app_registration.png differ diff --git a/docs/media/authentication/azure_ad_add_client_secret.png b/docs/media/authentication/azure_ad_add_client_secret.png new file mode 100644 index 000000000..93f4eb776 Binary files /dev/null and b/docs/media/authentication/azure_ad_add_client_secret.png differ diff --git a/docs/media/authentication/azure_ad_app_registration.png b/docs/media/authentication/azure_ad_app_registration.png new file mode 100644 index 000000000..eb1634e96 Binary files /dev/null and b/docs/media/authentication/azure_ad_app_registration.png differ diff --git a/docs/media/authentication/azure_ad_app_registration_created.png b/docs/media/authentication/azure_ad_app_registration_created.png new file mode 100644 index 000000000..7e14dc731 Binary files /dev/null and b/docs/media/authentication/azure_ad_app_registration_created.png differ diff --git a/docs/media/authentication/azure_ad_client_secret.png b/docs/media/authentication/azure_ad_client_secret.png new file mode 100644 index 000000000..bebae388b Binary files /dev/null and b/docs/media/authentication/azure_ad_client_secret.png differ diff --git a/docs/media/authentication/azure_ad_client_secret_created.png b/docs/media/authentication/azure_ad_client_secret_created.png new file mode 100644 index 000000000..c142f2a91 Binary files /dev/null and b/docs/media/authentication/azure_ad_client_secret_created.png differ diff --git a/docs/media/authentication/azure_ad_login_portal.png b/docs/media/authentication/azure_ad_login_portal.png new file mode 100644 index 000000000..891f64355 Binary files /dev/null and b/docs/media/authentication/azure_ad_login_portal.png differ diff --git a/docs/media/authentication/netbox_azure_ad_login.png b/docs/media/authentication/netbox_azure_ad_login.png new file mode 100644 index 000000000..a1bbe69d1 Binary files /dev/null and b/docs/media/authentication/netbox_azure_ad_login.png differ 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/media/development/github_fork_button.png b/docs/media/development/github_fork_button.png new file mode 100644 index 000000000..b326d5de1 Binary files /dev/null and b/docs/media/development/github_fork_button.png differ diff --git a/docs/media/development/github_fork_dialog.png b/docs/media/development/github_fork_dialog.png new file mode 100644 index 000000000..32fed0523 Binary files /dev/null and b/docs/media/development/github_fork_dialog.png differ diff --git a/docs/media/plugins/plugin_admin_ui.png b/docs/media/plugins/plugin_admin_ui.png deleted file mode 100644 index 44802c5fc..000000000 Binary files a/docs/media/plugins/plugin_admin_ui.png and /dev/null differ diff --git a/docs/media/plugins/plugin_rest_api_endpoint.png b/docs/media/plugins/plugin_rest_api_endpoint.png deleted file mode 100644 index 7cdf34cc8..000000000 Binary files a/docs/media/plugins/plugin_rest_api_endpoint.png and /dev/null differ diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md index f5a428e96..42c46e13c 100644 --- a/docs/models/circuits/providernetwork.md +++ b/docs/models/circuits/providernetwork.md @@ -2,4 +2,4 @@ This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity. -Each provider network must be assigned to a provider. A circuit may terminate to either a provider network or to a site. +Each provider network must be assigned to a provider, and may optionally be assigned an arbitrary service ID. A circuit may terminate to either a provider network or to a site. diff --git a/docs/models/dcim/devicebay.md b/docs/models/dcim/devicebay.md index 2aea14a7a..e79c426dc 100644 --- a/docs/models/dcim/devicebay.md +++ b/docs/models/dcim/devicebay.md @@ -5,4 +5,4 @@ Device bays represent a space or slot within a parent device in which a child de Child devices are first-class Devices in their own right: That is, they are fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and components. LAG interfaces may not group interfaces belonging to different child devices. !!! note - Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, these should be modeled as modules installed within module bays. diff --git a/docs/models/dcim/devicebaytemplate.md b/docs/models/dcim/devicebaytemplate.md index ebf7bd63c..a4c50067a 100644 --- a/docs/models/dcim/devicebaytemplate.md +++ b/docs/models/dcim/devicebaytemplate.md @@ -1,3 +1,3 @@ ## Device Bay Templates -A template for a device bay that will be created on all instantiations of the parent device type. +A template for a device bay that will be created on all instantiations of the parent device type. Device bays hold child devices, such as blade servers. diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index b919465c8..cf42185f4 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -4,13 +4,13 @@ A device type represents a particular make and model of hardware that exists in Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively. -Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: +Some devices house child devices which share physical resources, like space and power, but which function independently. A common example of this is blade server chassis. Each device type is designated as one of the following: * A parent device (which has device bays) * A child device (which must be installed within a device bay) * Neither !!! note - This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device. A device type may optionally specify an airflow direction, such as front-to-rear, rear-to-front, or passive. Airflow direction may also be set separately per device. If it is not defined for a device at the time of its creation, it will inherit the airflow setting of its device type. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 585674de1..7fa52fa9f 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -1,6 +1,6 @@ ## Interfaces -Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). +Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Additionally, each interface may optionally be assigned to a VRF. !!! note Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 237bad92c..fbd3172bb 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,7 +1,7 @@ # Inventory Items -Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes. +Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. -Each inventory item can be assigned a manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox). +Each inventory item can be assigned a functional role, manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside NetBox). -Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. +Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md new file mode 100644 index 000000000..8ed31481a --- /dev/null +++ b/docs/models/dcim/inventoryitemrole.md @@ -0,0 +1,3 @@ +# Inventory Item Roles + +Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc. diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md new file mode 100644 index 000000000..3167ed4ab --- /dev/null +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -0,0 +1,3 @@ +# Inventory Item Templates + +A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md new file mode 100644 index 000000000..bc9753ecc --- /dev/null +++ b/docs/models/dcim/module.md @@ -0,0 +1,5 @@ +# Modules + +A module is a field-replaceable hardware component installed within a device which houses its own child components. The most common example is a chassis-based router or switch. + +Similar to devices, modules are instantiated from module types, and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a module bay on a device, and each module bay may have only one module installed in it. A module may optionally be assigned a serial number and asset tag. diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md new file mode 100644 index 000000000..6c6f94598 --- /dev/null +++ b/docs/models/dcim/modulebay.md @@ -0,0 +1,3 @@ +## Module Bays + +Module bays represent a space or slot within a device in which a field-replaceable module may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device. diff --git a/docs/models/dcim/modulebaytemplate.md b/docs/models/dcim/modulebaytemplate.md new file mode 100644 index 000000000..463789305 --- /dev/null +++ b/docs/models/dcim/modulebaytemplate.md @@ -0,0 +1,3 @@ +## Module Bay Templates + +A template for a module bay that will be created on all instantiations of the parent device type. Module bays hold installed modules that do not have an independent management plane, such as line cards. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md new file mode 100644 index 000000000..c1c8c5079 --- /dev/null +++ b/docs/models/dcim/moduletype.md @@ -0,0 +1,23 @@ +# Module Types + +A module type represent a specific make and model of hardware component which is installable within a device and has its own child components. For example, consider a chassis-based switch or router with a number of field-replaceable line cards. Each line card has its own model number and includes a certain set of components such as interfaces. Each module type may have a manufacturer, model number, and part number assigned to it. + +Similar to device types, each module type can have any of the following component templates associated with it: + +* Interfaces +* Console ports +* Console server ports +* Power ports +* Power Outlets +* Front pass-through ports +* Rear pass-through ports + +Note that device bays and module bays may _not_ be added to modules. + +## Automatic Component Renaming + +When adding component templates to a module type, the string `{module}` can be used to reference the `position` field of the module bay into which an instance of the module type is being installed. + +For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`. + +Automatic renaming is supported for all modular component types (those listed above). diff --git a/docs/models/dcim/virtualchassis.md b/docs/models/dcim/virtualchassis.md index 3b6fb9d17..2466b065d 100644 --- a/docs/models/dcim/virtualchassis.md +++ b/docs/models/dcim/virtualchassis.md @@ -2,7 +2,8 @@ A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single device. A virtual chassis must be assigned a name and may be assigned a domain. -Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, and other attributes related to managing the VC. +Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, virtual interfaces, and other attributes related to managing the VC. +If a VC master is defined, interfaces from all VC members are displayed when navigating to its device interfaces view. This does not include other members interfaces declared as management-only. !!! note It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices. diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index e3462a6a7..da73816b6 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -19,6 +19,8 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * JSON: Arbitrary data stored in JSON format * Selection: A selection of one of several pre-defined custom choices * Multiple selection: A selection field which supports the assignment of multiple values +* Object: A single NetBox object of the type defined by `object_type` +* Multiple object: One or more NetBox objects of the type defined by `object_type` Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. @@ -41,3 +43,7 @@ NetBox supports limited custom validation for custom field values. Following are Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. + +### Custom Object Fields + +An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 7fd510841..16ba9d2af 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -2,7 +2,7 @@ Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS). -Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`. +Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`. For example, you might define a link like this: @@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as: View NMS ``` -Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually. !!! warning Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. @@ -24,13 +24,18 @@ Custom links appear as buttons in the top right corner of the page. Numeric weig The following context data is available within the template when rendering a custom link's text or URL. -| Variable | Description | -|----------|-------------| -| `obj` | The NetBox object being displayed | -| `debug` | A boolean indicating whether debugging is enabled | -| `request` | The current WSGI request | -| `user` | The current user (if authenticated) | -| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | +| Variable | Description | +|-----------|-------------------------------------------------------------------------------------------------------------------| +| `object` | The NetBox object being displayed | +| `obj` | Same as `object`; maintained for backward compatability until NetBox v3.5 | +| `debug` | A boolean indicating whether debugging is enabled | +| `request` | The current WSGI request | +| `user` | The current user (if authenticated) | +| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | + +While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type. + +Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list. ## Conditional Rendering diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index eb47dd332..d0137938d 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -3,7 +3,7 @@ A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks. !!! warning - Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. + Webhooks support the inclusion of user-submitted code to generate URL, custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. ## Configuration @@ -12,7 +12,7 @@ A webhook is a mechanism for conveying to some external system a change that too * **Enabled** - If unchecked, the webhook will be inactive. * **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected. * **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. -* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed. +* **URL** - The fully-qualified URL of the request to be sent. This may specify a destination port number if needed. Jinja2 templating is supported for this field. * **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`) * **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). * **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) @@ -23,7 +23,7 @@ A webhook is a mechanism for conveying to some external system a change that too ## Jinja2 Template Support -[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. +[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `URL`, `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration: diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md index 5efbc8428..c5baccd7b 100644 --- a/docs/models/ipam/fhrpgroup.md +++ b/docs/models/ipam/fhrpgroup.md @@ -8,9 +8,3 @@ A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to p * Gateway Load Balancing Protocol (GLBP) NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses. - -## FHRP Group Assignments - -Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority. - -Interfaces are assigned to FHRP groups under the interface detail view. diff --git a/docs/models/ipam/fhrpgroupassignment.md b/docs/models/ipam/fhrpgroupassignment.md new file mode 100644 index 000000000..c3e0bf200 --- /dev/null +++ b/docs/models/ipam/fhrpgroupassignment.md @@ -0,0 +1,5 @@ +# FHRP Group Assignments + +Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority. + +Interfaces are assigned to FHRP groups under the interface detail view. diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md new file mode 100644 index 000000000..7fed40211 --- /dev/null +++ b/docs/models/ipam/servicetemplate.md @@ -0,0 +1,3 @@ +# Service Templates + +Service templates can be used to instantiate services on devices and virtual machines. A template defines a name, protocol, and port number(s), and may optionally include a description. Services can be instantiated from templates and applied to devices and/or virtual machines, and may be associated with specific IP addresses. diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 819d45982..2840fafed 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -2,4 +2,6 @@ VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. +A minimum and maximum child VLAN ID must be set for each group. (These default to 1 and 4094 respectively.) VLANs created within a group must have a VID that falls between these values (inclusive). + Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/models/users/token.md b/docs/models/users/token.md index d0e0f8609..d98b51369 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -3,7 +3,7 @@ A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. !!! note - The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access. + All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts. Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 6fac7ce36..7f1a5082d 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -1,3 +1,3 @@ ## Interfaces -Virtual machine interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. +Virtual machine interfaces behave similarly to device interfaces, and can be assigned to VRFs, and may have IP addresses, VLANs, and services attached to them. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. diff --git a/docs/plugins/development.md b/docs/plugins/development.md deleted file mode 100644 index 9d1ad1444..000000000 --- a/docs/plugins/development.md +++ /dev/null @@ -1,432 +0,0 @@ -# Plugin Development - -!!! info "Help Improve the NetBox Plugins Framework!" - We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338). - -This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. - -Plugins can do a lot, including: - -* Create Django models to store data in the database -* Provide their own "pages" (views) in the web user interface -* Inject template content and navigation links -* Establish their own REST API endpoints -* Add custom request/response middleware - -However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. - -!!! warning - While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases. - -## Initial Setup - -### Plugin Structure - -Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: - -```no-highlight -project-name/ - - plugin_name/ - - templates/ - - plugin_name/ - - *.html - - __init__.py - - middleware.py - - navigation.py - - signals.py - - template_content.py - - urls.py - - views.py - - README - - setup.py -``` - -The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: - -* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. -* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. -* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens). - -The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. - -### Create setup.py - -`setup.py` is the [setup script](https://docs.python.org/3.7/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: - -```python -from setuptools import find_packages, setup - -setup( - name='netbox-animal-sounds', - version='0.1', - description='An example NetBox plugin', - url='https://github.com/netbox-community/netbox-animal-sounds', - author='Jeremy Stretch', - license='Apache 2.0', - install_requires=[], - packages=find_packages(), - include_package_data=True, - zip_safe=False, -) -``` - -Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). - -!!! note - `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) - -### Define a PluginConfig - -The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: - -```python -from extras.plugins import PluginConfig - -class AnimalSoundsConfig(PluginConfig): - name = 'netbox_animal_sounds' - verbose_name = 'Animal Sounds' - description = 'An example plugin for development purposes' - version = '0.1' - author = 'Jeremy Stretch' - author_email = 'author@example.com' - base_url = 'animal-sounds' - required_settings = [] - default_settings = { - 'loud': False - } - -config = AnimalSoundsConfig -``` - -NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. - -#### PluginConfig Attributes - -| Name | Description | -| ---- | ----------- | -| `name` | Raw plugin name; same as the plugin's source directory | -| `verbose_name` | Human-friendly name for the plugin | -| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | -| `description` | Brief description of the plugin's purpose | -| `author` | Name of plugin's author | -| `author_email` | Author's public email address | -| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | -| `required_settings` | A list of any configuration parameters that **must** be defined by the user | -| `default_settings` | A dictionary of configuration parameters and their default values | -| `min_version` | Minimum version of NetBox with which the plugin is compatible | -| `max_version` | Maximum version of NetBox with which the plugin is compatible | -| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | -| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | -| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | - -All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. - -### Create a Virtual Environment - -It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.) - -```shell -python3 -m venv /path/to/my/venv -``` - -You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) - -```shell -cd $VENV/lib/python3.7/site-packages/ -echo /opt/netbox/netbox > netbox.pth -``` - -### Install the Plugin for Development - -To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): - -```no-highlight -$ python setup.py develop -``` - -## Database Models - -If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. - -Below is an example `models.py` file containing a model with two character fields: - -```python -from django.db import models - -class Animal(models.Model): - name = models.CharField(max_length=50) - sound = models.CharField(max_length=50) - - def __str__(self): - return self.name -``` - -Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. - -!!! note - A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory. - -```no-highlight -$ ./manage.py makemigrations netbox_animal_sounds -Migrations for 'netbox_animal_sounds': - /home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py - - Create model Animal -``` - -Next, we can apply the migration to the database with the `migrate` command: - -```no-highlight -$ ./manage.py migrate netbox_animal_sounds -Operations to perform: - Apply all migrations: netbox_animal_sounds -Running migrations: - Applying netbox_animal_sounds.0001_initial... OK -``` - -For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). - -### Using the Django Admin Interface - -Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below: - -```python -from django.contrib import admin -from .models import Animal - -@admin.register(Animal) -class AnimalAdmin(admin.ModelAdmin): - list_display = ('name', 'sound') -``` - -This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view. - -![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png) - -## Views - -If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: - -```python -from django.shortcuts import render -from django.views.generic import View -from .models import Animal - -class RandomAnimalView(View): - """ - Display a randomly-selected animal. - """ - def get(self, request): - animal = Animal.objects.order_by('?').first() - return render(request, 'netbox_animal_sounds/animal.html', { - 'animal': animal, - }) -``` - -This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below. - -### Extending the Base Template - -NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks: - -* `title` - The page title -* `header` - The upper portion of the page -* `content` - The main page body -* `javascript` - A section at the end of the page for including Javascript code - -For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). - -```jinja2 -{% extends 'base/layout.html' %} - -{% block content %} - {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} -

- {% if animal %} - The {{ animal.name|lower }} says - {% if config.loud %} - {{ animal.sound|upper }}! - {% else %} - {{ animal.sound }} - {% endif %} - {% else %} - No animals have been created yet! - {% endif %} -

- {% endwith %} -{% endblock %} - -``` - -The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block. - -!!! note - Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of. - -Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths. - -```python -from django.urls import path -from . import views - -urlpatterns = [ - path('random/', views.RandomAnimalView.as_view(), name='random_animal'), -] -``` - -A URL pattern has three components: - -* `route` - The unique portion of the URL dedicated to this view -* `view` - The view itself -* `name` - A short name used to identify the URL path internally - -This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. - -## REST API Endpoints - -Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. - -First, we'll create a serializer for our `Animal` model, in `api/serializers.py`: - -```python -from rest_framework.serializers import ModelSerializer -from netbox_animal_sounds.models import Animal - -class AnimalSerializer(ModelSerializer): - - class Meta: - model = Animal - fields = ('id', 'name', 'sound') -``` - -Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`: - -```python -from rest_framework.viewsets import ModelViewSet -from netbox_animal_sounds.models import Animal -from .serializers import AnimalSerializer - -class AnimalViewSet(ModelViewSet): - queryset = Animal.objects.all() - serializer_class = AnimalSerializer -``` - -Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`. - -```python -from rest_framework import routers -from .views import AnimalViewSet - -router = routers.DefaultRouter() -router.register('animals', AnimalViewSet) -urlpatterns = router.urls -``` - -With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined. - -![NetBox REST API plugin endpoint](../media/plugins/plugin_rest_api_endpoint.png) - -!!! warning - This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have. - -## Navigation Menu Items - -To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. - -```python -from extras.plugins import PluginMenuButton, PluginMenuItem -from utilities.choices import ButtonColorChoices - -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) - ), -) -``` - -A `PluginMenuItem` has the following attributes: - -* `link` - The name of the URL path to which this menu item links -* `link_text` - The text presented to the user -* `permissions` - A list of permissions required to display this link (optional) -* `buttons` - An iterable of PluginMenuButton instances to display (optional) - -A `PluginMenuButton` has the following attributes: - -* `link` - The name of the URL path to which this button links -* `title` - The tooltip text (displayed when the mouse hovers over the button) -* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/)) -* `color` - One of the choices provided by `ButtonColorChoices` (optional) -* `permissions` - A list of permissions required to display this button (optional) - -!!! note - Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. - -## Extending Core Templates - -Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: - -* `left_page()` - Inject content on the left side of the page -* `right_page()` - Inject content on the right side of the page -* `full_width_page()` - Inject content across the entire bottom of the page -* `buttons()` - Add buttons to the top of the page - -Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. - -When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: - -* `object` - The object being viewed -* `request` - The current request -* `settings` - Global NetBox settings -* `config` - Plugin-specific configuration parameters - -For example, accessing `{{ request.user }}` within a template will return the current user. - -Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. - -```python -from extras.plugins import PluginTemplateExtension -from .models import Animal - -class SiteAnimalCount(PluginTemplateExtension): - model = 'dcim.site' - - def right_page(self): - return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ - 'animal_count': Animal.objects.count(), - }) - -template_extensions = [SiteAnimalCount] -``` - -## Background Tasks - -By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*. -These 3 core queues can be used out-of-the-box by plugins to define background tasks. - -Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration -is below: - -```python -class MyPluginConfig(PluginConfig): - name = 'myplugin' - ... - queues = [ - 'queue1', - 'queue2', - 'queue-whatever-the-name' - ] -``` - -The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*. -As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins. - -In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks. - -``` -python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name -``` diff --git a/docs/plugins/development/background-tasks.md b/docs/plugins/development/background-tasks.md new file mode 100644 index 000000000..5ed05752a --- /dev/null +++ b/docs/plugins/development/background-tasks.md @@ -0,0 +1,30 @@ +# Background Tasks + +NetBox supports the queuing of tasks that need to be performed in the background, decoupled from the request-response cycle, using the [Python RQ](https://python-rq.org/) library. Three task queues of differing priority are defined by default: + +* High +* Default +* Low + +Any tasks in the "high" queue are completed before the default queue is checked, and any tasks in the default queue are completed before those in the "low" queue. + +Plugins can also add custom queues for their own needs by setting the `queues` attribute under the PluginConfig class. An example is included below: + +```python +class MyPluginConfig(PluginConfig): + name = 'myplugin' + ... + queues = [ + 'foo', + 'bar', + ] +``` + +The PluginConfig above creates two custom queues with the following names `my_plugin.foo` and `my_plugin.bar`. (The plugin's name is prepended to each queue to avoid conflicts between plugins.) + +!!! warning "Configuring the RQ worker process" + By default, NetBox's RQ worker process only services the high, default, and low queues. Plugins which introduce custom queues should advise users to either reconfigure the default worker, or run a dedicated worker specifying the necessary queues. For example: + + ``` + python manage.py rqworker my_plugin.foo my_plugin.bar + ``` diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md new file mode 100644 index 000000000..318ad5b4e --- /dev/null +++ b/docs/plugins/development/filtersets.md @@ -0,0 +1,70 @@ +# Filters & Filter Sets + +Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. + +## FilterSet Classes + +To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below. + +```python +# filtersets.py +import django_filters +from netbox.filtersets import NetBoxModelFilterSet +from .models import MyModel + +class MyFilterSet(NetBoxModelFilterSet): + status = django_filters.MultipleChoiceFilter( + choices=( + ('foo', 'Foo'), + ('bar', 'Bar'), + ('baz', 'Baz'), + ), + null_value=None + ) + + class Meta: + model = MyModel + fields = ('some', 'other', 'fields') +``` + +### Declaring Filter Sets + +To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class: + +```python +# views.py +from netbox.views.generic import ObjectListView +from .filtersets import MyModelFitlerSet +from .models import MyModel + +class MyModelListView(ObjectListView): + queryset = MyModel.objects.all() + filterset = MyModelFitlerSet +``` + +To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view: + +```python +# api/views.py +from myplugin import models, filtersets +from . import serializers + +class MyModelViewSet(...): + queryset = models.MyModel.objects.all() + serializer_class = serializers.MyModelSerializer + filterset_class = filtersets.MyModelFilterSet +``` + +## Filter Classes + +### TagFilter + +The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class. + +```python +from django_filters import FilterSet +from extras.filters import TagFilter + +class MyModelFilterSet(FilterSet): + tag = TagFilter() +``` diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md new file mode 100644 index 000000000..e1eefa7a5 --- /dev/null +++ b/docs/plugins/development/forms.md @@ -0,0 +1,216 @@ +# Forms + +## Form Classes + +NetBox provides several base form classes for use by plugins. + +| Form Class | Purpose | +|---------------------------|--------------------------------------| +| `NetBoxModelForm` | Create/edit individual objects | +| `NetBoxModelCSVForm` | Bulk import objects from CSV data | +| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously | +| `NetBoxModelFilterSetForm` | Filter objects within a list view | + +### `NetBoxModelForm` + +This is the base form for creating and editing NetBox models. It extends Django's ModelForm to add support for tags and custom fields. + +| Attribute | Description | +|-------------|-------------------------------------------------------------| +| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | + +**Example** + +```python +from dcim.models import Site +from netbox.forms import NetBoxModelForm +from utilities.forms.fields import CommentField, DynamicModelChoiceField +from .models import MyModel + +class MyModelForm(NetBoxModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) + comments = CommentField() + fieldsets = ( + ('Model Stuff', ('name', 'status', 'site', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + class Meta: + model = MyModel + fields = ('name', 'status', 'site', 'comments', 'tags') +``` + +!!! tip "Comment fields" + If your form has a `comments` field, there's no need to list it; this will always appear last on the page. + +### `NetBoxModelCSVForm` + +This form facilitates the bulk import of new objects from CSV data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below. + +**Example** + +```python +from dcim.models import Site +from netbox.forms import NetBoxModelCSVForm +from utilities.forms import CSVModelChoiceField +from .models import MyModel + +class MyModelCSVForm(NetBoxModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Assigned site' + ) + + class Meta: + model = MyModel + fields = ('name', 'status', 'site', 'comments') +``` + +### `NetBoxModelBulkEditForm` + +This form facilitates editing multiple objects in bulk. Unlike a model form, this form does not have a child `Meta` class, and must explicitly define each field. All fields in a bulk edit form are generally declared with `required=False`. + +| Attribute | Description | +|-------------------|---------------------------------------------------------------------------------------------| +| `model` | The model of object being edited | +| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | +| `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) | + +**Example** + +```python +from django import forms +from dcim.models import Site +from netbox.forms import NetBoxModelCSVForm +from utilities.forms import CommentField, DynamicModelChoiceField +from .models import MyModel, MyModelStatusChoices + +class MyModelEditForm(NetBoxModelCSVForm): + name = forms.CharField( + required=False + ) + status = forms.ChoiceField( + choices=MyModelStatusChoices, + required=False + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + comments = CommentField() + + model = MyModel + fieldsets = ( + ('Model Stuff', ('name', 'status', 'site')), + ) + nullable_fields = ('site', 'comments') +``` + +### `NetBoxModelFilterSetForm` + +This form class is used to render a form expressly for filtering a list of objects. Its fields should correspond to filters defined on the model's filter set. + +| Attribute | Description | +|-------------------|-------------------------------------------------------------| +| `model` | The model of object being edited | +| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | + +**Example** + +```python +from dcim.models import Site +from netbox.forms import NetBoxModelFilterSetForm +from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField +from .models import MyModel, MyModelStatusChoices + +class MyModelFilterForm(NetBoxModelFilterSetForm): + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False + ) + status = MultipleChoiceField( + choices=MyModelStatusChoices, + required=False + ) + + model = MyModel +``` + +## General Purpose Fields + +In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below. + +::: utilities.forms.ColorField + selection: + members: false + +::: utilities.forms.CommentField + selection: + members: false + +::: utilities.forms.JSONField + selection: + members: false + +::: utilities.forms.MACAddressField + selection: + members: false + +::: utilities.forms.SlugField + selection: + members: false + +## Choice Fields + +::: utilities.forms.ChoiceField + selection: + members: false + +::: utilities.forms.MultipleChoiceField + selection: + members: false + +## Dynamic Object Fields + +::: utilities.forms.DynamicModelChoiceField + selection: + members: false + +::: utilities.forms.DynamicModelMultipleChoiceField + selection: + members: false + +## Content Type Fields + +::: utilities.forms.ContentTypeChoiceField + selection: + members: false + +::: utilities.forms.ContentTypeMultipleChoiceField + selection: + members: false + +## CSV Import Fields + +::: utilities.forms.CSVChoiceField + selection: + members: false + +::: utilities.forms.CSVMultipleChoiceField + selection: + members: false + +::: utilities.forms.CSVModelChoiceField + selection: + members: false + +::: utilities.forms.CSVContentTypeField + selection: + members: false + +::: utilities.forms.CSVMultipleContentTypeField + selection: + members: false diff --git a/docs/plugins/development/graphql-api.md b/docs/plugins/development/graphql-api.md new file mode 100644 index 000000000..0dadf021f --- /dev/null +++ b/docs/plugins/development/graphql-api.md @@ -0,0 +1,52 @@ +# GraphQL API + +## Defining the Schema Class + +A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class. This class must be a subclass of `graphene.ObjectType`. + +### Example + +```python +# graphql.py +import graphene +from netbox.graphql.types import NetBoxObjectType +from netbox.graphql.fields import ObjectField, ObjectListField +from . import filtersets, models + +class MyModelType(NetBoxObjectType): + + class Meta: + model = models.MyModel + fields = '__all__' + filterset_class = filtersets.MyModelFilterSet + +class MyQuery(graphene.ObjectType): + mymodel = ObjectField(MyModelType) + mymodel_list = ObjectListField(MyModelType) + +schema = MyQuery +``` + +## GraphQL Objects + +NetBox provides two object type classes for use by plugins. + +::: netbox.graphql.types.BaseObjectType + selection: + members: false + +::: netbox.graphql.types.NetBoxObjectType + selection: + members: false + +## GraphQL Fields + +NetBox provides two field classes for use by plugins. + +::: netbox.graphql.fields.ObjectField + selection: + members: false + +::: netbox.graphql.fields.ObjectListField + selection: + members: false diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md new file mode 100644 index 000000000..98db9e0bb --- /dev/null +++ b/docs/plugins/development/index.md @@ -0,0 +1,171 @@ +# Plugins Development + +!!! tip "Plugins Development Tutorial" + Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time! + +NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently. + +!!! info "Django Development" + Django is the Python framework on which NetBox is built. As Django itself is very well-documented, this documentation covers only the aspects of plugin development which are unique to NetBox. + +Plugins can do a lot, including: + +* Create Django models to store data in the database +* Provide their own "pages" (views) in the web user interface +* Inject template content and navigation links +* Extend NetBox's REST and GraphQL APIs +* Add custom request/response middleware + +However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. + +!!! warning + While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework to avoid breaking changes in future releases. + +## Plugin Structure + +Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin might look something like this: + +```no-highlight +project-name/ + - plugin_name/ + - api/ + - __init__.py + - serializers.py + - urls.py + - views.py + - migrations/ + - __init__.py + - 0001_initial.py + - ... + - templates/ + - plugin_name/ + - *.html + - __init__.py + - filtersets.py + - graphql.py + - models.py + - middleware.py + - navigation.py + - signals.py + - tables.py + - template_content.py + - urls.py + - views.py + - README.md + - setup.py +``` + +The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: + +* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. +* `README.md` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write `README` files using a markup language such as Markdown to enable human-friendly display. +* The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores. + +The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class, discussed below. + +## PluginConfig + +The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: + +```python +from extras.plugins import PluginConfig + +class FooBarConfig(PluginConfig): + name = 'foo_bar' + verbose_name = 'Foo Bar' + description = 'An example NetBox plugin' + version = '0.1' + author = 'Jeremy Stretch' + author_email = 'author@example.com' + base_url = 'foo-bar' + required_settings = [] + default_settings = { + 'baz': True + } + +config = FooBarConfig +``` + +NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. + +### PluginConfig Attributes + +| Name | Description | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------| +| `name` | Raw plugin name; same as the plugin's source directory | +| `verbose_name` | Human-friendly name for the plugin | +| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `description` | Brief description of the plugin's purpose | +| `author` | Name of plugin's author | +| `author_email` | Author's public email address | +| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | +| `required_settings` | A list of any configuration parameters that **must** be defined by the user | +| `default_settings` | A dictionary of configuration parameters and their default values | +| `min_version` | Minimum version of NetBox with which the plugin is compatible | +| `max_version` | Maximum version of NetBox with which the plugin is compatible | +| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | +| `queues` | A list of custom background task queues to create | +| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | +| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) | +| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | + +All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. + +## Create setup.py + +`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: + +```python +from setuptools import find_packages, setup + +setup( + name='my-example-plugin', + version='0.1', + description='An example NetBox plugin', + url='https://github.com/jeremystretch/my-example-plugin', + author='Jeremy Stretch', + license='Apache 2.0', + install_requires=[], + packages=find_packages(), + include_package_data=True, + zip_safe=False, +) +``` + +Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). + +!!! info + `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) + +## Create a Virtual Environment + +It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.) + +```shell +python3 -m venv ~/.virtualenvs/my_plugin +``` + +You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) + +```shell +echo /opt/netbox/netbox > $VENV/lib/python3.8/site-packages/netbox.pth +``` + +## Development Installation + +To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): + +```no-highlight +$ python setup.py develop +``` + +## Configure NetBox + +To enable the plugin in NetBox, add it to the `PLUGINS` parameter in `configuration.py`: + +```python +PLUGINS = [ + 'my_plugin', +] +``` diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md new file mode 100644 index 000000000..0e1fec6e5 --- /dev/null +++ b/docs/plugins/development/models.md @@ -0,0 +1,185 @@ +# Database Models + +## Creating Models + +If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Instances of a model (objects) can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. + +Below is an example `models.py` file containing a model with two character (text) fields: + +```python +from django.db import models + +class MyModel(models.Model): + foo = models.CharField(max_length=50) + bar = models.CharField(max_length=50) + + def __str__(self): + return f'{self.foo} {self.bar}' +``` + +Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`. + +## Enabling NetBox Features + +Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including: + +* Change logging +* Custom fields +* Custom links +* Custom validation +* Export templates +* Journaling +* Tags +* Webhooks + +This class performs two crucial functions: + +1. Apply any fields, methods, and/or attributes necessary to the operation of these features +2. Register the model with NetBox as utilizing these features + +Simply subclass BaseModel when defining a model in your plugin: + +```python +# models.py +from django.db import models +from netbox.models import NetBoxModel + +class MyModel(NetBoxModel): + foo = models.CharField() + ... +``` + +### Enabling Features Individually + +If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) + +For example, if we wanted to support only tags and export templates, we would inherit from NetBox's `ExportTemplatesMixin` and `TagsMixin` classes, and from Django's `Model` class. (Inheriting _all_ the available mixins is essentially the same as subclassing `NetBoxModel`.) + +```python +# models.py +from django.db import models +from netbox.models.features import ExportTemplatesMixin, TagsMixin + +class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +## Database Migrations + +Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.) + +!!! note Enable Developer Mode + NetBox enforces a safeguard around the `makemigrations` command to protect regular users from inadvertently creating erroneous schema migrations. To enable this command for plugin development, set `DEVELOPER=True` in `configuration.py`. + +```no-highlight +$ ./manage.py makemigrations my_plugin +Migrations for 'my_plugin': + /home/jstretch/animal_sounds/my_plugin/migrations/0001_initial.py + - Create model MyModel +``` + +Next, we can apply the migration to the database with the `migrate` command: + +```no-highlight +$ ./manage.py migrate my_plugin +Operations to perform: + Apply all migrations: my_plugin +Running migrations: + Applying my_plugin.0001_initial... OK +``` + +For more information about database migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). + +## Feature Mixins Reference + +!!! warning + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. + +::: netbox.models.features.ChangeLoggingMixin + +::: netbox.models.features.CustomLinksMixin + +::: netbox.models.features.CustomFieldsMixin + +::: netbox.models.features.CustomValidationMixin + +::: netbox.models.features.ExportTemplatesMixin + +::: netbox.models.features.JournalingMixin + +::: netbox.models.features.TagsMixin + +::: netbox.models.features.WebhooksMixin + +## Choice Sets + +For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.) + +To define choices for a model field, subclass `ChoiceSet` and define a tuple named `CHOICES`, of which each member is a two- or three-element tuple. These elements are: + +* The database value +* The corresponding human-friendly label +* The assigned color (optional) + +A complete example is provided below. + +!!! note + Authors may find it useful to declare each of the database values as constants on the class, and reference them within `CHOICES` members. This convention allows the values to be referenced from outside the class, however it is not strictly required. + +### Dynamic Configuration + +Some model field choices in NetBox can be configured by an administrator. For example, the default values for the Site model's `status` field can be replaced or supplemented with custom choices. To enable dynamic configuration for a ChoiceSet subclass, define its `key` as a string specifying the model and field name to which it applies. For example: + +```python +from utilities.choices import ChoiceSet + +class StatusChoices(ChoiceSet): + key = 'MyModel.status' +``` + +To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/optional-settings.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as: + +```python +FIELD_CHOICES = { + 'my_plugin.MyModel.status': ( + # Custom choices + ) +} +``` + +### Example + +```python +# choices.py +from utilities.choices import ChoiceSet + +class StatusChoices(ChoiceSet): + key = 'MyModel.status' + + STATUS_FOO = 'foo' + STATUS_BAR = 'bar' + STATUS_BAZ = 'baz' + + CHOICES = [ + (STATUS_FOO, 'Foo', 'red'), + (STATUS_BAR, 'Bar', 'green'), + (STATUS_BAZ, 'Baz', 'blue'), + ] +``` + +!!! warning + For dynamic configuration to work properly, `CHOICES` must be a mutable list, rather than a tuple. + +```python +# models.py +from django.db import models +from .choices import StatusChoices + +class MyModel(models.Model): + status = models.CharField( + max_length=50, + choices=StatusChoices, + default=StatusChoices.STATUS_FOO + ) +``` diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md new file mode 100644 index 000000000..52ae953a7 --- /dev/null +++ b/docs/plugins/development/navigation.md @@ -0,0 +1,50 @@ +# Navigation + +## Menu Items + +To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. + +!!! tip + The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance. + +```python +from extras.plugins import PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_animal_sounds:random_animal', + link_text='Random sound', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) + ), +) +``` + +A `PluginMenuItem` has the following attributes: + +| Attribute | Required | Description | +|---------------|----------|------------------------------------------------------| +| `link` | Yes | Name of the URL path to which this menu item links | +| `link_text` | Yes | The text presented to the user | +| `permissions` | - | A list of permissions required to display this link | +| `buttons` | - | An iterable of PluginMenuButton instances to include | + +## Menu Buttons + +A `PluginMenuButton` has the following attributes: + +| Attribute | Required | Description | +|---------------|----------|--------------------------------------------------------------------| +| `link` | Yes | Name of the URL path to which this button links | +| `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) | +| `icon_class` | Yes | Button icon CSS class* | +| `color` | - | One of the choices provided by `ButtonColorChoices` | +| `permissions` | - | A list of permissions required to display this button | + +*NetBox supports [Material Design Icons](https://materialdesignicons.com/). + +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. diff --git a/docs/plugins/development/rest-api.md b/docs/plugins/development/rest-api.md new file mode 100644 index 000000000..ea195dafc --- /dev/null +++ b/docs/plugins/development/rest-api.md @@ -0,0 +1,117 @@ +# REST API + +Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. + +Generally speaking, there aren't many NetBox-specific components to implementing REST API functionality in a plugin. NetBox employs the [Django REST Framework](https://www.django-rest-framework.org/) (DRF) for its REST API, and plugin authors will find that they can largely replicate the same patterns found in NetBox's implementation. Some brief examples are included here for reference. + +## Code Layout + +The recommended approach is to separate API serializers, views, and URLs into separate modules under the `api/` directory to keep things tidy, particularly for larger projects. The file at `api/__init__.py` can import the relevant components from each submodule to allow import all API components directly from elsewhere. However, this is merely a convention and not strictly required. + +```no-highlight +project-name/ + - plugin_name/ + - api/ + - __init__.py + - serializers.py + - urls.py + - views.py + ... +``` + +## Serializers + +### Model Serializers + +Serializers are responsible for converting Python objects to JSON data suitable for conveying to consumers, and vice versa. NetBox provides the `NetBoxModelSerializer` class for use by plugins to handle the assignment of tags and custom field data. (These features can also be included ad hoc via the `CustomFieldModelSerializer` and `TaggableModelSerializer` classes.) + +#### Example + +To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class. It is generally advisable to include a `url` attribute on each serializer. This will render the direct link to access the object being rendered. + +```python +# api/serializers.py +from rest_framework import serializers +from netbox.api.serializers import NetBoxModelSerializer +from my_plugin.models import MyModel + +class MyModelSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:myplugin-api:mymodel-detail' + ) + + class Meta: + model = MyModel + fields = ('id', 'foo', 'bar', 'baz') +``` + +### Nested Serializers + +There are two cases where it is generally desirable to show only a minimal representation of an object: + +1. When displaying an object related to the one being viewed (for example, the region to which a site is assigned) +2. Listing many objects using "brief" mode + +To accommodate these, it is recommended to create nested serializers accompanying the "full" serializer for each model. NetBox provides the `WritableNestedSerializer` class for just this purpose. This class accepts a primary key value on write, but displays an object representation for read requests. It also includes a read-only `display` attribute which conveys the string representation of the object. + +#### Example + +```python +# api/serializers.py +from rest_framework import serializers +from netbox.api.serializers import WritableNestedSerializer +from my_plugin.models import MyModel + +class NestedMyModelSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:myplugin-api:mymodel-detail' + ) + + class Meta: + model = MyModel + fields = ('id', 'display', 'foo') +``` + +## Viewsets + +Just as in the user interface, a REST API view handles the business logic of displaying and interacting with NetBox objects. NetBox provides the `NetBoxModelViewSet` class, which extends DRF's built-in `ModelViewSet` to handle bulk operations and object validation. + +Unlike the user interface, typically only a single view set is required per model: This view set handles all request types (`GET`, `POST`, `DELETE`, etc.). + +### Example + +To create a viewset for a plugin model, subclass `NetBoxModelViewSet` in `api/views.py`, and define the `queryset` and `serializer_class` attributes. + +```python +# api/views.py +from netbox.api.viewsets import ModelViewSet +from my_plugin.models import MyModel +from .serializers import MyModelSerializer + +class MyModelViewSet(ModelViewSet): + queryset = MyModel.objects.all() + serializer_class = MyModelSerializer +``` + +## Routers + +Routers map URLs to REST API views (endpoints). NetBox does not provide any custom components for this; the [`DefaultRouter`](https://www.django-rest-framework.org/api-guide/routers/#defaultrouter) class provided by DRF should suffice for most use cases. + +Routers should be exposed in `api/urls.py`. This file **must** define a variable named `urlpatterns`. + +### Example + +```python +# api/urls.py +from netbox.api.routers import NetBoxRouter +from .views import MyModelViewSet + +router = NetBoxRouter() +router.register('my-model', MyModelViewSet) +urlpatterns = router.urls +``` + +This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`. + +!!! warning + The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address. diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md new file mode 100644 index 000000000..77e258def --- /dev/null +++ b/docs/plugins/development/tables.md @@ -0,0 +1,88 @@ +# Tables + +NetBox employs the [`django-tables2`](https://django-tables2.readthedocs.io/) library for rendering dynamic object tables. These tables display lists of objects, and can be sorted and filtered by various parameters. + +## NetBoxTable + +To provide additional functionality beyond what is supported by the stock `Table` class in `django-tables2`, NetBox provides the `NetBoxTable` class. This custom table class includes support for: + +* User-configurable column display and ordering +* Custom field & custom link columns +* Automatic prefetching of related objects + +It also includes several default columns: + +* `pk` - A checkbox for selecting the object associated with each table row (where applicable) +* `id` - The object's numeric database ID, as a hyperlink to the object's view (hidden by default) +* `actions` - A dropdown menu presenting object-specific actions available to the user + +### Example + +```python +# tables.py +import django_tables2 as tables +from netbox.tables import NetBoxTable +from .models import MyModel + +class MyModelTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + ... + + class Meta(NetBoxTable.Meta): + model = MyModel + fields = ('pk', 'id', 'name', ...) + default_columns = ('pk', 'name', ...) +``` + +### Table Configuration + +The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example: + +```python +table = MyModelTable(data=MyModel.objects.all()) +table.configure(request) +``` + +This will automatically apply any user-specific preferences for the table. (If using a generic view provided by NetBox, table configuration is handled automatically.) + +## Columns + +The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`. + +::: netbox.tables.BooleanColumn + selection: + members: false + +::: netbox.tables.ChoiceFieldColumn + selection: + members: false + +::: netbox.tables.ColorColumn + selection: + members: false + +::: netbox.tables.ColoredLabelColumn + selection: + members: false + +::: netbox.tables.ContentTypeColumn + selection: + members: false + +::: netbox.tables.ContentTypesColumn + selection: + members: false + +::: netbox.tables.MarkdownColumn + selection: + members: false + +::: netbox.tables.TagColumn + selection: + members: false + +::: netbox.tables.TemplateColumn + selection: + members: false diff --git a/docs/plugins/development/templates.md b/docs/plugins/development/templates.md new file mode 100644 index 000000000..64616c442 --- /dev/null +++ b/docs/plugins/development/templates.md @@ -0,0 +1,247 @@ +# Templates + +Templates are used to render HTML content generated from a set of context data. NetBox provides a set of built-in templates suitable for use in plugin views. Plugin authors can extend these templates to minimize the work needed to create custom templates while ensuring that the content they produce matches NetBox's layout and style. These templates are all written in the [Django Template Language (DTL)](https://docs.djangoproject.com/en/stable/ref/templates/language/). + +## Template File Structure + +Plugin templates should live in the `templates//` path within the plugin root. For example if your plugin's name is `my_plugin` and you create a template named `foo.html`, it should be saved to `templates/my_plugin/foo.html`. (You can of course use subdirectories below this point as well.) This ensures that Django's template engine can locate the template for rendering. + +## Standard Blocks + +The following template blocks are available on all templates. + +| Name | Required | Description | +|----------------|----------|---------------------------------------------------------------------| +| `title` | Yes | Page title | +| `content` | Yes | Page content | +| `head` | - | Content to include in the HTML `` element | +| `footer` | - | Page footer content | +| `footer_links` | - | Links section of the page footer | +| `javascript` | - | Javascript content included at the end of the HTML `` element | + +!!! note + For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). + +## Base Templates + +### layout.html + +Path: `base/layout.html` + +NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This is a general-purpose template that can be used when none of the function-specific templates below are suitable. + +#### Blocks + +| Name | Required | Description | +|-----------|----------|----------------------------| +| `header` | - | Page header | +| `tabs` | - | Horizontal navigation tabs | +| `modals` | - | Bootstrap 5 modal elements | + +#### Example + +An example of a plugin template which extends `layout.html` is included below. + +```jinja2 +{% extends 'base/layout.html' %} + +{% block header %} +

My Custom Header

+{% endblock header %} + +{% block content %} +

{{ some_plugin_context_var }}

+{% endblock content %} +``` + +The first line of the template instructs Django to extend the NetBox base template, and the `block` sections inject our custom content within its `header` and `content` blocks. + +!!! note + Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important distinctions of which authors should be aware. Be sure to familiarize yourself with Django's template language before attempting to create new templates. + +## Generic View Templates + +### object.html + +Path: `generic/object.html` + +This template is used by the `ObjectView` generic view to display a single object. + +#### Blocks + +| Name | Required | Description | +|---------------------|----------|----------------------------------------------| +| `breadcrumbs` | - | Breadcrumb list items (HTML `
  • ` elements) | +| `object_identifier` | - | A unique identifier (string) for the object | +| `extra_controls` | - | Additional action buttons to display | +| `extra_tabs` | - | Additional tabs to include | + +#### Context + +| Name | Required | Description | +|----------|----------|----------------------------------| +| `object` | Yes | The object instance being viewed | + +### object_edit.html + +Path: `generic/object_edit.html` + +This template is used by the `ObjectEditView` generic view to create or modify a single object. + +#### Blocks + +| Name | Required | Description | +|------------------|----------|-------------------------------------------------------| +| `form` | - | Custom form content (within the HTML `
    ` element | +| `buttons` | - | Form submission buttons | + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `object` | Yes | The object instance being modified (or none, if creating) | +| `form` | Yes | The form class for creating/modifying the object | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +### object_delete.html + +Path: `generic/object_delete.html` + +This template is used by the `ObjectDeleteView` generic view to delete a single object. + +#### Blocks + +None + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `object` | Yes | The object instance being deleted | +| `form` | Yes | The form class for confirming the object's deletion | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +### object_list.html + +Path: `generic/object_list.html` + +This template is used by the `ObjectListView` generic view to display a filterable list of multiple objects. + +#### Blocks + +| Name | Required | Description | +|------------------|----------|--------------------------------------------------------------------| +| `extra_controls` | - | Additional action buttons | +| `bulk_buttons` | - | Additional bulk action buttons to display beneath the objects list | + +#### Context + +| Name | Required | Description | +|---------------|----------|---------------------------------------------------------------------------------------------| +| `model` | Yes | The object class | +| `table` | Yes | The table class used for rendering the list of objects | +| `permissions` | Yes | A mapping of add, change, and delete permissions for the current user | +| `actions` | Yes | A list of buttons to display (`add`, `import`, `export`, `bulk_edit`, and/or `bulk_delete`) | +| `filter_form` | - | The bound filterset form for filtering the objects list | +| `return_url` | - | The return URL to pass when submitting a bulk operation form | + +### bulk_import.html + +Path: `generic/bulk_import.html` + +This template is used by the `BulkImportView` generic view to import multiple objects at once from CSV data. + +#### Blocks + +None + +#### Context + +| Name | Required | Description | +|--------------|----------|--------------------------------------------------------------| +| `model` | Yes | The object class | +| `form` | Yes | The CSV import form class | +| `return_url` | - | The return URL to pass when submitting a bulk operation form | +| `fields` | - | A dictionary of form fields, to display import options | + +### bulk_edit.html + +Path: `generic/bulk_edit.html` + +This template is used by the `BulkEditView` generic view to modify multiple objects simultaneously. + +#### Blocks + +None + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `model` | Yes | The object class | +| `form` | Yes | The bulk edit form class | +| `table` | Yes | The table class used for rendering the list of objects | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +### bulk_delete.html + +Path: `generic/bulk_delete.html` + +This template is used by the `BulkDeleteView` generic view to delete multiple objects simultaneously. + +#### Blocks + +| Name | Required | Description | +|-----------------|----------|---------------------------------------| +| `message_extra` | - | Supplementary warning message content | + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `model` | Yes | The object class | +| `form` | Yes | The bulk delete form class | +| `table` | Yes | The table class used for rendering the list of objects | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +## Tags + +The following custom template tags are available in NetBox. + +!!! info + These are loaded automatically by the template backend: You do _not_ need to include a `{% load %}` tag in your template to activate them. + +::: utilities.templatetags.builtins.tags.badge + +::: utilities.templatetags.builtins.tags.checkmark + +::: utilities.templatetags.builtins.tags.tag + +## Filters + +The following custom template filters are available in NetBox. + +!!! info + These are loaded automatically by the template backend: You do _not_ need to include a `{% load %}` tag in your template to activate them. + +::: utilities.templatetags.builtins.filters.bettertitle + +::: utilities.templatetags.builtins.filters.content_type + +::: utilities.templatetags.builtins.filters.content_type_id + +::: utilities.templatetags.builtins.filters.linkify + +::: utilities.templatetags.builtins.filters.meta + +::: utilities.templatetags.builtins.filters.placeholder + +::: utilities.templatetags.builtins.filters.render_json + +::: utilities.templatetags.builtins.filters.render_markdown + +::: utilities.templatetags.builtins.filters.render_yaml + +::: utilities.templatetags.builtins.filters.split + +::: utilities.templatetags.builtins.filters.tzoffset diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md new file mode 100644 index 000000000..92626f8d3 --- /dev/null +++ b/docs/plugins/development/views.md @@ -0,0 +1,177 @@ +# Views + +## Writing Views + +If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`. + +As an example, let's write a view which displays a random animal and the sound it makes. We'll use Django's generic `View` class to minimize the amount of boilerplate code needed. + +```python +from django.shortcuts import render +from django.views.generic import View +from .models import Animal + +class RandomAnimalView(View): + """ + Display a randomly-selected animal. + """ + def get(self, request): + animal = Animal.objects.order_by('?').first() + return render(request, 'netbox_animal_sounds/animal.html', { + 'animal': animal, + }) +``` + +This view retrieves a random Animal instance from the database and passes it as a context variable when rendering a template named `animal.html`. HTTP `GET` requests are handled by the view's `get()` method, and `POST` requests are handled by its `post()` method. + +Our example above is extremely simple, but views can do just about anything. They are generally where the core of your plugin's functionality will reside. Views also are not limited to returning HTML content: A view could return a CSV file or image, for instance. For more information on views, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/class-based-views/). + +### URL Registration + +To make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths. + +```python +from django.urls import path +from . import views + +urlpatterns = [ + path('random/', views.RandomAnimalView.as_view(), name='random_animal'), +] +``` + +A URL pattern has three components: + +* `route` - The unique portion of the URL dedicated to this view +* `view` - The view itself +* `name` - A short name used to identify the URL path internally + +This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. + +### View Classes + +NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. + +| View Class | Description | +|--------------------|--------------------------------| +| `ObjectView` | View a single object | +| `ObjectEditView` | Create or edit a single object | +| `ObjectDeleteView` | Delete a single object | +| `ObjectListView` | View a list of objects | +| `BulkImportView` | Import a set of new objects | +| `BulkEditView` | Edit multiple objects | +| `BulkDeleteView` | Delete multiple objects | + +!!! warning + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. + +#### Example Usage + +```python +# views.py +from netbox.views.generic import ObjectEditView +from .models import Thing + +class ThingEditView(ObjectEditView): + queryset = Thing.objects.all() + template_name = 'myplugin/thing.html' + ... +``` +## Object Views + +Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly. + +::: netbox.views.generic.base.BaseObjectView + +::: netbox.views.generic.ObjectView + selection: + members: + - get_object + - get_template_name + +::: netbox.views.generic.ObjectEditView + selection: + members: + - get_object + - alter_object + +::: netbox.views.generic.ObjectDeleteView + selection: + members: + - get_object + +## Multi-Object Views + +Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly. + +::: netbox.views.generic.base.BaseMultiObjectView + +::: netbox.views.generic.ObjectListView + selection: + members: + - get_table + - export_table + - export_template + +::: netbox.views.generic.BulkImportView + selection: + members: false + +::: netbox.views.generic.BulkEditView + selection: + members: false + +::: netbox.views.generic.BulkDeleteView + selection: + members: + - get_form + +## Feature Views + +These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path. + +::: netbox.views.generic.ObjectChangeLogView + selection: + members: + - get_form + +::: netbox.views.generic.ObjectJournalView + selection: + members: + - get_form + +## Extending Core Views + +Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: + +* `left_page()` - Inject content on the left side of the page +* `right_page()` - Inject content on the right side of the page +* `full_width_page()` - Inject content across the entire bottom of the page +* `buttons()` - Add buttons to the top of the page + +Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. + +When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: + +* `object` - The object being viewed +* `request` - The current request +* `settings` - Global NetBox settings +* `config` - Plugin-specific configuration parameters + +For example, accessing `{{ request.user }}` within a template will return the current user. + +Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. + +```python +from extras.plugins import PluginTemplateExtension +from .models import Animal + +class SiteAnimalCount(PluginTemplateExtension): + model = 'dcim.site' + + def right_page(self): + return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ + 'animal_count': Animal.objects.count(), + }) + +template_extensions = [SiteAnimalCount] +``` diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 13c129398..f8bb365be 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,18 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 3.2](./version-3.2.md) (April 2022) + +* Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) +* Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) +* Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) +* Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) +* Improved User Preferences ([#7759](https://github.com/netbox-community/netbox/issues/7759)) +* Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) +* Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) +* Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) +* Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) + #### [Version 3.1](./version-3.1.md) (December 2021) * Contact Objects ([#1344](https://github.com/netbox-community/netbox/issues/1344)) 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.1.md b/docs/release-notes/version-3.1.md index ed713102d..27ba4e69e 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,11 +1,50 @@ # NetBox v3.1 -## v3.1.10 (FUTURE) +## v3.1.11 (2022-04-05) + +### Enhancements + +* [#8163](https://github.com/netbox-community/netbox/issues/8163) - Show bridge interface members under interface view +* [#8365](https://github.com/netbox-community/netbox/issues/8365) - Enable filtering child devices by parent device ID +* [#8785](https://github.com/netbox-community/netbox/issues/8785) - Permit wildcard values in IP address DNS names +* [#8790](https://github.com/netbox-community/netbox/issues/8790) - Include site and prefixes columns in VLAN group VLANs table +* [#8830](https://github.com/netbox-community/netbox/issues/8830) - Add Checkpoint ClusterXL protocol for FHRP groups +* [#8974](https://github.com/netbox-community/netbox/issues/8974) - Use monospace font for text areas in config revision form +* [#9012](https://github.com/netbox-community/netbox/issues/9012) - Linkify circuits count in providers list +* [#9036](https://github.com/netbox-community/netbox/issues/9036) - Add bulk edit capability for site contact fields ### Bug Fixes +* [#8866](https://github.com/netbox-community/netbox/issues/8866) - Prevent exception when searching for a rack position with no rack specified under device edit view +* [#9009](https://github.com/netbox-community/netbox/issues/9009) - Fix device count for racks in global search results + +--- + +## v3.1.10 (2022-03-25) + +### Enhancements + +* [#8232](https://github.com/netbox-community/netbox/issues/8232) - Use a different color for 100% utilization bars +* [#8457](https://github.com/netbox-community/netbox/issues/8457) - Enable adding non-racked devices from site & location views +* [#8553](https://github.com/netbox-community/netbox/issues/8553) - Add missing object types to global search form +* [#8575](https://github.com/netbox-community/netbox/issues/8575) - Add rack columns to cables list +* [#8645](https://github.com/netbox-community/netbox/issues/8645) - Enable filtering objects by assigned contacts & contact roles +* [#8926](https://github.com/netbox-community/netbox/issues/8926) - Add device type, role columns to device bay table + +### Bug Fixes + +* [#8696](https://github.com/netbox-community/netbox/issues/8696) - Fix help link under FHRP group assigment creation view +* [#8813](https://github.com/netbox-community/netbox/issues/8813) - Retain global search bar query after submitting * [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode * [#8850](https://github.com/netbox-community/netbox/issues/8850) - Show airflow field on device REST API serializer when config context data is included +* [#8905](https://github.com/netbox-community/netbox/issues/8905) - Disable ordering by assigned tags to prevent erroneous results +* [#8919](https://github.com/netbox-community/netbox/issues/8919) - Fix filtering of VLAN groups by site under prefix edit form +* [#8924](https://github.com/netbox-community/netbox/issues/8924) - Improve load time of custom script list +* [#8932](https://github.com/netbox-community/netbox/issues/8932) - Fix error when setting null value for interface `rf_role` via REST API +* [#8935](https://github.com/netbox-community/netbox/issues/8935) - Correct ordering of next/previous racks to use naturalized names +* [#8947](https://github.com/netbox-community/netbox/issues/8947) - Retain filter parameters when handling an export template exception +* [#8951](https://github.com/netbox-community/netbox/issues/8951) - Allow changing device type & platform to different manufacturer simultaneously +* [#8952](https://github.com/netbox-community/netbox/issues/8952) - Device images in rear rack elevations should be hyperlinked --- diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md new file mode 100644 index 000000000..1760d4d2e --- /dev/null +++ b/docs/release-notes/version-3.2.md @@ -0,0 +1,290 @@ +# NetBox v3.2 + +## v3.2.3 (FUTURE) + +--- + +## 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 + +--- + +## v3.2.1 (2022-04-14) + +### Enhancements + +* [#5479](https://github.com/netbox-community/netbox/issues/5479) - Allow custom job timeouts for scripts & reports +* [#8543](https://github.com/netbox-community/netbox/issues/8543) - Improve filtering for wireless LAN VLAN selection +* [#8920](https://github.com/netbox-community/netbox/issues/8920) - Limit number of non-racked devices displayed +* [#8956](https://github.com/netbox-community/netbox/issues/8956) - Retain old script/report results for configured lifetime +* [#8973](https://github.com/netbox-community/netbox/issues/8973) - Display VLAN group count under site view +* [#9081](https://github.com/netbox-community/netbox/issues/9081) - Add `fhrpgroup_id` filter for IP addresses +* [#9099](https://github.com/netbox-community/netbox/issues/9099) - Enable display of installed module serial & asset tag in module bays list +* [#9110](https://github.com/netbox-community/netbox/issues/9110) - Add Neutrik proprietary power connectors +* [#9123](https://github.com/netbox-community/netbox/issues/9123) - Improve appearance of SSO login providers + +### Bug Fixes + +* [#8931](https://github.com/netbox-community/netbox/issues/8931) - Copy assigned tenant when cloning a location +* [#9055](https://github.com/netbox-community/netbox/issues/9055) - Restore ability to move inventory item to other device +* [#9057](https://github.com/netbox-community/netbox/issues/9057) - Fix missing instance counts for module types +* [#9061](https://github.com/netbox-community/netbox/issues/9061) - Fix general search for device components +* [#9065](https://github.com/netbox-community/netbox/issues/9065) - Min/max VID should not be required when filtering VLAN groups +* [#9079](https://github.com/netbox-community/netbox/issues/9079) - Fail validation when an inventory item is assigned as its own parent +* [#9096](https://github.com/netbox-community/netbox/issues/9096) - Remove duplicate filter tag when filtering by "none" +* [#9100](https://github.com/netbox-community/netbox/issues/9100) - Include position field in module type YAML export +* [#9116](https://github.com/netbox-community/netbox/issues/9116) - `assigned_to_interface` filter for IP addresses should not match FHRP group assignments +* [#9118](https://github.com/netbox-community/netbox/issues/9118) - Fix validation error when importing VM child interfaces +* [#9128](https://github.com/netbox-community/netbox/issues/9128) - Resolve component labels per module bay position when installing modules + +--- + +## v3.2.0 (2022-04-05) + +!!! warning "Python 3.8 or Later Required" + NetBox v3.2 requires Python 3.8 or later. + +!!! warning "Deletion of Legacy Data" + This release includes a database migration that will remove the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields from the site model. (These fields have been superseded by the ASN and contact models introduced in NetBox v3.1.) To protect against the accidental destruction of data, the upgrade process **will fail** if any sites still have data in any of these fields. To bypass this safeguard, set the `NETBOX_DELETE_LEGACY_DATA` environment variable when running the upgrade script, which will permit the destruction of legacy data. + +!!! tip "Migration Scripts" + A set of [migration scripts](https://github.com/netbox-community/migration-scripts) is available to assist with the migration of legacy site data. + +### Breaking Changes + +* Automatic redirection of legacy slug-based URL paths has been removed. URL-based slugs were changed to use numeric IDs in v2.11.0. +* The `asn` field has been removed from the site model. Please replicate any site ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. +* The `asn` query filter for sites now matches against the AS number of assigned ASN objects. +* The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. +* The `created` field of all change-logged models now conveys a full datetime object, rather than only a date. (Previous date-only values will receive a timestamp of 00:00.) While this change is largely unconcerning, strictly-typed API consumers may need to be updated. +* A `pre_run()` method has been added to the base Report class. Although unlikely to affect most installations, you may need to alter any reports which already use this name for a method. +* Webhook URLs now support Jinja2 templating. Although this is unlikely to introduce any issues, it's possible that an unusual URL might trigger a Jinja2 rendering error, in which case the URL would need to be properly escaped. + +### New Features + +#### Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) + +NetBox's plugins framework has been extended considerably in this release. Additions include: + +* Officially-supported generic view classes for common CRUD operations: + * `ObjectView` + * `ObjectEditView` + * `ObjectDeleteView` + * `ObjectListView` + * `BulkImportView` + * `BulkEditView` + * `BulkDeleteView` +* The `NetBoxModel` base class, which enables various NetBox features, including: + * Change logging + * Custom fields + * Custom links + * Custom validation + * Export templates + * Journaling + * Tags + * Webhooks +* Four base form classes for manipulating objects via the UI: + * `NetBoxModelForm` + * `NetBoxModelCSVForm` + * `NetBoxModelBulkEditForm` + * `NetBoxModelFilterSetForm` +* The `NetBoxModelFilterSet` base class for plugin filter sets +* The `NetBoxTable` base class for rendering object tables with `django-tables2`, as well as various custom column classes +* Function-specific templates (for generic views) +* Various custom template tags and filters +* `NetBoxModelViewSet` and several base serializer classes now provide enhanced REST API functionality +* Plugins can now extend NetBox's GraphQL API with their own schema + +No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their existing code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized. + +#### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) + +Several new models have been added to represent field-replaceable device modules, such as line cards installed within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components (interfaces, console ports, etc.) associated with it. These components become available to the parent device once the module has been installed within a module bay. This provides a convenient mechanism to effect the addition and deletion of device components as modules are installed and removed. + +Automatic renaming of module components is also supported. When a new module is created, any occurrence of the string `{module}` in a component name will be replaced with the position of the module bay into which the module is being installed. + +As with device types, the NetBox community offers a selection of curated real-world module type definitions in our [device type library](https://github.com/netbox-community/devicetype-library). These YAML files can be imported directly to NetBox for your convenience. + +#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) + +Two new types of custom field have been introduced: object and multi-object. These can be used to associate an object in NetBox with some other arbitrary object(s) regardless of its type. For example, you might create a custom field named `primary_site` on the tenant model so that each tenant can have particular site designated as its primary. The multi-object custom field type allows for the assignment of multiple objects of the same type. + +Custom field object assignment is fully supported in the REST API, and functions similarly to built-in foreign key relations. Nested representations are provided automatically for each custom field object. + +#### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) + +Custom choices can be now added to most object status fields in NetBox. This is done by defining the [`FIELD_CHOICES`](../configuration/optional-settings.md#field_choices) configuration parameter to map field identifiers to an iterable of custom choices an (optionally) colors. These choices are populated automatically when NetBox initializes. For example, the following configuration will add three custom choices for the site status field, each with a designated color: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), + ) +} +``` + +This will replace all default choices for this field with those listed. If instead the intent is to _extend_ the set of default choices, this can be done by appending a plus sign (`+`) to the end of the field identifier. For example, the following will add a single extra choice while retaining the defaults provided by NetBox: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ('fubar', 'FUBAR', 'red'), + ) +} +``` + +#### Improved User Preferences ([#7759](https://github.com/netbox-community/netbox/issues/7759)) + +A robust new mechanism for managing user preferences is included in this release. The user preferences form has been improved for better usability, and administrators can now define default preferences for all users with the [`DEFAULT_USER_PREFERENCES`](../configuration/dynamic-settings.md##default_user_preferences) configuration parameter. For example, this can be used to define the columns which appear by default in a table: + +```python +DEFAULT_USER_PREFERENCES = { + 'tables': { + 'IPAddressTable': { + 'columns': ['address', 'status', 'created', 'description'] + } + } +} +``` + +Users can adjust their own preferences under their user profile. A complete list of supported preferences is available in NetBox's [developer documentation](../development/user-preferences.md). + +#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) + +A new model has been introduced to represent functional roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. + +#### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) + +Inventory items can now be templatized on a device type similar to other components (such as interfaces or console ports). This enables users to better pre-model fixed hardware components such as power supplies or hard disks. + +Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other templated components. These relationships will be mirrored when instantiating inventory items on a newly-created device (see [#7846](https://github.com/netbox-community/netbox/issues/7846)). For example, if defining an optic assigned to an interface template on a device type, the instantiated device will mimic this relationship between the optic and interface. + +#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) + +A new service template model has been introduced to assist in standardizing the definition and association of applications with devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. + +#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) + +A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically from the available pool. + +Where it is desired to limit the range of available VLANs within a group, users can define a minimum and/or maximum VLAN ID per group (see [#8168](https://github.com/netbox-community/netbox/issues/8168)). + +### Enhancements + +* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table pagination controls +* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences +* [#7650](https://github.com/netbox-community/netbox/issues/7650) - Expose `AUTH_PASSWORD_VALIDATORS` setting to enforce password validation for local accounts +* [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables +* [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks +* [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts +* [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components +* [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable the assignment of interfaces to VRFs +* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to device interface model +* [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group +* [#8295](https://github.com/netbox-community/netbox/issues/8295) - Jinja2 rendering is now supported for webhook URLs +* [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links +* [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields +* [#8463](https://github.com/netbox-community/netbox/issues/8463) - Change the `created` field on all change-logged models from date to datetime +* [#8496](https://github.com/netbox-community/netbox/issues/8496) - Enable assigning multiple ASNs to a provider +* [#8572](https://github.com/netbox-community/netbox/issues/8572) - Add a `pre_run()` method for reports +* [#8593](https://github.com/netbox-community/netbox/issues/8593) - Add a `link` field for contacts +* [#8649](https://github.com/netbox-community/netbox/issues/8649) - Enable customization of configuration module using `NETBOX_CONFIGURATION` environment variable +* [#9006](https://github.com/netbox-community/netbox/issues/9006) - Enable custom fields, custom links, and tags for journal entries + +### Bug Fixes (From Beta2) + +* [#8658](https://github.com/netbox-community/netbox/issues/8658) - Fix display of assigned components under inventory item lists +* [#8838](https://github.com/netbox-community/netbox/issues/8838) - Fix FieldError exception during global search +* [#8845](https://github.com/netbox-community/netbox/issues/8845) - Correct default ASN formatting in table +* [#8869](https://github.com/netbox-community/netbox/issues/8869) - Fix NoReverseMatch exception when displaying tag w/assignments +* [#8872](https://github.com/netbox-community/netbox/issues/8872) - Enable filtering by custom object fields +* [#8970](https://github.com/netbox-community/netbox/issues/8970) - Permit nested inventory item templates on device types +* [#8976](https://github.com/netbox-community/netbox/issues/8976) - Add missing `object_type` field on CustomField REST API serializer +* [#8978](https://github.com/netbox-community/netbox/issues/8978) - Fix instantiation of front ports when provisioning a module +* [#9007](https://github.com/netbox-community/netbox/issues/9007) - Fix FieldError exception when instantiating a device type with nested inventory items + +### Other Changes + +* [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later +* [#7743](https://github.com/netbox-community/netbox/issues/7743) - Remove legacy ASN field from site model +* [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model +* [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs +* [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys +* [#8509](https://github.com/netbox-community/netbox/issues/8509) - `CSRF_TRUSTED_ORIGINS` is now a discrete configuration parameter (rather than being populated from `ALLOWED_HOSTS`) +* [#8684](https://github.com/netbox-community/netbox/issues/8684) - Change custom link template context variable `obj` to `object` (backward-compatible) + +### REST API Changes + +* Added the following endpoints: + * `/api/dcim/inventory-item-roles/` + * `/api/dcim/inventory-item-templates/` + * `/api/dcim/modules/` + * `/api/dcim/module-bays/` + * `/api/dcim/module-bay-templates/` + * `/api/dcim/module-types/` + * `/api/ipam/service-templates/` + * `/api/ipam/vlan-groups//available-vlans/` +* circuits.Provider + * Added `asns` field +* circuits.ProviderNetwork + * Added `service_id` field +* dcim.ConsolePort + * Added `module` field +* dcim.ConsoleServerPort + * Added `module` field +* dcim.FrontPort + * Added `module` field +* dcim.Interface + * Added `module`, `speed`, `duplex`, and `vrf` fields +* dcim.InventoryItem + * Added `component_type`, `component_id`, and `role` fields + * Added read-only `component` field (GFK) +* dcim.PowerPort + * Added `module` field +* dcim.PowerOutlet + * Added `module` field +* dcim.RearPort + * Added `module` field +* dcim.Site + * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields +* extras.ConfigContext + * Add `cluster_types` field +* extras.CustomField + * Added `data_type` and `object_type` fields +* extras.CustomLink + * Added `enabled` field +* extras.JournalEntry + * Added `custom_fields` and `tags` fields +* ipam.ASN + * Added `provider_count` field +* ipam.VLANGroup + * Added the `/availables-vlans/` endpoint + * Added `min_vid` and `max_vid` fields +* tenancy.Contact + * Added `link` field +* virtualization.VMInterface + * Added `vrf` field diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index c8726f8e6..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# File inclusion plugin for Python-Markdown -# https://github.com/cmacmackin/markdown-include -markdown-include - -# MkDocs Material theme (for documentation build) -# https://github.com/squidfunk/mkdocs-material -mkdocs-material diff --git a/mkdocs.yml b/mkdocs.yml index 6245f3bb2..225c6d4bf 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: @@ -18,6 +18,24 @@ theme: toggle: icon: material/lightbulb name: Switch to Light Mode +plugins: + - search + - mkdocstrings: + handlers: + python: + setup_commands: + - import os + - import django + - os.chdir('netbox/') + - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") + - django.setup() + rendering: + heading_level: 3 + members_order: source + show_root_heading: true + show_root_full_path: false + show_root_toc_entry: false + show_source: false extra: social: - icon: fontawesome/brands/github @@ -62,6 +80,7 @@ nav: - Sites and Racks: 'core-functionality/sites-and-racks.md' - Devices and Cabling: 'core-functionality/devices.md' - Device Types: 'core-functionality/device-types.md' + - Modules: 'core-functionality/modules.md' - Virtualization: 'core-functionality/virtualization.md' - Service Mapping: 'core-functionality/services.md' - Circuits: 'core-functionality/circuits.md' @@ -86,9 +105,23 @@ nav: - Webhooks: 'additional-features/webhooks.md' - Plugins: - Using Plugins: 'plugins/index.md' - - Developing Plugins: 'plugins/development.md' + - Developing Plugins: + - Getting Started: 'plugins/development/index.md' + - Models: 'plugins/development/models.md' + - Views: 'plugins/development/views.md' + - Navigation: 'plugins/development/navigation.md' + - Templates: 'plugins/development/templates.md' + - Tables: 'plugins/development/tables.md' + - Forms: 'plugins/development/forms.md' + - Filters & Filter Sets: 'plugins/development/filtersets.md' + - REST API: 'plugins/development/rest-api.md' + - GraphQL API: 'plugins/development/graphql-api.md' + - Background Tasks: 'plugins/development/background-tasks.md' - Administration: - - Authentication: 'administration/authentication.md' + - Authentication: + - Overview: 'administration/authentication/overview.md' + - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md' + - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' @@ -115,6 +148,7 @@ nav: - Release Checklist: 'development/release-checklist.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.2: 'release-notes/version-3.2.md' - Version 3.1: 'release-notes/version-3.1.md' - Version 3.0: 'release-notes/version-3.0.md' - Version 2.11: 'release-notes/version-2.11.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 42f9d9322..19570f067 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -4,8 +4,10 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import LinkTerminationSerializer -from netbox.api import ChoiceField -from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer +from ipam.models import ASN +from ipam.api.nested_serializers import NestedASNSerializer +from netbox.api import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -14,15 +16,23 @@ from .nested_serializers import * # Providers # -class ProviderSerializer(PrimaryModelSerializer): +class ProviderSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=NestedASNSerializer, + required=False, + many=True + ) + + # Related object counts circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] @@ -30,15 +40,15 @@ class ProviderSerializer(PrimaryModelSerializer): # Provider networks # -class ProviderNetworkSerializer(PrimaryModelSerializer): +class ProviderNetworkSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') provider = NestedProviderSerializer() class Meta: model = ProviderNetwork fields = [ - 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ] @@ -46,7 +56,7 @@ class ProviderNetworkSerializer(PrimaryModelSerializer): # Circuits # -class CircuitTypeSerializer(PrimaryModelSerializer): +class CircuitTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) @@ -70,7 +80,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer): ] -class CircuitSerializer(PrimaryModelSerializer): +class CircuitSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 5389e0bde..616adfaa4 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,8 +1,8 @@ -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.CircuitsRootView # Providers diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 2b3e3b122..3573c05e3 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,8 +3,7 @@ from rest_framework.routers import APIRootView from circuits import filtersets from circuits.models import * from dcim.api.views import PassThroughPortMixin -from extras.api.views import CustomFieldModelViewSet -from netbox.api.views import ModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet from utilities.utils import count_related from . import serializers @@ -21,8 +20,8 @@ class CircuitsRootView(APIRootView): # Providers # -class ProviderViewSet(CustomFieldModelViewSet): - queryset = Provider.objects.prefetch_related('tags').annotate( +class ProviderViewSet(NetBoxModelViewSet): + queryset = Provider.objects.prefetch_related('asns', 'tags').annotate( circuit_count=count_related(Circuit, 'provider') ) serializer_class = serializers.ProviderSerializer @@ -33,7 +32,7 @@ class ProviderViewSet(CustomFieldModelViewSet): # Circuit Types # -class CircuitTypeViewSet(CustomFieldModelViewSet): +class CircuitTypeViewSet(NetBoxModelViewSet): queryset = CircuitType.objects.prefetch_related('tags').annotate( circuit_count=count_related(Circuit, 'type') ) @@ -45,7 +44,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet): # Circuits # -class CircuitViewSet(CustomFieldModelViewSet): +class CircuitViewSet(NetBoxModelViewSet): queryset = Circuit.objects.prefetch_related( 'type', 'tenant', 'provider', 'termination_a', 'termination_z' ).prefetch_related('tags') @@ -57,7 +56,7 @@ class CircuitViewSet(CustomFieldModelViewSet): # Circuit Terminations # -class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet): +class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = CircuitTermination.objects.prefetch_related( 'circuit', 'site', 'provider_network', 'cable' ) @@ -70,7 +69,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet): # Provider networks # -class ProviderNetworkViewSet(CustomFieldModelViewSet): +class ProviderNetworkViewSet(NetBoxModelViewSet): queryset = ProviderNetwork.objects.prefetch_related('tags') serializer_class = serializers.ProviderNetworkSerializer filterset_class = filtersets.ProviderNetworkFilterSet diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 0efa431fa..ddb00c64b 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class CircuitStatusChoices(ChoiceSet): + key = 'Circuit.status' STATUS_DEPROVISIONING = 'deprovisioning' STATUS_ACTIVE = 'active' @@ -14,23 +15,14 @@ class CircuitStatusChoices(ChoiceSet): STATUS_OFFLINE = 'offline' STATUS_DECOMMISSIONED = 'decommissioned' - CHOICES = ( - (STATUS_PLANNED, 'Planned'), - (STATUS_PROVISIONING, 'Provisioning'), - (STATUS_ACTIVE, 'Active'), - (STATUS_OFFLINE, 'Offline'), - (STATUS_DEPROVISIONING, 'Deprovisioning'), - (STATUS_DECOMMISSIONED, 'Decommissioned'), - ) - - CSS_CLASSES = { - STATUS_DEPROVISIONING: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_PROVISIONING: 'primary', - STATUS_OFFLINE: 'danger', - STATUS_DECOMMISSIONED: 'secondary', - } + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_PROVISIONING, 'Provisioning', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_OFFLINE, 'Offline', 'red'), + (STATUS_DEPROVISIONING, 'Deprovisioning', 'yellow'), + (STATUS_DECOMMISSIONED, 'Decommissioned', 'gray'), + ] # diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 5a6a95785..b7fa100a8 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,9 +3,9 @@ from django.db.models import Q from dcim.filtersets import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup -from extras.filters import TagFilter -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet -from tenancy.filtersets import TenancyFilterSet +from ipam.models import ASN +from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet +from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -19,11 +19,7 @@ __all__ = ( ) -class ProviderFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='circuits__terminations__site__region', @@ -61,7 +57,11 @@ class ProviderFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() + asn_id = django_filters.ModelMultipleChoiceFilter( + field_name='asns', + queryset=ASN.objects.all(), + label='ASN (ID)', + ) class Meta: model = Provider @@ -79,11 +79,7 @@ class ProviderFilterSet(PrimaryModelFilterSet): ) -class ProviderNetworkFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class ProviderNetworkFilterSet(NetBoxModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), label='Provider (ID)', @@ -94,35 +90,30 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Provider (slug)', ) - tag = TagFilter() class Meta: model = ProviderNetwork - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'service_id', 'description'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(name__icontains=value) | + Q(service_id__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() class CircuitTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = CircuitType fields = ['id', 'name', 'slug', 'description'] -class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), label='Provider (ID)', @@ -189,7 +180,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Circuit diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 37edd3a62..6e283219a 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -1,10 +1,15 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.choices import CircuitStatusChoices from circuits.models import * -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.models import ASN +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect +from utilities.forms import ( + add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + StaticSelect, +) __all__ = ( 'CircuitBulkEditForm', @@ -14,14 +19,15 @@ __all__ = ( ) -class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Provider.objects.all(), - widget=forms.MultipleHiddenInput - ) +class ProviderBulkEditForm(NetBoxModelBulkEditForm): asn = forms.IntegerField( required=False, - label='ASN' + label='ASN (legacy)' + ) + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False ) account = forms.CharField( max_length=30, @@ -47,23 +53,27 @@ class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - ] - - -class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ProviderNetwork.objects.all(), - widget=forms.MultipleHiddenInput + model = Provider + fieldsets = ( + (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), ) + nullable_fields = ( + 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ) + + +class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False ) - description = forms.CharField( + service_id = forms.CharField( max_length=100, + required=False, + label='Service ID' + ) + description = forms.CharField( + max_length=200, required=False ) comments = CommentField( @@ -71,31 +81,29 @@ class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor label='Comments' ) - class Meta: - nullable_fields = [ - 'description', 'comments', - ] - - -class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=CircuitType.objects.all(), - widget=forms.MultipleHiddenInput + model = ProviderNetwork + fieldsets = ( + (None, ('provider', 'service_id', 'description')), ) + nullable_fields = ( + 'service_id', 'description', 'comments', + ) + + +class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( max_length=200, required=False ) - class Meta: - nullable_fields = ['description'] - - -class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Circuit.objects.all(), - widget=forms.MultipleHiddenInput + model = CircuitType + fieldsets = ( + (None, ('description',)), ) + nullable_fields = ('description',) + + +class CircuitBulkEditForm(NetBoxModelBulkEditForm): type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), required=False @@ -127,7 +135,10 @@ class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'tenant', 'commit_rate', 'description', 'comments', - ] + model = Circuit + fieldsets = ( + (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')), + ) + nullable_fields = ( + 'tenant', 'commit_rate', 'description', 'comments', + ) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index af5ec4425..6da79f75c 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,6 +1,6 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * -from extras.forms import CustomFieldModelCSVForm +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField @@ -12,7 +12,7 @@ __all__ = ( ) -class ProviderCSVForm(CustomFieldModelCSVForm): +class ProviderCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -22,7 +22,7 @@ class ProviderCSVForm(CustomFieldModelCSVForm): ) -class ProviderNetworkCSVForm(CustomFieldModelCSVForm): +class ProviderNetworkCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', @@ -32,11 +32,11 @@ class ProviderNetworkCSVForm(CustomFieldModelCSVForm): class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'description', 'comments', + 'provider', 'name', 'service_id', 'description', 'comments', ] -class CircuitTypeCSVForm(CustomFieldModelCSVForm): +class CircuitTypeCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -47,7 +47,7 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm): } -class CircuitCSVForm(CustomFieldModelCSVForm): +class CircuitCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index a668f9b16..ca3b003b9 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -4,9 +4,10 @@ from django.utils.translation import gettext as _ from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.models import Region, Site, SiteGroup -from extras.forms import CustomFieldModelFilterForm -from tenancy.forms import TenancyFilterForm -from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField +from ipam.models import ASN +from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import TenancyFilterForm, ContactModelFilterForm +from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField __all__ = ( 'CircuitFilterForm', @@ -16,13 +17,14 @@ __all__ = ( ) -class ProviderFilterForm(CustomFieldModelFilterForm): +class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['asn'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('ASN', ('asn',)), + ('Contacts', ('contact', 'contact_role')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -44,39 +46,49 @@ class ProviderFilterForm(CustomFieldModelFilterForm): ) asn = forms.IntegerField( required=False, - label=_('ASN') + label=_('ASN (legacy)') + ) + asn_id = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + required=False, + label=_('ASNs') ) tag = TagFilterField(model) -class ProviderNetworkFilterForm(CustomFieldModelFilterForm): +class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork - field_groups = ( - ('q', 'tag'), - ('provider_id',), + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('provider_id', 'service_id')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), required=False, label=_('Provider') ) + service_id = forms.CharField( + max_length=100, + required=False + ) tag = TagFilterField(model) -class CircuitTypeFilterForm(CustomFieldModelFilterForm): +class CircuitTypeFilterForm(NetBoxModelFilterSetForm): model = CircuitType tag = TagFilterField(model) -class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit - field_groups = [ - ['q', 'tag'], - ['provider_id', 'provider_network_id'], - ['type_id', 'status', 'commit_rate'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Provider', ('provider_id', 'provider_network_id')), + ('Attributes', ('type_id', 'status', 'commit_rate')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), required=False, @@ -95,10 +107,9 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): }, label=_('Provider network') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=CircuitStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 2ea246fd0..8fd5fb92d 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -1,9 +1,10 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.models import * from dcim.models import Region, Site, SiteGroup -from extras.forms import CustomFieldModelForm -from extras.models import Tag +from ipam.models import ASN +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -19,23 +20,25 @@ __all__ = ( ) -class ProviderForm(CustomFieldModelForm): +class ProviderForm(NetBoxModelForm): slug = SlugField() - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), required=False ) + comments = CommentField() + + fieldsets = ( + ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), + ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ) class Meta: model = Provider fields = [ - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', + 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', ] - fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'tags')), - ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), - ) widgets = { 'noc_contact': SmallTextarea( attrs={'rows': 5} @@ -53,32 +56,25 @@ class ProviderForm(CustomFieldModelForm): } -class ProviderNetworkForm(CustomFieldModelForm): +class ProviderNetworkForm(NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all() ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')), ) class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'description', 'comments', 'tags', + 'provider', 'name', 'service_id', 'description', 'comments', 'tags', ] - fieldsets = ( - ('Provider Network', ('provider', 'name', 'description', 'tags')), - ) -class CircuitTypeForm(CustomFieldModelForm): +class CircuitTypeForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = CircuitType @@ -87,7 +83,7 @@ class CircuitTypeForm(CustomFieldModelForm): ] -class CircuitForm(TenancyForm, CustomFieldModelForm): +class CircuitForm(TenancyForm, NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all() ) @@ -95,9 +91,10 @@ class CircuitForm(TenancyForm, CustomFieldModelForm): queryset=CircuitType.objects.all() ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: @@ -106,10 +103,6 @@ class CircuitForm(TenancyForm, CustomFieldModelForm): 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'comments', 'tags', ] - fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) help_texts = { 'cid': "Unique circuit ID", 'commit_rate': "Committed rate", @@ -122,6 +115,19 @@ class CircuitForm(TenancyForm, CustomFieldModelForm): class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False, + initial_params={ + 'circuits': '$circuit' + } + ) + circuit = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + query_params={ + 'provider_id': '$provider', + }, + ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -152,8 +158,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed', - 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', + 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -161,12 +167,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'pp_info': "Patch panel ID and port number(s)" } widgets = { - 'term_side': forms.HiddenInput(), + 'term_side': StaticSelect(), 'port_speed': SelectSpeedWidget(), 'upstream_speed': SelectSpeedWidget(), } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index a6c28c4cd..027b53203 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,5 +1,5 @@ from circuits import filtersets, models -from netbox.graphql.types import ObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( 'CircuitTerminationType', @@ -18,7 +18,7 @@ class CircuitTerminationType(ObjectType): filterset_class = filtersets.CircuitTerminationFilterSet -class CircuitType(PrimaryObjectType): +class CircuitType(NetBoxObjectType): class Meta: model = models.Circuit @@ -34,7 +34,7 @@ class CircuitTypeType(OrganizationalObjectType): filterset_class = filtersets.CircuitTypeFilterSet -class ProviderType(PrimaryObjectType): +class ProviderType(NetBoxObjectType): class Meta: model = models.Provider @@ -42,7 +42,7 @@ class ProviderType(PrimaryObjectType): filterset_class = filtersets.ProviderFilterSet -class ProviderNetworkType(PrimaryObjectType): +class ProviderNetworkType(NetBoxObjectType): class Meta: model = models.ProviderNetwork diff --git a/netbox/circuits/migrations/0032_provider_service_id.py b/netbox/circuits/migrations/0032_provider_service_id.py new file mode 100644 index 000000000..58936d1bd --- /dev/null +++ b/netbox/circuits/migrations/0032_provider_service_id.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0004_rename_cable_peer'), + ('dcim', '0145_site_remove_deprecated_fields'), + ] + + operations = [ + migrations.AddField( + model_name='providernetwork', + name='service_id', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/circuits/migrations/0033_standardize_id_fields.py b/netbox/circuits/migrations/0033_standardize_id_fields.py new file mode 100644 index 000000000..475fc2527 --- /dev/null +++ b/netbox/circuits/migrations/0033_standardize_id_fields.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0032_provider_service_id'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='providernetwork', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='circuittermination', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0034_created_datetimefield.py b/netbox/circuits/migrations/0034_created_datetimefield.py new file mode 100644 index 000000000..4af78c1a2 --- /dev/null +++ b/netbox/circuits/migrations/0034_created_datetimefield.py @@ -0,0 +1,38 @@ +# Generated by Django 4.0.2 on 2022-02-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0033_standardize_id_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuittermination', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuittype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='provider', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='providernetwork', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0035_provider_asns.py b/netbox/circuits/migrations/0035_provider_asns.py new file mode 100644 index 000000000..afb0da4d6 --- /dev/null +++ b/netbox/circuits/migrations/0035_provider_asns.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.3 on 2022-03-30 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ('circuits', '0034_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='asns', + field=models.ManyToManyField(blank=True, related_name='providers', to='ipam.asn'), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 602c0f403..02ba5209d 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,8 +5,8 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from extras.utils import extras_features -from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel +from netbox.models.features import WebhooksMixin __all__ = ( 'Circuit', @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -44,8 +43,7 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Circuit(PrimaryModel): +class Circuit(NetBoxModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -134,12 +132,11 @@ class Circuit(PrimaryModel): def get_absolute_url(self): return reverse('circuits:circuit', args=[self.pk]) - def get_status_class(self): - return CircuitStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return CircuitStatusChoices.colors.get(self.status) -@extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, LinkTermination): +class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -212,13 +209,9 @@ class CircuitTermination(ChangeLoggedModel, LinkTermination): raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): - # Annotate the parent Circuit - try: - circuit = self.circuit - except Circuit.DoesNotExist: - # Parent circuit has been deleted - circuit = None - return super().to_objectchange(action, related_object=circuit) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.circuit + return objectchange @property def parent_object(self): diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index b3a6902f9..4211a54a6 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,9 +3,7 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from extras.utils import extras_features -from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet +from netbox.models import NetBoxModel __all__ = ( 'ProviderNetwork', @@ -13,8 +11,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Provider(PrimaryModel): +class Provider(NetBoxModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -33,6 +30,11 @@ class Provider(PrimaryModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) + asns = models.ManyToManyField( + to='ipam.ASN', + related_name='providers', + blank=True + ) account = models.CharField( max_length=30, blank=True, @@ -73,8 +75,7 @@ class Provider(PrimaryModel): return reverse('circuits:provider', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ProviderNetwork(PrimaryModel): +class ProviderNetwork(NetBoxModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or unimportant to the user. @@ -87,6 +88,11 @@ class ProviderNetwork(PrimaryModel): on_delete=models.PROTECT, related_name='networks' ) + service_id = models.CharField( + max_length=100, + blank=True, + verbose_name='Service ID' + ) description = models.CharField( max_length=200, blank=True diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py deleted file mode 100644 index 889792be3..000000000 --- a/netbox/circuits/tables.py +++ /dev/null @@ -1,157 +0,0 @@ -import django_tables2 as tables -from django_tables2.utils import Accessor - -from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn -from .models import * - - -__all__ = ( - 'CircuitTable', - 'CircuitTypeTable', - 'ProviderTable', - 'ProviderNetworkTable', -) - - -CIRCUITTERMINATION_LINK = """ -{% if value.site %} - {{ value.site }} -{% elif value.provider_network %} - {{ value.provider_network }} -{% endif %} -""" - -# -# Table columns -# - - -class CommitRateColumn(tables.TemplateColumn): - """ - Humanize the commit rate in the column view - """ - - template_code = """ - {% load helpers %} - {{ record.commit_rate|humanize_speed }} - """ - - def __init__(self, *args, **kwargs): - super().__init__(template_code=self.template_code, *args, **kwargs) - - def value(self, value): - return str(value) if value else None - -# -# Providers -# - - -class ProviderTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - circuit_count = tables.Column( - accessor=Accessor('count_circuits'), - verbose_name='Circuits' - ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='circuits:provider_list' - ) - - class Meta(BaseTable.Meta): - model = Provider - fields = ( - 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', - 'comments', 'tags', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') - - -# -# Provider networks -# - -class ProviderNetworkTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - provider = tables.Column( - linkify=True - ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='circuits:providernetwork_list' - ) - - class Meta(BaseTable.Meta): - model = ProviderNetwork - fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags', 'created', 'last_updated',) - default_columns = ('pk', 'name', 'provider', 'description') - - -# -# Circuit types -# - -class CircuitTypeTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - tags = TagColumn( - url_name='circuits:circuittype_list' - ) - circuit_count = tables.Column( - verbose_name='Circuits' - ) - actions = ButtonsColumn(CircuitType) - - class Meta(BaseTable.Meta): - model = CircuitType - fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',) - default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') - - -# -# Circuits -# - -class CircuitTable(BaseTable): - pk = ToggleColumn() - cid = tables.Column( - linkify=True, - verbose_name='Circuit ID' - ) - provider = tables.Column( - linkify=True - ) - status = ChoiceFieldColumn() - tenant = TenantColumn() - termination_a = tables.TemplateColumn( - template_code=CIRCUITTERMINATION_LINK, - verbose_name='Side A' - ) - termination_z = tables.TemplateColumn( - template_code=CIRCUITTERMINATION_LINK, - verbose_name='Side Z' - ) - commit_rate = CommitRateColumn() - comments = MarkdownColumn() - tags = TagColumn( - url_name='circuits:circuit_list' - ) - - class Meta(BaseTable.Meta): - model = Circuit - fields = ( - 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated', - ) - default_columns = ( - 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', - ) diff --git a/netbox/circuits/tables/__init__.py b/netbox/circuits/tables/__init__.py new file mode 100644 index 000000000..b61c13cae --- /dev/null +++ b/netbox/circuits/tables/__init__.py @@ -0,0 +1,3 @@ +from .circuits import * +from .columns import * +from .providers import * diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py new file mode 100644 index 000000000..cb8c940b0 --- /dev/null +++ b/netbox/circuits/tables/circuits.py @@ -0,0 +1,77 @@ +import django_tables2 as tables + +from circuits.models import * +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn +from .columns import CommitRateColumn + +__all__ = ( + 'CircuitTable', + 'CircuitTypeTable', +) + + +CIRCUITTERMINATION_LINK = """ +{% if value.site %} + {{ value.site }} +{% elif value.provider_network %} + {{ value.provider_network }} +{% endif %} +""" + + +class CircuitTypeTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + tags = columns.TagColumn( + url_name='circuits:circuittype_list' + ) + circuit_count = tables.Column( + verbose_name='Circuits' + ) + + class Meta(NetBoxTable.Meta): + model = CircuitType + fields = ( + 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') + + +class CircuitTable(NetBoxTable): + cid = tables.Column( + linkify=True, + verbose_name='Circuit ID' + ) + provider = tables.Column( + linkify=True + ) + status = columns.ChoiceFieldColumn() + tenant = TenantColumn() + termination_a = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side A' + ) + termination_z = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side Z' + ) + commit_rate = CommitRateColumn() + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='circuits:circuit_list' + ) + + class Meta(NetBoxTable.Meta): + model = Circuit + fields = ( + 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', + ) diff --git a/netbox/circuits/tables/columns.py b/netbox/circuits/tables/columns.py new file mode 100644 index 000000000..d7a5ffd28 --- /dev/null +++ b/netbox/circuits/tables/columns.py @@ -0,0 +1,21 @@ +import django_tables2 as tables + +__all__ = ( + 'CommitRateColumn', +) + + +class CommitRateColumn(tables.TemplateColumn): + """ + Humanize the commit rate in the column view + """ + template_code = """ + {% load helpers %} + {{ record.commit_rate|humanize_speed }} + """ + + def __init__(self, *args, **kwargs): + super().__init__(template_code=self.template_code, *args, **kwargs) + + def value(self, value): + return str(value) if value else None diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py new file mode 100644 index 000000000..e97ade7d8 --- /dev/null +++ b/netbox/circuits/tables/providers.py @@ -0,0 +1,67 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from circuits.models import * +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'ProviderTable', + 'ProviderNetworkTable', +) + + +class ProviderTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + asns = tables.ManyToManyColumn( + linkify_item=True, + verbose_name='ASNs' + ) + asn_count = columns.LinkedCountColumn( + accessor=tables.A('asns__count'), + viewname='ipam:asn_list', + url_params={'provider_id': 'pk'}, + verbose_name='ASN Count' + ) + circuit_count = columns.LinkedCountColumn( + accessor=Accessor('count_circuits'), + viewname='circuits:circuit_list', + url_params={'provider_id': 'pk'}, + verbose_name='Circuits' + ) + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='circuits:provider_list' + ) + + class Meta(NetBoxTable.Meta): + model = Provider + fields = ( + 'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', + 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') + + +class ProviderNetworkTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + provider = tables.Column( + linkify=True + ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='circuits:providernetwork_list' + ) + + class Meta(NetBoxTable.Meta): + model = ProviderNetwork + fields = ( + 'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags', + ) + default_columns = ('pk', 'name', 'provider', 'service_id', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 830c7d9ca..02b489ac4 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -3,6 +3,7 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * from dcim.models import Site +from ipam.models import ASN, RIR from utilities.testing import APITestCase, APIViewTestCases @@ -18,20 +19,6 @@ class AppTest(APITestCase): class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] - create_data = [ - { - 'name': 'Provider 4', - 'slug': 'provider-4', - }, - { - 'name': 'Provider 5', - 'slug': 'provider-5', - }, - { - 'name': 'Provider 6', - 'slug': 'provider-6', - }, - ] bulk_update_data = { 'asn': 1234, } @@ -39,6 +26,12 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + providers = ( Provider(name='Provider 1', slug='provider-1'), Provider(name='Provider 2', slug='provider-2'), @@ -46,6 +39,24 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): ) Provider.objects.bulk_create(providers) + cls.create_data = [ + { + 'name': 'Provider 4', + 'slug': 'provider-4', + 'asns': [asns[0].pk, asns[1].pk], + }, + { + 'name': 'Provider 5', + 'slug': 'provider-5', + 'asns': [asns[2].pk, asns[3].pk], + }, + { + 'name': 'Provider 6', + 'slug': 'provider-6', + 'asns': [asns[4].pk, asns[5].pk], + }, + ] + class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 20416c4e6..205236712 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -4,6 +4,7 @@ from circuits.choices import * from circuits.filtersets import * from circuits.models import * from dcim.models import Cable, Region, Site, SiteGroup +from ipam.models import ASN, RIR from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests @@ -15,6 +16,14 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = ( + ASN(asn=64512, rir=rir), + ASN(asn=64513, rir=rir), + ASN(asn=64514, rir=rir), + ) + ASN.objects.bulk_create(asns) + providers = ( Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), @@ -23,6 +32,9 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), ) Provider.objects.bulk_create(providers) + providers[0].asns.set([asns[0]]) + providers[1].asns.set([asns[1]]) + providers[2].asns.set([asns[2]]) regions = ( Region(name='Test Region 1', slug='test-region-1'), @@ -70,10 +82,15 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['provider-1', 'provider-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn(self): + def test_asn(self): # Legacy field params = {'asn': ['65001', '65002']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_asn_id(self): # ASN object assignment + asns = ASN.objects.all()[:2] + params = {'asn_id': [asns[0].pk, asns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_account(self): params = {'account': ['1234', '2345']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 851d52ae8..17c846c86 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -6,6 +6,7 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * from dcim.models import Cable, Interface, Site +from ipam.models import ASN, RIR from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -15,11 +16,21 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - Provider.objects.bulk_create([ + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + + providers = ( Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 3', slug='provider-3', asn=65003), - ]) + ) + Provider.objects.bulk_create(providers) + providers[0].asns.set([asns[0], asns[1]]) + providers[1].asns.set([asns[2], asns[3]]) + providers[2].asns.set([asns[4], asns[5]]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -27,6 +38,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Provider X', 'slug': 'provider-x', 'asn': 65123, + 'asns': [asns[6].pk, asns[7].pk], 'account': '1234', 'portal_url': 'http://example.com/portal', 'noc_contact': 'noc@example.com', @@ -218,6 +230,7 @@ class CircuitTerminationTestCase( CircuitTermination.objects.bulk_create(circuit_terminations) cls.form_data = { + 'circuit': circuits[2].pk, 'term_side': 'A', 'site': sites[2].pk, 'description': 'New description', diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 1cea1965e..f3ee64cf0 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,8 +1,7 @@ from django.urls import path from dcim.views import CableCreateView, PathTraceView -from extras.views import ObjectChangeLogView, ObjectJournalView -from utilities.views import SlugRedirectView +from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from . import views from .models import * @@ -16,7 +15,6 @@ urlpatterns = [ path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers//', views.ProviderView.as_view(), name='provider'), - path('providers//', SlugRedirectView.as_view(), kwargs={'model': Provider}), path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), @@ -59,7 +57,7 @@ urlpatterns = [ path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations - path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), + path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b549b3a01..c05aa31df 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,10 +5,8 @@ from django.shortcuts import get_object_or_404, redirect, render from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.tables import paginate_table from utilities.utils import count_related from . import filtersets, forms, tables -from .choices import CircuitTerminationSideChoices from .models import * @@ -35,7 +33,7 @@ class ProviderView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) - paginate_table(circuits_table, request) + circuits_table.configure(request) return { 'circuits_table': circuits_table, @@ -44,7 +42,7 @@ class ProviderView(generic.ObjectView): class ProviderEditView(generic.ObjectEditView): queryset = Provider.objects.all() - model_form = forms.ProviderForm + form = forms.ProviderForm class ProviderDeleteView(generic.ObjectDeleteView): @@ -96,7 +94,7 @@ class ProviderNetworkView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits) - paginate_table(circuits_table, request) + circuits_table.configure(request) return { 'circuits_table': circuits_table, @@ -105,7 +103,7 @@ class ProviderNetworkView(generic.ObjectView): class ProviderNetworkEditView(generic.ObjectEditView): queryset = ProviderNetwork.objects.all() - model_form = forms.ProviderNetworkForm + form = forms.ProviderNetworkForm class ProviderNetworkDeleteView(generic.ObjectDeleteView): @@ -150,7 +148,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',)) - paginate_table(circuits_table, request) + circuits_table.configure(request) return { 'circuits_table': circuits_table, @@ -159,7 +157,7 @@ class CircuitTypeView(generic.ObjectView): class CircuitTypeEditView(generic.ObjectEditView): queryset = CircuitType.objects.all() - model_form = forms.CircuitTypeForm + form = forms.CircuitTypeForm class CircuitTypeDeleteView(generic.ObjectDeleteView): @@ -207,7 +205,7 @@ class CircuitView(generic.ObjectView): class CircuitEditView(generic.ObjectEditView): queryset = Circuit.objects.all() - model_form = forms.CircuitForm + form = forms.CircuitForm class CircuitDeleteView(generic.ObjectDeleteView): @@ -317,17 +315,9 @@ class CircuitSwapTerminations(generic.ObjectEditView): class CircuitTerminationEditView(generic.ObjectEditView): queryset = CircuitTermination.objects.all() - model_form = forms.CircuitTerminationForm + form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' - def alter_obj(self, obj, request, url_args, url_kwargs): - if 'circuit' in url_kwargs: - obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit']) - return obj - - def get_return_url(self, request, obj): - return obj.circuit.get_absolute_url() - class CircuitTerminationDeleteView(generic.ObjectDeleteView): queryset = CircuitTermination.objects.all() diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 1fdde78d7..0ec0e07e0 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -4,6 +4,7 @@ from dcim import models from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ + 'ComponentNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -19,7 +20,13 @@ __all__ = [ 'NestedInterfaceSerializer', 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', + 'NestedInventoryItemRoleSerializer', + 'NestedInventoryItemTemplateSerializer', 'NestedManufacturerSerializer', + 'NestedModuleBaySerializer', + 'NestedModuleBayTemplateSerializer', + 'NestedModuleSerializer', + 'NestedModuleTypeSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', @@ -117,7 +124,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer): # -# Device types +# Device/module types # class NestedManufacturerSerializer(WritableNestedSerializer): @@ -139,6 +146,20 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count'] +class NestedModuleTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = NestedManufacturerSerializer(read_only=True) + # module_count = serializers.IntegerField(read_only=True) + + class Meta: + model = models.ModuleType + fields = ['id', 'url', 'display', 'manufacturer', 'model'] + + +# +# Component templates +# + class NestedConsolePortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') @@ -195,6 +216,14 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedModuleBayTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + + class Meta: + model = models.ModuleBayTemplate + fields = ['id', 'url', 'display', 'name'] + + class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') @@ -203,6 +232,15 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedInventoryItemTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.InventoryItemTemplate + fields = ['id', 'url', 'display', 'name', '_depth'] + + # # Devices # @@ -235,6 +273,37 @@ class NestedDeviceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class ModuleNestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + + class Meta: + model = models.ModuleBay + fields = ['id', 'url', 'display', 'name'] + + +class ComponentNestedModuleSerializer(WritableNestedSerializer): + """ + Used by device component serializers. + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + module_bay = ModuleNestedModuleBaySerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay'] + + +class NestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer(read_only=True) + module_bay = ModuleNestedModuleBaySerializer(read_only=True) + module_type = NestedModuleTypeSerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type'] + + class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) @@ -298,6 +367,15 @@ class NestedFrontPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] +class NestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + module = NestedModuleSerializer(read_only=True) + + class Meta: + model = models.ModuleBay + fields = ['id', 'url', 'display', 'module', 'name'] + + class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer(read_only=True) @@ -317,6 +395,15 @@ class NestedInventoryItemSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'device', 'name', '_depth'] +class NestedInventoryItemRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + inventoryitem_count = serializers.IntegerField(read_only=True) + + class Meta: + model = models.InventoryItemRole + fields = ['id', 'url', 'display', 'name', 'slug', 'inventoryitem_count'] + + # # Cables # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 2d555e756..813c946a3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,11 +6,13 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer +from ipam.api.nested_serializers import ( + NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, +) from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( - NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, + NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from netbox.config import ConfigItem from tenancy.api.nested_serializers import NestedTenantSerializer @@ -107,7 +109,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer): ] -class SiteSerializer(PrimaryModelSerializer): +class SiteSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) @@ -132,10 +134,10 @@ class SiteSerializer(PrimaryModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', + 'virtualmachine_count', 'vlan_count', ] @@ -159,7 +161,7 @@ class LocationSerializer(NestedGroupModelSerializer): ] -class RackRoleSerializer(PrimaryModelSerializer): +class RackRoleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) @@ -171,7 +173,7 @@ class RackRoleSerializer(PrimaryModelSerializer): ] -class RackSerializer(PrimaryModelSerializer): +class RackSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') site = NestedSiteSerializer() location = NestedLocationSerializer(required=False, allow_null=True, default=None) @@ -210,7 +212,7 @@ class RackUnitSerializer(serializers.Serializer): return obj['name'] -class RackReservationSerializer(PrimaryModelSerializer): +class RackReservationSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') rack = NestedRackSerializer() user = NestedUserSerializer() @@ -261,10 +263,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # -# Device types +# Device/module types # -class ManufacturerSerializer(PrimaryModelSerializer): +class ManufacturerSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -278,7 +280,7 @@ class ManufacturerSerializer(PrimaryModelSerializer): ] -class DeviceTypeSerializer(PrimaryModelSerializer): +class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) @@ -294,6 +296,23 @@ class DeviceTypeSerializer(PrimaryModelSerializer): ] +class ModuleTypeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = NestedManufacturerSerializer() + # module_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ModuleType + fields = [ + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + +# +# Component templates +# + class ConsolePortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') device_type = NestedDeviceTypeSerializer() @@ -409,6 +428,18 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): ] +class ModuleBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + device_type = NestedDeviceTypeSerializer() + + class Meta: + model = ModuleBayTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', + 'last_updated', + ] + + class DeviceBayTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') device_type = NestedDeviceTypeSerializer() @@ -418,11 +449,45 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] +class InventoryItemTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + device_type = NestedDeviceTypeSerializer() + parent = serializers.PrimaryKeyRelatedField( + queryset=InventoryItemTemplate.objects.all(), + allow_null=True, + default=None + ) + role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', + 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_component(self, obj): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.component, context=context).data + + # # Devices # -class DeviceRoleSerializer(PrimaryModelSerializer): +class DeviceRoleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -435,7 +500,7 @@ class DeviceRoleSerializer(PrimaryModelSerializer): ] -class PlatformSerializer(PrimaryModelSerializer): +class PlatformSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -449,7 +514,7 @@ class PlatformSerializer(PrimaryModelSerializer): ] -class DeviceSerializer(PrimaryModelSerializer): +class DeviceSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() @@ -491,6 +556,20 @@ class DeviceSerializer(PrimaryModelSerializer): return data +class ModuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer() + module_bay = NestedModuleBaySerializer() + module_type = NestedModuleTypeSerializer() + + class Meta: + model = Module + fields = [ + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + class DeviceWithConfigContextSerializer(DeviceSerializer): config_context = serializers.SerializerMethodField() @@ -515,9 +594,13 @@ class DeviceNAPALMSerializer(serializers.Serializer): # Device components # -class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -533,15 +616,19 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali class Meta: model = ConsoleServerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -557,15 +644,19 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = ConsolePort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, @@ -587,15 +678,20 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, @@ -606,21 +702,27 @@ class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = PowerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=InterfaceTypeChoices) parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) - rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) + duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -629,6 +731,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con required=False, many=True ) + vrf = NestedVRFSerializer(required=False, allow_null=True) cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( @@ -643,12 +746,12 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', - 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', - 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - 'count_fhrp_groups', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', + 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', + 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): @@ -665,16 +768,20 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con return super().validate(data) -class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): +class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) cable = NestedCableSerializer(read_only=True) class Meta: model = RearPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -691,9 +798,13 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): +class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() cable = NestedCableSerializer(read_only=True) @@ -701,13 +812,26 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): class Meta: model = FrontPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', + 'custom_fields', 'created', 'last_updated', '_occupied', ] -class DeviceBaySerializer(PrimaryModelSerializer): +class ModuleBaySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + device = NestedDeviceSerializer() + # installed_module = NestedModuleSerializer(required=False, allow_null=True) + + class Meta: + model = ModuleBay + fields = [ + 'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + +class DeviceBaySerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) @@ -720,22 +844,50 @@ class DeviceBaySerializer(PrimaryModelSerializer): ] -# -# Inventory items -# - -class InventoryItemSerializer(PrimaryModelSerializer): +class InventoryItemSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) + role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = InventoryItem fields = [ - 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', - 'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', + 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags', + 'custom_fields', 'created', 'last_updated', '_depth', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_component(self, obj): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.component, context=context).data + + +# +# Device component roles +# + +class InventoryItemRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + inventoryitem_count = serializers.IntegerField(read_only=True) + + class Meta: + model = InventoryItemRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'inventoryitem_count', ] @@ -743,7 +895,7 @@ class InventoryItemSerializer(PrimaryModelSerializer): # Cables # -class CableSerializer(PrimaryModelSerializer): +class CableSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) @@ -849,7 +1001,7 @@ class CablePathSerializer(serializers.ModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(PrimaryModelSerializer): +class VirtualChassisSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) @@ -866,7 +1018,7 @@ class VirtualChassisSerializer(PrimaryModelSerializer): # Power panels # -class PowerPanelSerializer(PrimaryModelSerializer): +class PowerPanelSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') site = NestedSiteSerializer() location = NestedLocationSerializer( @@ -884,7 +1036,7 @@ class PowerPanelSerializer(PrimaryModelSerializer): ] -class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 491f4e7f2..f67d241d5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,8 +1,8 @@ -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.DCIMRootView # Sites @@ -16,9 +16,10 @@ router.register('rack-roles', views.RackRoleViewSet) router.register('racks', views.RackViewSet) router.register('rack-reservations', views.RackReservationViewSet) -# Device types +# Device/module types router.register('manufacturers', views.ManufacturerViewSet) router.register('device-types', views.DeviceTypeViewSet) +router.register('module-types', views.ModuleTypeViewSet) # Device type components router.register('console-port-templates', views.ConsolePortTemplateViewSet) @@ -28,12 +29,15 @@ router.register('power-outlet-templates', views.PowerOutletTemplateViewSet) router.register('interface-templates', views.InterfaceTemplateViewSet) router.register('front-port-templates', views.FrontPortTemplateViewSet) router.register('rear-port-templates', views.RearPortTemplateViewSet) +router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) +router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) -# Devices +# Device/modules router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('modules', views.ModuleViewSet) # Device components router.register('console-ports', views.ConsolePortViewSet) @@ -43,9 +47,13 @@ router.register('power-outlets', views.PowerOutletViewSet) router.register('interfaces', views.InterfaceViewSet) router.register('front-ports', views.FrontPortViewSet) router.register('rear-ports', views.RearPortViewSet) +router.register('module-bays', views.ModuleBayViewSet) router.register('device-bays', views.DeviceBayViewSet) router.register('inventory-items', views.InventoryItemViewSet) +# Device component roles +router.register('inventory-item-roles', views.InventoryItemRoleViewSet) + # Cables router.register('cables', views.CableViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 5830396ce..e99ef333a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -14,12 +14,12 @@ from rest_framework.viewsets import ViewSet from circuits.models import Circuit from dcim import filtersets from dcim.models import * -from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet +from extras.api.views import ConfigContextQuerySetMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata -from netbox.api.views import ModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model from utilities.utils import count_related @@ -103,7 +103,7 @@ class PassThroughPortMixin(object): # Regions # -class RegionViewSet(CustomFieldModelViewSet): +class RegionViewSet(NetBoxModelViewSet): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -119,7 +119,7 @@ class RegionViewSet(CustomFieldModelViewSet): # Site groups # -class SiteGroupViewSet(CustomFieldModelViewSet): +class SiteGroupViewSet(NetBoxModelViewSet): queryset = SiteGroup.objects.add_related_count( SiteGroup.objects.all(), Site, @@ -135,7 +135,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet): # Sites # -class SiteViewSet(CustomFieldModelViewSet): +class SiteViewSet(NetBoxModelViewSet): queryset = Site.objects.prefetch_related( 'region', 'tenant', 'asns', 'tags' ).annotate( @@ -154,7 +154,7 @@ class SiteViewSet(CustomFieldModelViewSet): # Locations # -class LocationViewSet(CustomFieldModelViewSet): +class LocationViewSet(NetBoxModelViewSet): queryset = Location.objects.add_related_count( Location.objects.add_related_count( Location.objects.all(), @@ -176,7 +176,7 @@ class LocationViewSet(CustomFieldModelViewSet): # Rack roles # -class RackRoleViewSet(CustomFieldModelViewSet): +class RackRoleViewSet(NetBoxModelViewSet): queryset = RackRole.objects.prefetch_related('tags').annotate( rack_count=count_related(Rack, 'role') ) @@ -188,7 +188,7 @@ class RackRoleViewSet(CustomFieldModelViewSet): # Racks # -class RackViewSet(CustomFieldModelViewSet): +class RackViewSet(NetBoxModelViewSet): queryset = Rack.objects.prefetch_related( 'site', 'location', 'role', 'tenant', 'tags' ).annotate( @@ -250,7 +250,7 @@ class RackViewSet(CustomFieldModelViewSet): # Rack reservations # -class RackReservationViewSet(ModelViewSet): +class RackReservationViewSet(NetBoxModelViewSet): queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer filterset_class = filtersets.RackReservationFilterSet @@ -260,7 +260,7 @@ class RackReservationViewSet(ModelViewSet): # Manufacturers # -class ManufacturerViewSet(CustomFieldModelViewSet): +class ManufacturerViewSet(NetBoxModelViewSet): queryset = Manufacturer.objects.prefetch_related('tags').annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), @@ -271,10 +271,10 @@ class ManufacturerViewSet(CustomFieldModelViewSet): # -# Device types +# Device/module types # -class DeviceTypeViewSet(CustomFieldModelViewSet): +class DeviceTypeViewSet(NetBoxModelViewSet): queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( device_count=count_related(Device, 'device_type') ) @@ -283,63 +283,84 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): brief_prefetch_fields = ['manufacturer'] +class ModuleTypeViewSet(NetBoxModelViewSet): + queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate( + # module_count=count_related(Module, 'module_type') + ) + serializer_class = serializers.ModuleTypeSerializer + filterset_class = filtersets.ModuleTypeFilterSet + brief_prefetch_fields = ['manufacturer'] + + # # Device type components # -class ConsolePortTemplateViewSet(ModelViewSet): +class ConsolePortTemplateViewSet(NetBoxModelViewSet): queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer filterset_class = filtersets.ConsolePortTemplateFilterSet -class ConsoleServerPortTemplateViewSet(ModelViewSet): +class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet): queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer filterset_class = filtersets.ConsoleServerPortTemplateFilterSet -class PowerPortTemplateViewSet(ModelViewSet): +class PowerPortTemplateViewSet(NetBoxModelViewSet): queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer filterset_class = filtersets.PowerPortTemplateFilterSet -class PowerOutletTemplateViewSet(ModelViewSet): +class PowerOutletTemplateViewSet(NetBoxModelViewSet): queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer filterset_class = filtersets.PowerOutletTemplateFilterSet -class InterfaceTemplateViewSet(ModelViewSet): +class InterfaceTemplateViewSet(NetBoxModelViewSet): queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer filterset_class = filtersets.InterfaceTemplateFilterSet -class FrontPortTemplateViewSet(ModelViewSet): +class FrontPortTemplateViewSet(NetBoxModelViewSet): queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.FrontPortTemplateSerializer filterset_class = filtersets.FrontPortTemplateFilterSet -class RearPortTemplateViewSet(ModelViewSet): +class RearPortTemplateViewSet(NetBoxModelViewSet): queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.RearPortTemplateSerializer filterset_class = filtersets.RearPortTemplateFilterSet -class DeviceBayTemplateViewSet(ModelViewSet): +class ModuleBayTemplateViewSet(NetBoxModelViewSet): + queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer') + serializer_class = serializers.ModuleBayTemplateSerializer + filterset_class = filtersets.ModuleBayTemplateFilterSet + + +class DeviceBayTemplateViewSet(NetBoxModelViewSet): queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateViewSet(NetBoxModelViewSet): + queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') + serializer_class = serializers.InventoryItemTemplateSerializer + filterset_class = filtersets.InventoryItemTemplateFilterSet + + # # Device roles # -class DeviceRoleViewSet(CustomFieldModelViewSet): +class DeviceRoleViewSet(NetBoxModelViewSet): queryset = DeviceRole.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') @@ -352,7 +373,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet): # Platforms # -class PlatformViewSet(CustomFieldModelViewSet): +class PlatformViewSet(NetBoxModelViewSet): queryset = Platform.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') @@ -362,10 +383,10 @@ class PlatformViewSet(CustomFieldModelViewSet): # -# Devices +# Devices/modules # -class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): +class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', @@ -511,83 +532,120 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): return Response(response) +class ModuleViewSet(NetBoxModelViewSet): + queryset = Module.objects.prefetch_related( + 'device', 'module_bay', 'module_type__manufacturer', 'tags', + ) + serializer_class = serializers.ModuleSerializer + filterset_class = filtersets.ModuleFilterSet + + # # Device components # -class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') +class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): + queryset = ConsolePort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet brief_prefetch_fields = ['device'] -class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): +class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet brief_prefetch_fields = ['device'] -class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') +class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): + queryset = PowerPort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet brief_prefetch_fields = ['device'] -class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') +class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): + queryset = PowerOutlet.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet brief_prefetch_fields = ['device'] -class InterfaceViewSet(PathEndpointMixin, ModelViewSet): +class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', + 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet brief_prefetch_fields = ['device'] -class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') +class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): + queryset = FrontPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags' + ) serializer_class = serializers.FrontPortSerializer filterset_class = filtersets.FrontPortFilterSet brief_prefetch_fields = ['device'] -class RearPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') +class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): + queryset = RearPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags' + ) serializer_class = serializers.RearPortSerializer filterset_class = filtersets.RearPortFilterSet brief_prefetch_fields = ['device'] -class DeviceBayViewSet(ModelViewSet): - queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') +class ModuleBayViewSet(NetBoxModelViewSet): + queryset = ModuleBay.objects.prefetch_related('tags') + serializer_class = serializers.ModuleBaySerializer + filterset_class = filtersets.ModuleBayFilterSet + brief_prefetch_fields = ['device'] + + +class DeviceBayViewSet(NetBoxModelViewSet): + queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags') serializer_class = serializers.DeviceBaySerializer filterset_class = filtersets.DeviceBayFilterSet brief_prefetch_fields = ['device'] -class InventoryItemViewSet(ModelViewSet): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') +class InventoryItemViewSet(NetBoxModelViewSet): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filtersets.InventoryItemFilterSet brief_prefetch_fields = ['device'] +# +# Device component roles +# + +class InventoryItemRoleViewSet(NetBoxModelViewSet): + queryset = InventoryItemRole.objects.prefetch_related('tags').annotate( + inventoryitem_count=count_related(InventoryItem, 'role') + ) + serializer_class = serializers.InventoryItemRoleSerializer + filterset_class = filtersets.InventoryItemRoleFilterSet + + # # Cables # -class CableViewSet(ModelViewSet): +class CableViewSet(NetBoxModelViewSet): metadata_class = ContentTypeMetadata queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' @@ -600,7 +658,7 @@ class CableViewSet(ModelViewSet): # Virtual chassis # -class VirtualChassisViewSet(ModelViewSet): +class VirtualChassisViewSet(NetBoxModelViewSet): queryset = VirtualChassis.objects.prefetch_related('tags').annotate( member_count=count_related(Device, 'virtual_chassis') ) @@ -613,7 +671,7 @@ class VirtualChassisViewSet(ModelViewSet): # Power panels # -class PowerPanelViewSet(ModelViewSet): +class PowerPanelViewSet(NetBoxModelViewSet): queryset = PowerPanel.objects.prefetch_related( 'site', 'location' ).annotate( @@ -627,7 +685,7 @@ class PowerPanelViewSet(ModelViewSet): # Power feeds # -class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): +class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerFeed.objects.prefetch_related( 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' ) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8900b2d89..e369201b4 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class SiteStatusChoices(ChoiceSet): + key = 'Site.status' STATUS_PLANNED = 'planned' STATUS_STAGING = 'staging' @@ -13,21 +14,13 @@ class SiteStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' - CHOICES = ( - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGING, 'Staging'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - (STATUS_RETIRED, 'Retired'), - ) - - CSS_CLASSES = { - STATUS_PLANNED: 'info', - STATUS_STAGING: 'primary', - STATUS_ACTIVE: 'success', - STATUS_DECOMMISSIONING: 'warning', - STATUS_RETIRED: 'danger', - } + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), + ] # @@ -67,6 +60,7 @@ class RackWidthChoices(ChoiceSet): class RackStatusChoices(ChoiceSet): + key = 'Rack.status' STATUS_RESERVED = 'reserved' STATUS_AVAILABLE = 'available' @@ -74,21 +68,13 @@ class RackStatusChoices(ChoiceSet): STATUS_ACTIVE = 'active' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( - (STATUS_RESERVED, 'Reserved'), - (STATUS_AVAILABLE, 'Available'), - (STATUS_PLANNED, 'Planned'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DEPRECATED, 'Deprecated'), - ) - - CSS_CLASSES = { - STATUS_RESERVED: 'warning', - STATUS_AVAILABLE: 'success', - STATUS_PLANNED: 'info', - STATUS_ACTIVE: 'primary', - STATUS_DEPRECATED: 'danger', - } + CHOICES = [ + (STATUS_RESERVED, 'Reserved', 'yellow'), + (STATUS_AVAILABLE, 'Available', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_ACTIVE, 'Active', 'blue'), + (STATUS_DEPRECATED, 'Deprecated', 'red'), + ] class RackDimensionUnitChoices(ChoiceSet): @@ -144,6 +130,7 @@ class DeviceFaceChoices(ChoiceSet): class DeviceStatusChoices(ChoiceSet): + key = 'Device.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -153,25 +140,15 @@ class DeviceStatusChoices(ChoiceSet): STATUS_INVENTORY = 'inventory' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_INVENTORY, 'Inventory'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_INVENTORY: 'secondary', - STATUS_DECOMMISSIONING: 'warning', - } + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGED, 'Staged', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + (STATUS_INVENTORY, 'Inventory', 'purple'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + ] class DeviceAirflowChoices(ChoiceSet): @@ -368,6 +345,11 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_DC = 'dc-terminal' # Proprietary TYPE_SAF_D_GRID = 'saf-d-grid' + TYPE_NEUTRIK_POWERCON_20A = 'neutrik-powercon-20' + 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' @@ -479,6 +461,11 @@ class PowerPortTypeChoices(ChoiceSet): )), ('Proprietary', ( (TYPE_SAF_D_GRID, 'Saf-D-Grid'), + (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'), + (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'), @@ -584,6 +571,10 @@ class PowerOutletTypeChoices(ChoiceSet): # Proprietary TYPE_HDOT_CX = 'hdot-cx' TYPE_SAF_D_GRID = 'saf-d-grid' + TYPE_NEUTRIK_POWERCON_20A = 'neutrik-powercon-20a' + TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a' + TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' + TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' # Other TYPE_HARDWIRED = 'hardwired' @@ -688,6 +679,10 @@ class PowerOutletTypeChoices(ChoiceSet): ('Proprietary', ( (TYPE_HDOT_CX, 'HDOT Cx'), (TYPE_SAF_D_GRID, 'Saf-D-Grid'), + (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'), + (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), + (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), + (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), @@ -974,6 +969,19 @@ class InterfaceTypeChoices(ChoiceSet): ) +class InterfaceDuplexChoices(ChoiceSet): + + DUPLEX_HALF = 'half' + DUPLEX_FULL = 'full' + DUPLEX_AUTO = 'auto' + + CHOICES = ( + (DUPLEX_HALF, 'Half'), + (DUPLEX_FULL, 'Full'), + (DUPLEX_AUTO, 'Auto'), + ) + + class InterfaceModeChoices(ChoiceSet): MODE_ACCESS = 'access' @@ -1164,17 +1172,11 @@ class LinkStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( - (STATUS_CONNECTED, 'Connected'), - (STATUS_PLANNED, 'Planned'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_CONNECTED, 'Connected', 'green'), + (STATUS_PLANNED, 'Planned', 'blue'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), ) - CSS_CLASSES = { - STATUS_CONNECTED: 'success', - STATUS_PLANNED: 'info', - STATUS_DECOMMISSIONING: 'warning', - } - class CableLengthUnitChoices(ChoiceSet): @@ -1203,25 +1205,19 @@ class CableLengthUnitChoices(ChoiceSet): # class PowerFeedStatusChoices(ChoiceSet): + key = 'PowerFeed.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' STATUS_FAILED = 'failed' - CHOICES = ( - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_FAILED, 'Failed'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_FAILED: 'danger', - } + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + ] class PowerFeedTypeChoices(ChoiceSet): @@ -1230,15 +1226,10 @@ class PowerFeedTypeChoices(ChoiceSet): TYPE_REDUNDANT = 'redundant' CHOICES = ( - (TYPE_PRIMARY, 'Primary'), - (TYPE_REDUNDANT, 'Redundant'), + (TYPE_PRIMARY, 'Primary', 'green'), + (TYPE_REDUNDANT, 'Redundant', 'cyan'), ) - CSS_CLASSES = { - TYPE_PRIMARY: 'success', - TYPE_REDUNDANT: 'info', - } - class PowerFeedSupplyChoices(ChoiceSet): diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 2136f06aa..45844b049 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -50,16 +50,43 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES # -# PowerFeeds +# Power feeds # POWERFEED_VOLTAGE_DEFAULT = 120 - POWERFEED_AMPERAGE_DEFAULT = 20 - POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage +# +# Device components +# + +MODULAR_COMPONENT_TEMPLATE_MODELS = Q( + app_label='dcim', + model__in=( + 'consoleporttemplate', + 'consoleserverporttemplate', + 'frontporttemplate', + 'interfacetemplate', + 'poweroutlettemplate', + 'powerporttemplate', + 'rearporttemplate', + )) + +MODULAR_COMPONENT_MODELS = Q( + app_label='dcim', + model__in=( + 'consoleport', + 'consoleserverport', + 'frontport', + 'interface', + 'poweroutlet', + 'powerport', + 'rearport', + )) + + # # Cabling and connections # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 62326b289..54f533a7f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,14 +1,13 @@ import django_filters from django.contrib.auth.models import User -from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet -from ipam.models import ASN +from ipam.models import ASN, VRF from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, + BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) -from tenancy.filtersets import TenancyFilterSet -from tenancy.models import Tenant +from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet +from tenancy.models import * from utilities.choices import ColorChoices from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, @@ -39,8 +38,14 @@ __all__ = ( 'InterfaceFilterSet', 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', + 'InventoryItemRoleFilterSet', + 'InventoryItemTemplateFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', + 'ModuleBayFilterSet', + 'ModuleBayTemplateFilterSet', + 'ModuleFilterSet', + 'ModuleTypeFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -62,7 +67,7 @@ __all__ = ( ) -class RegionFilterSet(OrganizationalModelFilterSet): +class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Parent region (ID)', @@ -73,14 +78,13 @@ class RegionFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) - tag = TagFilter() class Meta: model = Region fields = ['id', 'name', 'slug', 'description'] -class SiteGroupFilterSet(OrganizationalModelFilterSet): +class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=SiteGroup.objects.all(), label='Parent site group (ID)', @@ -91,18 +95,13 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) - tag = TagFilter() class Meta: model = SiteGroup fields = ['id', 'name', 'slug', 'description'] -class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=SiteStatusChoices, null_value=None @@ -131,19 +130,23 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Group (slug)', ) + asn = django_filters.ModelMultipleChoiceFilter( + field_name='asns__asn', + queryset=ASN.objects.all(), + to_field_name='asn', + label='AS (ID)', + ) asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), label='AS (ID)', ) - tag = TagFilter() class Meta: model = Site - fields = [ - 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'description' - ] + fields = ( + 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description' + ) def search(self, queryset, name, value): if not value.strip(): @@ -154,20 +157,16 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): Q(description__icontains=value) | Q(physical_address__icontains=value) | Q(shipping_address__icontains=value) | - Q(contact_name__icontains=value) | - Q(contact_phone__icontains=value) | - Q(contact_email__icontains=value) | Q(comments__icontains=value) ) try: - qs_filter |= Q(asn=int(value.strip())) qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass return queryset.filter(qs_filter) -class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): +class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -217,7 +216,6 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): to_field_name='slug', label='Location (slug)', ) - tag = TagFilter() class Meta: model = Location @@ -233,18 +231,13 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RackRole fields = ['id', 'name', 'slug', 'color', 'description'] -class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -317,7 +310,6 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): serial = django_filters.CharFilter( lookup_expr='iexact' ) - tag = TagFilter() class Meta: model = Rack @@ -338,11 +330,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): rack_id = django_filters.ModelMultipleChoiceFilter( queryset=Rack.objects.all(), label='Rack (ID)', @@ -381,7 +369,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='username', label='User (name)', ) - tag = TagFilter() class Meta: model = RackReservation @@ -398,19 +385,14 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class ManufacturerFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() +class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class Meta: model = Manufacturer fields = ['id', 'name', 'slug', 'description'] -class DeviceTypeFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class DeviceTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', @@ -445,11 +427,18 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', ) - tag = TagFilter() + inventory_items = django_filters.BooleanFilter( + method='_inventory_items', + label='Has inventory items', + ) class Meta: model = DeviceType @@ -488,9 +477,87 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): rearporttemplates__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebaytemplates__isnull=value) + 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( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + console_ports = django_filters.BooleanFilter( + method='_console_ports', + label='Has console ports', + ) + console_server_ports = django_filters.BooleanFilter( + method='_console_server_ports', + label='Has console server ports', + ) + power_ports = django_filters.BooleanFilter( + method='_power_ports', + label='Has power ports', + ) + power_outlets = django_filters.BooleanFilter( + method='_power_outlets', + label='Has power outlets', + ) + interfaces = django_filters.BooleanFilter( + method='_interfaces', + label='Has interfaces', + ) + pass_through_ports = django_filters.BooleanFilter( + method='_pass_through_ports', + label='Has pass-through ports', + ) + + class Meta: + model = ModuleType + fields = ['id', 'model', 'part_number'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(manufacturer__name__icontains=value) | + Q(model__icontains=value) | + Q(part_number__icontains=value) | + Q(comments__icontains=value) + ) + + def _console_ports(self, queryset, name, value): + return queryset.exclude(consoleporttemplates__isnull=value) + + def _console_server_ports(self, queryset, name, value): + return queryset.exclude(consoleserverporttemplates__isnull=value) + + def _power_ports(self, queryset, name, value): + return queryset.exclude(powerporttemplates__isnull=value) + + def _power_outlets(self, queryset, name, value): + return queryset.exclude(poweroutlettemplates__isnull=value) + + def _interfaces(self, queryset, name, value): + return queryset.exclude(interfacetemplates__isnull=value) + + def _pass_through_ports(self, queryset, name, value): + return queryset.exclude( + frontporttemplates__isnull=value, + rearporttemplates__isnull=value + ) + class DeviceTypeComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( @@ -509,28 +576,36 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): return queryset.filter(name__icontains=value) -class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): + moduletype_id = django_filters.ModelMultipleChoiceFilter( + queryset=ModuleType.objects.all(), + field_name='module_type_id', + label='Module type (ID)', + ) + + +class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate fields = ['id', 'name', 'type'] -class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate fields = ['id', 'name', 'type'] -class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw'] -class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): feed_leg = django_filters.MultipleChoiceFilter( choices=PowerOutletFeedLegChoices, null_value=None @@ -541,7 +616,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompone fields = ['id', 'name', 'type', 'feed_leg'] -class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, null_value=None @@ -552,7 +627,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name', 'type', 'mgmt_only'] -class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -563,7 +638,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name', 'type', 'color'] -class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -574,6 +649,13 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentF fields = ['id', 'name', 'type', 'color', 'positions'] +class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + + class Meta: + model = ModuleBayTemplate + fields = ['id', 'name'] + + class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: @@ -581,8 +663,50 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name'] +class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemTemplate.objects.all(), + label='Parent inventory item (ID)', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=InventoryItemRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + component_type = ContentTypeFilter() + component_id = MultiValueNumberFilter() + + class Meta: + model = InventoryItemTemplate + fields = ['id', 'name', 'label', 'part_id'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(part_id__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) + + class DeviceRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = DeviceRole @@ -601,18 +725,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) - tag = TagFilter() class Meta: model = Platform fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] -class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), @@ -639,6 +758,11 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex to_field_name='slug', label='Role (slug)', ) + parent_device_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent_bay__device', + queryset=Device.objects.all(), + label='Parent Device (ID)', + ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', @@ -758,11 +882,14 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = Device @@ -809,10 +936,55 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex rearports__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebays__isnull=value) + def _device_bays(self, queryset, name, value): return queryset.exclude(devicebays__isnull=value) +class ModuleFilterSet(NetBoxModelFilterSet): + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + module_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type', + queryset=ModuleType.objects.all(), + label='Module type (ID)', + ) + module_type = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__model', + queryset=ModuleType.objects.all(), + to_field_name='model', + label='Module type (model)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + + class Meta: + model = Module + fields = ['id', 'serial', 'asset_tag'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(serial__icontains=value.strip()) | + Q(asset_tag__icontains=value.strip()) | + Q(comments__icontains=value) + ).distinct() + + class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', @@ -887,7 +1059,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Virtual Chassis', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -899,6 +1070,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) +class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): + """ + Extends DeviceComponentFilterSet to add a module_id filter for components + which can be associated with a particular module within a device. + """ + module_id = django_filters.ModelMultipleChoiceFilter( + queryset=Module.objects.all(), + label='Module (ID)', + ) + + class CableTerminationFilterSet(django_filters.FilterSet): cabled = django_filters.BooleanFilter( field_name='cable', @@ -919,7 +1101,12 @@ class PathEndpointFilterSet(django_filters.FilterSet): return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) -class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsolePortFilterSet( + ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -930,7 +1117,12 @@ class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'description'] -class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsoleServerPortFilterSet( + ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -941,7 +1133,12 @@ class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet fields = ['id', 'name', 'label', 'description'] -class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerPortFilterSet( + ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -952,7 +1149,12 @@ class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerOutletFilterSet( + ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -967,11 +1169,12 @@ class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'feed_leg', 'description'] -class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class InterfaceFilterSet( + ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis # members device = MultiValueCharFilter( @@ -1003,9 +1206,12 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT queryset=Interface.objects.all(), label='LAG interface (ID)', ) + speed = MultiValueNumberFilter() + duplex = django_filters.MultipleChoiceFilter( + choices=InterfaceDuplexChoices + ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() - tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1024,6 +1230,17 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT rf_channel = django_filters.MultipleChoiceFilter( choices=WirelessChannelChoices ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='vrf', + queryset=VRF.objects.all(), + label='VRF', + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) class Meta: model = Interface @@ -1080,7 +1297,11 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT }.get(value, queryset.none()) -class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class FrontPortFilterSet( + ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, + CableTerminationFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1091,7 +1312,11 @@ class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'type', 'color', 'description'] -class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class RearPortFilterSet( + ModularDeviceComponentFilterSet, + NetBoxModelFilterSet, + CableTerminationFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1102,18 +1327,21 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] -class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): + + class Meta: + model = ModuleBay + fields = ['id', 'name', 'label', 'description'] + + +class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'label', 'description'] -class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), label='Parent inventory item (ID)', @@ -1128,6 +1356,18 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=InventoryItemRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + component_type = ContentTypeFilter() + component_id = MultiValueNumberFilter() serial = django_filters.CharFilter( lookup_expr='iexact' ) @@ -1149,11 +1389,14 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): return queryset.filter(qs_filter) -class VirtualChassisFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): + + class Meta: + model = InventoryItemRole + fields = ['id', 'name', 'slug', 'color'] + + +class VirtualChassisFilterSet(NetBoxModelFilterSet): master_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Master (ID)', @@ -1212,7 +1455,6 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Tenant (slug)', ) - tag = TagFilter() class Meta: model = VirtualChassis @@ -1229,11 +1471,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() -class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): termination_a_type = ContentTypeFilter() termination_a_id = MultiValueNumberFilter() termination_b_type = ContentTypeFilter() @@ -1270,7 +1508,6 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): method='filter_device', field_name='device__site__slug' ) - tag = TagFilter() class Meta: model = Cable @@ -1289,11 +1526,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset -class PowerPanelFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -1336,7 +1569,6 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): lookup_expr='in', label='Location (ID)', ) - tag = TagFilter() class Meta: model = PowerPanel @@ -1351,11 +1583,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='power_panel__site__region', @@ -1406,7 +1634,6 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE choices=PowerFeedStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = PowerFeed diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 16e860c38..314a7a75f 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -3,8 +3,8 @@ 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 .object_create import ComponentForm +from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model +from .object_create import ComponentCreateForm __all__ = ( 'ConsolePortBulkCreateForm', @@ -13,6 +13,7 @@ __all__ = ( # 'FrontPortBulkCreateForm', 'InterfaceBulkCreateForm', 'InventoryItemBulkCreateForm', + 'ModuleBayBulkCreateForm', 'PowerOutletBulkCreateForm', 'PowerPortBulkCreateForm', 'RearPortBulkCreateForm', @@ -23,7 +24,7 @@ __all__ = ( # Device components # -class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm): +class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -71,12 +72,12 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', ) @@ -95,17 +96,28 @@ class RearPortBulkCreateForm( field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') +class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): + model = ModuleBay + 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): model = DeviceBay field_order = ('name_pattern', 'label_pattern', 'description', 'tags') class InventoryItemBulkCreateForm( - form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), + form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), DeviceBulkAddComponentForm ): model = InventoryItem field_order = ( - 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', - 'tags', + 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 3b604d79d..9e4f5e400 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,13 +6,12 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX -from ipam.models import VLAN, ASN +from ipam.models import ASN, VLAN, VRF +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, + DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, ) __all__ = ( @@ -31,8 +30,14 @@ __all__ = ( 'InterfaceBulkEditForm', 'InterfaceTemplateBulkEditForm', 'InventoryItemBulkEditForm', + 'InventoryItemRoleBulkEditForm', + 'InventoryItemTemplateBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', + 'ModuleBulkEditForm', + 'ModuleBayBulkEditForm', + 'ModuleBayTemplateBulkEditForm', + 'ModuleTypeBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -52,11 +57,7 @@ __all__ = ( ) -class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Region.objects.all(), - widget=forms.MultipleHiddenInput - ) +class RegionBulkEditForm(NetBoxModelBulkEditForm): parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -66,15 +67,14 @@ class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'description'] - - -class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - widget=forms.MultipleHiddenInput + model = Region + fieldsets = ( + (None, ('parent', 'description')), ) + nullable_fields = ('parent', 'description') + + +class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): parent = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False @@ -84,15 +84,14 @@ class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'description'] - - -class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Site.objects.all(), - widget=forms.MultipleHiddenInput + model = SiteGroup + fieldsets = ( + (None, ('parent', 'description')), ) + nullable_fields = ('parent', 'description') + + +class SiteBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(SiteStatusChoices), required=False, @@ -111,17 +110,23 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - asn = forms.IntegerField( - min_value=BGP_ASN_MIN, - max_value=BGP_ASN_MAX, - required=False, - label='ASN' - ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), required=False ) + contact_name = forms.CharField( + max_length=50, + required=False + ) + contact_phone = forms.CharField( + max_length=20, + required=False + ) + contact_email = forms.EmailField( + required=False, + label='Contact E-mail' + ) description = forms.CharField( max_length=100, required=False @@ -132,17 +137,16 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = [ - 'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone', - ] - - -class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Location.objects.all(), - widget=forms.MultipleHiddenInput + model = Site + fieldsets = ( + (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), ) + nullable_fields = ( + 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + ) + + +class LocationBulkEditForm(NetBoxModelBulkEditForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False @@ -163,15 +167,14 @@ class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'tenant', 'description'] - - -class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackRole.objects.all(), - widget=forms.MultipleHiddenInput + model = Location + fieldsets = ( + (None, ('site', 'parent', 'tenant', 'description')), ) + nullable_fields = ('parent', 'tenant', 'description') + + +class RackRoleBulkEditForm(NetBoxModelBulkEditForm): color = ColorField( required=False ) @@ -180,15 +183,14 @@ class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['color', 'description'] - - -class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Rack.objects.all(), - widget=forms.MultipleHiddenInput + model = RackRole + fieldsets = ( + (None, ('color', 'description')), ) + nullable_fields = ('color', 'description') + + +class RackBulkEditForm(NetBoxModelBulkEditForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -278,17 +280,18 @@ class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - ] - - -class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackReservation.objects.all(), - widget=forms.MultipleHiddenInput() + model = Rack + fieldsets = ( + ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), + ('Location', ('region', 'site_group', 'site', 'location')), + ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), ) + nullable_fields = ( + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + ) + + +class RackReservationBulkEditForm(NetBoxModelBulkEditForm): user = forms.ModelChoiceField( queryset=User.objects.order_by( 'username' @@ -305,33 +308,33 @@ class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor required=False ) - class Meta: - nullable_fields = [] - - -class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - widget=forms.MultipleHiddenInput + model = RackReservation + fieldsets = ( + (None, ('user', 'tenant', 'description')), ) + + +class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( max_length=200, required=False ) - class Meta: - nullable_fields = ['description'] - - -class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceType.objects.all(), - widget=forms.MultipleHiddenInput() + model = Manufacturer + fieldsets = ( + (None, ('description',)), ) + nullable_fields = ('description',) + + +class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) + part_number = forms.CharField( + required=False + ) u_height = forms.IntegerField( min_value=1, required=False @@ -347,15 +350,30 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ['airflow'] - - -class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - widget=forms.MultipleHiddenInput + model = DeviceType + fieldsets = ( + (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), ) + nullable_fields = ('part_number', 'airflow') + + +class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + part_number = forms.CharField( + required=False + ) + + model = ModuleType + fieldsets = ( + (None, ('manufacturer', 'part_number')), + ) + nullable_fields = ('part_number',) + + +class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): color = ColorField( required=False ) @@ -369,15 +387,14 @@ class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['color', 'description'] - - -class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Platform.objects.all(), - widget=forms.MultipleHiddenInput + model = DeviceRole + fieldsets = ( + (None, ('color', 'vm_role', 'description')), ) + nullable_fields = ('color', 'description') + + +class PlatformBulkEditForm(NetBoxModelBulkEditForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -392,15 +409,14 @@ class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['manufacturer', 'napalm_driver', 'description'] - - -class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Device.objects.all(), - widget=forms.MultipleHiddenInput() + model = Platform + fieldsets = ( + (None, ('manufacturer', 'napalm_driver', 'description')), ) + nullable_fields = ('manufacturer', 'napalm_driver', 'description') + + +class DeviceBulkEditForm(NetBoxModelBulkEditForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -451,17 +467,43 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Serial Number' ) - class Meta: - nullable_fields = [ - 'tenant', 'platform', 'serial', 'airflow', - ] - - -class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Cable.objects.all(), - widget=forms.MultipleHiddenInput + model = Device + fieldsets = ( + ('Device', ('device_role', 'status', 'tenant', 'platform')), + ('Location', ('site', 'location')), + ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ) + nullable_fields = ( + 'tenant', 'platform', 'serial', 'airflow', + ) + + +class ModuleBulkEditForm(NetBoxModelBulkEditForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + + model = Module + fieldsets = ( + (None, ('manufacturer', 'module_type', 'serial')), + ) + nullable_fields = ('serial',) + + +class CableBulkEditForm(NetBoxModelBulkEditForm): type = forms.ChoiceField( choices=add_blank_choice(CableTypeChoices), required=False, @@ -496,10 +538,14 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = [ - 'type', 'status', 'tenant', 'label', 'color', 'length', - ] + model = Cable + fieldsets = ( + (None, ('type', 'status', 'tenant', 'label')), + ('Attributes', ('color', 'length', 'length_unit')), + ) + nullable_fields = ( + 'type', 'status', 'tenant', 'label', 'color', 'length', + ) def clean(self): super().clean() @@ -513,25 +559,20 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): }) -class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualChassis.objects.all(), - widget=forms.MultipleHiddenInput() - ) +class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): domain = forms.CharField( max_length=30, required=False ) - class Meta: - nullable_fields = ['domain'] - - -class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPanel.objects.all(), - widget=forms.MultipleHiddenInput + model = VirtualChassis + fieldsets = ( + (None, ('domain',)), ) + nullable_fields = ('domain',) + + +class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -562,15 +603,14 @@ class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): } ) - class Meta: - nullable_fields = ['location'] - - -class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), - widget=forms.MultipleHiddenInput + model = PowerPanel + fieldsets = ( + (None, ('region', 'site_group', 'site', 'location')), ) + nullable_fields = ('location',) + + +class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), required=False @@ -621,10 +661,12 @@ class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'location', 'comments', - ] + model = PowerFeed + fieldsets = ( + (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected')), + ('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) + ) + nullable_fields = ('location', 'comments') # @@ -646,8 +688,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ('label', 'type', 'description') + nullable_fields = ('label', 'type', 'description') class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): @@ -668,8 +709,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'description') + nullable_fields = ('label', 'type', 'description') class PowerPortTemplateBulkEditForm(BulkEditForm): @@ -700,8 +740,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') + nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateBulkEditForm(BulkEditForm): @@ -737,8 +776,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') + nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -775,8 +813,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class FrontPortTemplateBulkEditForm(BulkEditForm): @@ -800,8 +837,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class RearPortTemplateBulkEditForm(BulkEditForm): @@ -825,8 +861,23 @@ class RearPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) + + +class ModuleBayTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ModuleBayTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + + nullable_fields = ('label', 'position', 'description') class DeviceBayTemplateBulkEditForm(BulkEditForm): @@ -842,90 +893,125 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') + + +class InventoryItemTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItemTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') # # Device components # -class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=ConsolePort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class ConsoleServerPortBulkEditForm( - form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class PowerPortBulkEditForm( - form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class PowerOutletBulkEditForm( - form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput() - ) +class ComponentBulkEditForm(NetBoxModelBulkEditForm): device = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, disabled=True, widget=forms.HiddenInput() ) + module = forms.ModelChoiceField( + queryset=Module.objects.all(), + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit module queryset to Modules which belong to the parent Device + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + self.fields['module'].queryset = Module.objects.filter(device=device) + else: + self.fields['module'].choices = () + self.fields['module'].widget.attrs['disabled'] = True + + +class ConsolePortBulkEditForm( + form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), + ComponentBulkEditForm +): mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect ) - class Meta: - nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] + model = ConsolePort + fieldsets = ( + (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + ) + nullable_fields = ('module', 'label', 'description') + + +class ConsoleServerPortBulkEditForm( + form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), + ComponentBulkEditForm +): + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = ConsoleServerPort + fieldsets = ( + (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + ) + nullable_fields = ('module', 'label', 'description') + + +class PowerPortBulkEditForm( + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), + ComponentBulkEditForm +): + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = PowerPort + fieldsets = ( + (None, ('module', 'type', 'label', 'description', 'mark_connected')), + ('Power', ('maximum_draw', 'allocated_draw')), + ) + nullable_fields = ('module', 'label', 'description') + + +class PowerOutletBulkEditForm( + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), + ComponentBulkEditForm +): + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = PowerOutlet + fieldsets = ( + (None, ('module', 'type', 'label', 'description', 'mark_connected')), + ('Power', ('feed_leg', 'power_port')), + ) + nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -941,22 +1027,12 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', - 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'tx_power', ]), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + ComponentBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -974,7 +1050,13 @@ class InterfaceBulkEditForm( required=False, query_params={ 'type': 'lag', - } + }, + label='LAG' + ) + speed = forms.IntegerField( + required=False, + widget=SelectSpeedWidget(), + label='Speed' ) mgmt_only = forms.NullBooleanField( required=False, @@ -993,12 +1075,26 @@ class InterfaceBulkEditForm( queryset=VLAN.objects.all(), required=False ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) - class Meta: - nullable_fields = [ - 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', - ] + model = Interface + fieldsets = ( + (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), + ('Addressing', ('vrf', 'mac_address', 'wwn')), + ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), + ) + nullable_fields = ( + 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', + 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', + 'vrf', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1062,59 +1158,87 @@ class InterfaceBulkEditForm( class FrontPortBulkEditForm( form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + ComponentBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput() + model = FrontPort + fieldsets = ( + (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), ) - - class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('module', 'label', 'description') class RearPortBulkEditForm( form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + ComponentBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput() + model = RearPort + fieldsets = ( + (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), ) + nullable_fields = ('module', 'label', 'description') - class Meta: - nullable_fields = ['label', 'description'] + +class ModuleBayBulkEditForm( + form_from_model(ModuleBay, ['label', 'position', 'description']), + NetBoxModelBulkEditForm +): + model = ModuleBay + fieldsets = ( + (None, ('label', 'position', 'description')), + ) + nullable_fields = ('label', 'position', 'description') class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() + model = DeviceBay + fieldsets = ( + (None, ('label', 'description')), ) - - class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class InventoryItemBulkEditForm( - form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), + NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=InventoryItem.objects.all(), - widget=forms.MultipleHiddenInput() + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) - class Meta: - nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] + model = InventoryItem + fieldsets = ( + (None, ('device', 'label', 'role', 'manufacturer', 'part_id', 'description')), + ) + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') + + +# +# Device component roles +# + +class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): + color = ColorField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + model = InventoryItemRole + fieldsets = ( + (None, ('color', 'description')), + ) + nullable_fields = ('color', 'description') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 59b59bb00..b28c16fad 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -7,7 +7,8 @@ from django.utils.safestring import mark_safe from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelCSVForm +from ipam.models import VRF +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster @@ -24,8 +25,11 @@ __all__ = ( 'FrontPortCSVForm', 'InterfaceCSVForm', 'InventoryItemCSVForm', + 'InventoryItemRoleCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', + 'ModuleCSVForm', + 'ModuleBayCSVForm', 'PlatformCSVForm', 'PowerFeedCSVForm', 'PowerOutletCSVForm', @@ -42,7 +46,7 @@ __all__ = ( ) -class RegionCSVForm(CustomFieldModelCSVForm): +class RegionCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, @@ -55,7 +59,7 @@ class RegionCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteGroupCSVForm(CustomFieldModelCSVForm): +class SiteGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -68,7 +72,7 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteCSVForm(CustomFieldModelCSVForm): +class SiteCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, help_text='Operational status' @@ -96,8 +100,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): model = Site fields = ( 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', ) help_texts = { 'time_zone': mark_safe( @@ -106,7 +109,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): } -class LocationCSVForm(CustomFieldModelCSVForm): +class LocationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -133,7 +136,7 @@ class LocationCSVForm(CustomFieldModelCSVForm): fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') -class RackRoleCSVForm(CustomFieldModelCSVForm): +class RackRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -144,7 +147,7 @@ class RackRoleCSVForm(CustomFieldModelCSVForm): } -class RackCSVForm(CustomFieldModelCSVForm): +class RackCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name' @@ -202,7 +205,7 @@ class RackCSVForm(CustomFieldModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class RackReservationCSVForm(CustomFieldModelCSVForm): +class RackReservationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -252,14 +255,14 @@ class RackReservationCSVForm(CustomFieldModelCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ManufacturerCSVForm(CustomFieldModelCSVForm): +class ManufacturerCSVForm(NetBoxModelCSVForm): class Meta: model = Manufacturer fields = ('name', 'slug', 'description') -class DeviceRoleCSVForm(CustomFieldModelCSVForm): +class DeviceRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -270,7 +273,7 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm): } -class PlatformCSVForm(CustomFieldModelCSVForm): +class PlatformCSVForm(NetBoxModelCSVForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), @@ -284,7 +287,7 @@ class PlatformCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') -class BaseDeviceCSVForm(CustomFieldModelCSVForm): +class BaseDeviceCSVForm(NetBoxModelCSVForm): device_role = CSVModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -400,6 +403,35 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) +class ModuleCSVForm(NetBoxModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + module_bay = CSVModelChoiceField( + queryset=ModuleBay.objects.all(), + to_field_name='name' + ) + module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name='model' + ) + + class Meta: + model = Module + fields = ( + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit module_bay queryset by assigned device + params = {f"device__{self.fields['device'].to_field_name}": data.get('device')} + self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params) + + class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), @@ -446,7 +478,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): # Device components # -class ConsolePortCSVForm(CustomFieldModelCSVForm): +class ConsolePortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -469,7 +501,7 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') -class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): +class ConsoleServerPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -492,7 +524,7 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') -class PowerPortCSVForm(CustomFieldModelCSVForm): +class PowerPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -510,7 +542,7 @@ class PowerPortCSVForm(CustomFieldModelCSVForm): ) -class PowerOutletCSVForm(CustomFieldModelCSVForm): +class PowerOutletCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -559,7 +591,7 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm): self.fields['power_port'].queryset = PowerPort.objects.none() -class InterfaceCSVForm(CustomFieldModelCSVForm): +class InterfaceCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -586,11 +618,21 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): choices=InterfaceTypeChoices, help_text='Physical medium' ) + duplex = CSVChoiceField( + choices=InterfaceDuplexChoices, + required=False + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Assigned VRF' + ) rf_role = CSVChoiceField( choices=WirelessRoleChoices, required=False, @@ -600,8 +642,8 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', - 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', + 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) @@ -609,11 +651,11 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): super().__init__(data, *args, **kwargs) if data: - # Limit interface choices for parent, bridge and lag to device only - params = {} - if data.get('device'): - params[f"device__{self.fields['device'].to_field_name}"] = data.get('device') - if params: + # Limit choices for parent, bridge, and LAG interfaces to the assigned device + if device := data.get('device'): + params = { + f"device__{self.fields['device'].to_field_name}": device + } self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params) @@ -626,7 +668,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): return self.cleaned_data['enabled'] -class FrontPortCSVForm(CustomFieldModelCSVForm): +class FrontPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -674,7 +716,7 @@ class FrontPortCSVForm(CustomFieldModelCSVForm): self.fields['rear_port'].queryset = RearPort.objects.none() -class RearPortCSVForm(CustomFieldModelCSVForm): +class RearPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -692,7 +734,18 @@ class RearPortCSVForm(CustomFieldModelCSVForm): } -class DeviceBayCSVForm(CustomFieldModelCSVForm): +class ModuleBayCSVForm(NetBoxModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleBay + fields = ('device', 'name', 'label', 'position', 'description') + + +class DeviceBayCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -738,11 +791,16 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm): self.fields['installed_device'].queryset = Interface.objects.none() -class InventoryItemCSVForm(CustomFieldModelCSVForm): +class InventoryItemCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + role = CSVModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', @@ -758,7 +816,8 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): class Meta: model = InventoryItem fields = ( - 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', + 'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', ) def __init__(self, *args, **kwargs): @@ -777,7 +836,26 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): self.fields['parent'].queryset = InventoryItem.objects.none() -class CableCSVForm(CustomFieldModelCSVForm): +# +# Device component roles +# + +class InventoryItemRoleCSVForm(NetBoxModelCSVForm): + slug = SlugField() + + class Meta: + model = InventoryItemRole + fields = ('name', 'slug', 'color', 'description') + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + +# +# Cables +# + +class CableCSVForm(NetBoxModelCSVForm): # Termination A side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -878,7 +956,11 @@ class CableCSVForm(CustomFieldModelCSVForm): return length_unit if length_unit is not None else '' -class VirtualChassisCSVForm(CustomFieldModelCSVForm): +# +# Virtual chassis +# + +class VirtualChassisCSVForm(NetBoxModelCSVForm): master = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -891,7 +973,11 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm): fields = ('name', 'domain', 'master') -class PowerPanelCSVForm(CustomFieldModelCSVForm): +# +# Power +# + +class PowerPanelCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -917,7 +1003,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class PowerFeedCSVForm(CustomFieldModelCSVForm): +class PowerFeedCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 6a7a09023..1ba7adf84 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,7 +1,7 @@ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * -from extras.forms import CustomFieldModelForm from extras.models import Tag +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect @@ -18,7 +18,7 @@ __all__ = ( ) -class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm): """ Base form for connecting a Cable to a Device component """ @@ -70,10 +70,6 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): 'rack_id': '$termination_b_rack', } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Cable @@ -171,7 +167,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm): termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', @@ -212,10 +208,6 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): 'circuit_id': '$termination_b_circuit' } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta(ConnectCableToDeviceForm.Meta): fields = [ @@ -229,7 +221,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): return getattr(self.cleaned_data['termination_b_id'], 'pk', None) -class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm): termination_b_region = DynamicModelChoiceField( queryset=Region.objects.all(), label='Region', @@ -274,10 +266,6 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): 'power_panel_id': '$termination_b_powerpanel' } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta(ConnectCableToDeviceForm.Meta): fields = [ diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index f4b4c0a87..7f30941a2 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -5,12 +5,13 @@ from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm -from ipam.models import ASN -from tenancy.forms import TenancyFilterForm +from extras.forms import LocalConfigContextFilterForm +from ipam.models import ASN, VRF +from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import ( - APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, - StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField, + StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget, ) from wireless.choices import * @@ -27,8 +28,13 @@ __all__ = ( 'InterfaceConnectionFilterForm', 'InterfaceFilterForm', 'InventoryItemFilterForm', + 'InventoryItemRoleFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', + 'ModuleFilterForm', + 'ModuleFilterForm', + 'ModuleBayFilterForm', + 'ModuleTypeFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -47,7 +53,7 @@ __all__ = ( ) -class DeviceComponentFilterForm(CustomFieldModelFilterForm): +class DeviceComponentFilterForm(NetBoxModelFilterSetForm): name = forms.CharField( required=False ) @@ -98,8 +104,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): ) -class RegionFilterForm(CustomFieldModelFilterForm): +class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region + fieldsets = ( + (None, ('q', 'tag', 'parent_id')), + ('Contacts', ('contact', 'contact_role')) + ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -108,8 +118,12 @@ class RegionFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class SiteGroupFilterForm(CustomFieldModelFilterForm): +class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup + fieldsets = ( + (None, ('q', 'tag', 'parent_id')), + ('Contacts', ('contact', 'contact_role')) + ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -118,18 +132,17 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site - field_groups = [ - ['q', 'tag'], - ['status', 'region_id', 'group_id'], - ['tenant_group_id', 'tenant_id'], - ['asn_id'] - ] - status = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) + status = MultipleChoiceField( choices=SiteStatusChoices, - required=False, - widget=StaticSelectMultiple(), + required=False ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -149,13 +162,14 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id', 'parent_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -187,20 +201,21 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class RackRoleFilterForm(CustomFieldModelFilterForm): +class RackRoleFilterForm(NetBoxModelFilterSetForm): model = RackRole tag = TagFilterField(model) -class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_id', 'location_id'], - ['status', 'role_id'], - ['type', 'width', 'serial', 'asset_tag'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_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')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -223,20 +238,17 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): }, label=_('Location') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=RackStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) - type = forms.MultipleChoiceField( + type = MultipleChoiceField( choices=RackTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) - width = forms.MultipleChoiceField( + width = MultipleChoiceField( choices=RackWidthChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) role_id = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), @@ -265,14 +277,14 @@ class RackElevationFilterForm(RackFilterForm): ) -class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation - field_groups = [ - ['q', 'tag'], - ['user_id'], - ['region_id', 'site_id', 'location_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('User', ('user_id',)), + ('Rack', ('region_id', 'site_id', 'location_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -303,32 +315,125 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class ManufacturerFilterForm(CustomFieldModelFilterForm): +class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer + fieldsets = ( + (None, ('q', 'tag')), + ('Contacts', ('contact', 'contact_role')) + ) tag = TagFilterField(model) -class DeviceTypeFilterForm(CustomFieldModelFilterForm): +class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType - field_groups = [ - ['q', 'tag'], - ['manufacturer_id', 'subdevice_role', 'airflow'], - ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', + 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', + )), + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, label=_('Manufacturer') ) - subdevice_role = forms.MultipleChoiceField( - choices=add_blank_choice(SubdeviceRoleChoices), - required=False, - widget=StaticSelectMultiple() + part_number = forms.CharField( + required=False ) - airflow = forms.MultipleChoiceField( + subdevice_role = MultipleChoiceField( + choices=add_blank_choice(SubdeviceRoleChoices), + required=False + ) + airflow = MultipleChoiceField( choices=add_blank_choice(DeviceAirflowChoices), + required=False + ) + console_ports = forms.NullBooleanField( required=False, - widget=StaticSelectMultiple() + label='Has console ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=StaticSelect( + 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) + + +class ModuleTypeFilterForm(NetBoxModelFilterSetForm): + model = ModuleType + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'part_number')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', + 'pass_through_ports', + )), + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + part_number = forms.CharField( + required=False ) console_ports = forms.NullBooleanField( required=False, @@ -375,12 +480,12 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class DeviceRoleFilterForm(CustomFieldModelFilterForm): +class DeviceRoleFilterForm(NetBoxModelFilterSetForm): model = DeviceRole tag = TagFilterField(model) -class PlatformFilterForm(CustomFieldModelFilterForm): +class PlatformFilterForm(NetBoxModelFilterSetForm): model = Platform manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -390,19 +495,25 @@ class PlatformFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): +class DeviceFilterForm( + LocalConfigContextFilterForm, + TenancyFilterForm, + ContactModelFilterForm, + NetBoxModelFilterSetForm +): model = Device - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], - ['status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address'], - ['manufacturer_id', 'device_type_id', 'platform_id'], - ['tenant_group_id', 'tenant_id'], - [ - 'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports', - 'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data', - ], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('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')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', + )), + ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data')) + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -465,15 +576,13 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi null_option='None', label=_('Platform') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=DeviceStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) - airflow = forms.MultipleChoiceField( + airflow = MultipleChoiceField( choices=add_blank_choice(DeviceAirflowChoices), - required=False, - widget=StaticSelectMultiple() + required=False ) serial = forms.CharField( required=False @@ -544,13 +653,43 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi tag = TagFilterField(model) -class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): + model = Module + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + module_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Type'), + fetch_trigger='open' + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + tag = TagFilterField(model) + + +class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -573,14 +712,14 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable - field_groups = [ - ['q', 'tag'], - ['site_id', 'rack_id', 'device_id'], - ['type', 'status', 'color', 'length', 'length_unit'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('site_id', 'rack_id', 'device_id')), + ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -613,15 +752,13 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): }, label=_('Device') ) - type = forms.MultipleChoiceField( + type = MultipleChoiceField( choices=add_blank_choice(CableTypeChoices), - required=False, - widget=StaticSelect() + required=False ) - status = forms.ChoiceField( + status = MultipleChoiceField( required=False, - choices=add_blank_choice(LinkStatusChoices), - widget=StaticSelect() + choices=add_blank_choice(LinkStatusChoices) ) color = ColorField( required=False @@ -636,11 +773,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class PowerPanelFilterForm(CustomFieldModelFilterForm): +class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel - field_groups = ( - ('q', 'tag'), - ('region_id', 'site_group_id', 'site_id', 'location_id') + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), + ('Contacts', ('contact', 'contact_role')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -673,14 +811,13 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class PowerFeedFilterForm(CustomFieldModelFilterForm): +class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['power_panel_id', 'rack_id'], - ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), + ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -717,10 +854,9 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): }, label=_('Rack') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=PowerFeedStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) type = forms.ChoiceField( choices=add_blank_choice(PowerFeedTypeChoices), @@ -755,91 +891,93 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): class ConsolePortFilterForm(DeviceComponentFilterForm): model = ConsolePort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False, - widget=StaticSelectMultiple() + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) - speed = forms.MultipleChoiceField( + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( choices=ConsolePortSpeedChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class ConsoleServerPortFilterForm(DeviceComponentFilterForm): model = ConsoleServerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False, - widget=StaticSelectMultiple() + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) - speed = forms.MultipleChoiceField( + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( choices=ConsolePortSpeedChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class PowerPortFilterForm(DeviceComponentFilterForm): model = PowerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + type = MultipleChoiceField( choices=PowerPortTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class PowerOutletFilterForm(DeviceComponentFilterForm): model = PowerOutlet - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + type = MultipleChoiceField( choices=PowerOutletTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], - ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - kind = forms.MultipleChoiceField( - choices=InterfaceKindChoices, - required=False, - widget=StaticSelectMultiple() + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), + ('Addressing', ('vrf_id', 'mac_address', 'wwn')), + ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) - type = forms.MultipleChoiceField( + kind = MultipleChoiceField( + choices=InterfaceKindChoices, + required=False + ) + type = MultipleChoiceField( choices=InterfaceTypeChoices, + required=False + ) + speed = forms.IntegerField( required=False, - widget=StaticSelectMultiple() + label='Select Speed', + widget=SelectSpeedWidget(attrs={'readonly': None}) + ) + duplex = MultipleChoiceField( + choices=InterfaceDuplexChoices, + required=False ) enabled = forms.NullBooleanField( required=False, @@ -861,16 +999,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) - rf_role = forms.MultipleChoiceField( + rf_role = MultipleChoiceField( choices=WirelessRoleChoices, required=False, - widget=StaticSelectMultiple(), label='Wireless role' ) - rf_channel = forms.MultipleChoiceField( + rf_channel = MultipleChoiceField( choices=WirelessChannelChoices, required=False, - widget=StaticSelectMultiple(), label='Wireless channel' ) rf_channel_frequency = forms.IntegerField( @@ -887,20 +1023,24 @@ class InterfaceFilterForm(DeviceComponentFilterForm): min_value=0, max_value=127 ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tag = TagFilterField(model) class FrontPortFilterForm(DeviceComponentFilterForm): - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'color')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) model = FrontPort - type = forms.MultipleChoiceField( + type = MultipleChoiceField( choices=PortTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) color = ColorField( required=False @@ -910,15 +1050,14 @@ class FrontPortFilterForm(DeviceComponentFilterForm): class RearPortFilterForm(DeviceComponentFilterForm): model = RearPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'color')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + type = MultipleChoiceField( choices=PortTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) color = ColorField( required=False @@ -926,23 +1065,42 @@ class RearPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) +class ModuleBayFilterForm(DeviceComponentFilterForm): + model = ModuleBay + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'position')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + tag = TagFilterField(model) + position = forms.CharField( + required=False + ) + + class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay - field_groups = [ - ['q', 'tag'], - ['name', 'label'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) tag = TagFilterField(model) class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + role_id = DynamicModelMultipleChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False, + label=_('Role'), + fetch_trigger='open' + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -963,6 +1121,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) +# +# Device component roles +# + +class InventoryItemRoleFilterForm(NetBoxModelFilterSetForm): + model = InventoryItemRole + tag = TagFilterField(model) + + # # Connections # diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index bff880466..e070d18ee 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -7,14 +7,13 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelForm -from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup, ASN +from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, - SlugField, StaticSelect, + APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, + SlugField, StaticSelect, SelectSpeedWidget, ) from virtualization.models import Cluster, ClusterGroup from wireless.models import WirelessLAN, WirelessLANGroup @@ -38,8 +37,14 @@ __all__ = ( 'InterfaceForm', 'InterfaceTemplateForm', 'InventoryItemForm', + 'InventoryItemRoleForm', + 'InventoryItemTemplateForm', 'LocationForm', 'ManufacturerForm', + 'ModuleForm', + 'ModuleBayForm', + 'ModuleBayTemplateForm', + 'ModuleTypeForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -67,16 +72,12 @@ Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) """ -class RegionForm(CustomFieldModelForm): +class RegionForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Region @@ -85,16 +86,12 @@ class RegionForm(CustomFieldModelForm): ) -class SiteGroupForm(CustomFieldModelForm): +class SiteGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = SiteGroup @@ -103,7 +100,7 @@ class SiteGroupForm(CustomFieldModelForm): ) -class SiteForm(TenancyForm, CustomFieldModelForm): +class SiteForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -124,28 +121,20 @@ class SiteForm(TenancyForm, CustomFieldModelForm): widget=StaticSelect() ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Site', ( + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), + ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')), ) class Meta: model = Site - fields = [ - 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', - ] - fieldsets = ( - ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'asns', 'time_zone', 'description', - 'tags', - )), - ('Tenancy', ('tenant_group', 'tenant')), - ('Contact Info', ( - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', - )), + fields = ( + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', ) widgets = { 'physical_address': SmallTextarea( @@ -163,7 +152,6 @@ class SiteForm(TenancyForm, CustomFieldModelForm): } help_texts = { 'name': "Full name of the site", - 'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model", 'facility': "Data center provider and facility (e.g. Equinix NY7)", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", @@ -174,7 +162,7 @@ class SiteForm(TenancyForm, CustomFieldModelForm): } -class LocationForm(TenancyForm, CustomFieldModelForm): +class LocationForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -204,9 +192,12 @@ class LocationForm(TenancyForm, CustomFieldModelForm): } ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Location', ( + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: @@ -214,20 +205,10 @@ class LocationForm(TenancyForm, CustomFieldModelForm): fields = ( 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', ) - fieldsets = ( - ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', - )), - ('Tenancy', ('tenant_group', 'tenant')), - ) -class RackRoleForm(CustomFieldModelForm): +class RackRoleForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = RackRole @@ -236,7 +217,7 @@ class RackRoleForm(CustomFieldModelForm): ] -class RackForm(TenancyForm, CustomFieldModelForm): +class RackForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -270,10 +251,6 @@ class RackForm(TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Rack @@ -296,7 +273,7 @@ class RackForm(TenancyForm, CustomFieldModelForm): } -class RackReservationForm(TenancyForm, CustomFieldModelForm): +class RackReservationForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -343,9 +320,10 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): ), widget=StaticSelect() ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: @@ -354,18 +332,10 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', ] - fieldsets = ( - ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) -class ManufacturerForm(CustomFieldModelForm): +class ManufacturerForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Manufacturer @@ -374,7 +344,7 @@ class ManufacturerForm(CustomFieldModelForm): ] -class DeviceTypeForm(CustomFieldModelForm): +class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all() ) @@ -382,9 +352,15 @@ class DeviceTypeForm(CustomFieldModelForm): slug_source='model' ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Device Type', ( + 'manufacturer', 'model', 'slug', 'part_number', 'tags', + )), + ('Chassis', ( + 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + )), + ('Images', ('front_image', 'rear_image')), ) class Meta: @@ -393,15 +369,6 @@ class DeviceTypeForm(CustomFieldModelForm): 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', ] - fieldsets = ( - ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'tags', - )), - ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - )), - ('Images', ('front_image', 'rear_image')), - ) widgets = { 'subdevice_role': StaticSelect(), 'front_image': ClearableFileInput(attrs={ @@ -413,12 +380,27 @@ class DeviceTypeForm(CustomFieldModelForm): } -class DeviceRoleForm(CustomFieldModelForm): - slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class ModuleTypeForm(NetBoxModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all() ) + comments = CommentField() + + fieldsets = ( + ('Module Type', ( + 'manufacturer', 'model', 'part_number', 'tags', + )), + ) + + class Meta: + model = ModuleType + fields = [ + 'manufacturer', 'model', 'part_number', 'comments', 'tags', + ] + + +class DeviceRoleForm(NetBoxModelForm): + slug = SlugField() class Meta: model = DeviceRole @@ -427,7 +409,7 @@ class DeviceRoleForm(CustomFieldModelForm): ] -class PlatformForm(CustomFieldModelForm): +class PlatformForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -435,10 +417,6 @@ class PlatformForm(CustomFieldModelForm): slug = SlugField( max_length=64 ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Platform @@ -450,7 +428,7 @@ class PlatformForm(CustomFieldModelForm): } -class DeviceForm(TenancyForm, CustomFieldModelForm): +class DeviceForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -544,10 +522,6 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): required=False, label='' ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Device @@ -606,11 +580,6 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): # can be flipped from one face to another. self.fields['position'].widget.add_query_param('exclude', self.instance.pk) - # Limit platform by manufacturer - self.fields['platform'].queryset = Platform.objects.filter( - Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer) - ) - # Disable rack assignment if this is a child device installed in a parent device if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): self.fields['site'].disabled = True @@ -653,7 +622,74 @@ class CableForm(TenancyForm, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False + + +class ModuleForm(NetBoxModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + initial_params={ + 'modulebays': '$module_bay' + } ) + module_bay = DynamicModelChoiceField( + queryset=ModuleBay.objects.all(), + query_params={ + 'device_id': '$device' + } + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + initial_params={ + 'module_types': '$module_type' + } + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + comments = CommentField() + replicate_components = forms.BooleanField( + required=False, + initial=True, + help_text="Automatically populate components associated with this module type" + ) + + fieldsets = ( + ('Module', ( + 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', + )), + ('Hardware', ( + 'serial', 'asset_tag', 'replicate_components', + )), + ) + + class Meta: + model = Module + fields = [ + 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', + 'replicate_components', 'comments', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + self.fields['replicate_components'].initial = False + self.fields['replicate_components'].disabled = True + + def save(self, *args, **kwargs): + + # If replicate_components is False, disable automatic component replication on the instance + if self.instance.pk or not self.cleaned_data['replicate_components']: + self.instance._disable_replication = True + + return super().save(*args, **kwargs) + + +class CableForm(TenancyForm, NetBoxModelForm): class Meta: model = Cable @@ -672,7 +708,7 @@ class CableForm(TenancyForm, CustomFieldModelForm): } -class PowerPanelForm(CustomFieldModelForm): +class PowerPanelForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -701,9 +737,9 @@ class PowerPanelForm(CustomFieldModelForm): 'site_id': '$site' } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), ) class Meta: @@ -711,12 +747,9 @@ class PowerPanelForm(CustomFieldModelForm): fields = [ 'region', 'site_group', 'site', 'location', 'name', 'tags', ] - fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), - ) -class PowerFeedForm(CustomFieldModelForm): +class PowerFeedForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -756,9 +789,11 @@ class PowerFeedForm(CustomFieldModelForm): } ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) class Meta: @@ -767,11 +802,6 @@ class PowerFeedForm(CustomFieldModelForm): 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] - fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), - ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), - ) widgets = { 'status': StaticSelect(), 'type': StaticSelect(), @@ -784,15 +814,11 @@ class PowerFeedForm(CustomFieldModelForm): # Virtual chassis # -class VirtualChassisForm(CustomFieldModelForm): +class VirtualChassisForm(NetBoxModelForm): master = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = VirtualChassis @@ -908,10 +934,12 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect, } @@ -919,10 +947,12 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect, } @@ -930,78 +960,96 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), } class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): + power_port = DynamicModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type', + } + ) + class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), + 'feed_leg': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=self.instance.device_type - ) - class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): + rear_port = DynamicModelChoiceField( + queryset=RearPortTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type', + } + ) + class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', + 'description', ] widgets = { 'device_type': forms.HiddenInput(), - 'rear_port': StaticSelect(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit rear_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): - self.fields['rear_port'].queryset = RearPortTemplate.objects.filter( - device_type=self.instance.device_type - ) - class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } +class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'position', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate @@ -1013,104 +1061,171 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } +class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): + parent = DynamicModelChoiceField( + queryset=InventoryItemTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type' + } + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + required=False, + widget=forms.HiddenInput + ) + component_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + 'component_type', 'component_id', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + # # Device components # -class ConsolePortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class ConsolePortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = ConsolePort fields = [ - 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'speed': StaticSelect(), } -class ConsoleServerPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class ConsoleServerPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'speed': StaticSelect(), } -class PowerPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class PowerPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = PowerPort fields = [ - 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', + 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), } -class PowerOutletForm(CustomFieldModelForm): - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False +class PowerOutletForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + power_port = DynamicModelChoiceField( + queryset=PowerPort.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'feed_leg': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Limit power_port choices to the local device - if hasattr(self.instance, 'device'): - self.fields['power_port'].queryset = PowerPort.objects.filter( - device=self.instance.device - ) - - -class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): +class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface' + label='Parent interface', + query_params={ + 'device_id': '$device', + } ) bridge = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Bridged interface' + label='Bridged interface', + query_params={ + 'device_id': '$device', + } ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, label='LAG interface', query_params={ + 'device_id': '$device', 'type': 'lag', } ) @@ -1138,6 +1253,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Untagged VLAN', query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', } ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -1146,23 +1262,39 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Tagged VLANs', query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + + fieldsets = ( + ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), + ('Addressing', ('vrf', 'mac_address', 'wwn')), + ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + ('Wireless', ( + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', + )), ) class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', - 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', + 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', + 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', + 'vrf', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), + 'speed': SelectSpeedWidget(), + 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), @@ -1176,63 +1308,47 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'rf_channel_width': "Populated by selected channel (if set)", } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - - # Restrict parent/bridge/LAG interface assignment by device/VC - self.fields['parent'].widget.add_query_param('device_id', device.pk) - self.fields['bridge'].widget.add_query_param('device_id', device.pk) - self.fields['lag'].widget.add_query_param('device_id', device.pk) - if device.virtual_chassis and device.virtual_chassis.master: - self.fields['parent'].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) - self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - - # Limit VLAN choices by device - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) - - -class FrontPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class FrontPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) + rear_port = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + query_params={ + 'device_id': '$device', + } ) class Meta: model = FrontPort fields = [ - 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), - 'rear_port': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Limit RearPort choices to the local device - if hasattr(self.instance, 'device'): - self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter( - device=self.instance.device - ) - - -class RearPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class RearPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = RearPort fields = [ - 'device', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1240,11 +1356,19 @@ class RearPortForm(CustomFieldModelForm): } -class DeviceBayForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) +class ModuleBayForm(NetBoxModelForm): + + class Meta: + model = ModuleBay + fields = [ + 'device', 'name', 'label', 'position', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class DeviceBayForm(NetBoxModelForm): class Meta: model = DeviceBay @@ -1276,7 +1400,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class InventoryItemForm(CustomFieldModelForm): +class InventoryItemForm(NetBoxModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all() ) @@ -1287,18 +1411,47 @@ class InventoryItemForm(CustomFieldModelForm): 'device_id': '$device' } ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_MODELS, + required=False, + widget=forms.HiddenInput + ) + component_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput + ) + + fieldsets = ( + ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), + ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), ) class Meta: model = InventoryItem fields = [ - 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'tags', + 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'description', 'component_type', 'component_id', 'tags', + ] + + +# +# Device component roles +# + +class InventoryItemRoleForm(NetBoxModelForm): + slug = SlugField() + + class Meta: + model = InventoryItemRole + fields = [ + 'name', 'slug', 'color', 'description', 'tags', ] diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 92b92ef3e..e3e9c1179 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,41 +1,25 @@ from django import forms -from dcim.choices import * -from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelForm, CustomFieldsMixin -from extras.models import Tag -from ipam.models import VLAN +from netbox.forms import NetBoxModelForm from utilities.forms import ( - add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, StaticSelect, + BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, ) -from wireless.choices import * -from .common import InterfaceCommonForm __all__ = ( - 'ConsolePortCreateForm', - 'ConsolePortTemplateCreateForm', - 'ConsoleServerPortCreateForm', - 'ConsoleServerPortTemplateCreateForm', - 'DeviceBayCreateForm', - 'DeviceBayTemplateCreateForm', + 'ComponentTemplateCreateForm', + 'DeviceComponentCreateForm', 'FrontPortCreateForm', 'FrontPortTemplateCreateForm', - 'InterfaceCreateForm', - 'InterfaceTemplateCreateForm', 'InventoryItemCreateForm', - 'PowerOutletCreateForm', - 'PowerOutletTemplateCreateForm', - 'PowerPortCreateForm', - 'PowerPortTemplateCreateForm', - 'RearPortCreateForm', - 'RearPortTemplateCreateForm', + 'ModularComponentTemplateCreateForm', + 'ModuleBayCreateForm', + 'ModuleBayTemplateCreateForm', 'VirtualChassisCreateForm', ) -class ComponentForm(BootstrapMixin, forms.Form): +class ComponentCreateForm(BootstrapMixin, forms.Form): """ Subclass this form when facilitating the creation of one or more device component or component templates based on a name pattern. @@ -52,18 +36,175 @@ class ComponentForm(BootstrapMixin, forms.Form): def clean(self): super().clean() - # Validate that the number of components being created from both the name_pattern and label_pattern are equal - if self.cleaned_data['label_pattern']: - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if name_pattern_count != label_pattern_count: + # Validate that all patterned fields generate an equal number of values + patterned_fields = [ + field_name for field_name in self.fields if field_name.endswith('_pattern') + ] + pattern_count = len(self.cleaned_data['name_pattern']) + for field_name in patterned_fields: + value_count = len(self.cleaned_data[field_name]) + if self.cleaned_data[field_name] and value_count != pattern_count: raise forms.ValidationError({ - 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however ' - f'{label_pattern_count} labels will be generated. These counts must match.' + field_name: f'The provided pattern specifies {value_count} values, but {pattern_count} are ' + f'expected.' }, code='label_pattern_mismatch') -class VirtualChassisCreateForm(CustomFieldModelForm): +class ComponentTemplateCreateForm(ComponentCreateForm): + """ + Creation form for component templates that can be assigned only to a DeviceType. + """ + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + ) + field_order = ('device_type', 'name_pattern', 'label_pattern') + + +class ModularComponentTemplateCreateForm(ComponentCreateForm): + """ + Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType. + """ + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False + ) + field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern') + + +class DeviceComponentCreateForm(ComponentCreateForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + field_order = ('device', 'name_pattern', 'label_pattern') + + +class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'device_type', 'name_pattern', 'label_pattern', 'rear_port_set', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # TODO: This needs better validation + if 'device_type' in self.initial or self.data.get('device_type'): + parent = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') + ) + elif 'module_type' in self.initial or self.data.get('module_type'): + parent = ModuleType.objects.get( + pk=self.initial.get('module_type') or self.data.get('module_type') + ) + else: + return + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in parent.frontporttemplates.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = parent.rearporttemplates.all() + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class FrontPortCreateForm(DeviceComponentCreateForm): + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'rear_port_set', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available + # mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in device.frontports.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPort.objects.filter(device=device) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): + position_pattern = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + ) + field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern') + + +class ModuleBayCreateForm(DeviceComponentCreateForm): + position_pattern = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern') + + +class InventoryItemCreateForm(ComponentCreateForm): + # Device is assigned by the model form + field_order = ('name_pattern', 'label_pattern') + + +class VirtualChassisCreateForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -107,10 +248,6 @@ class VirtualChassisCreateForm(CustomFieldModelForm): required=False, help_text='Position of the first member device. Increases by one for each additional member.' ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = VirtualChassis @@ -136,521 +273,3 @@ class VirtualChassisCreateForm(CustomFieldModelForm): member.save() return instance - - -# -# Component templates -# - -class ComponentTemplateCreateForm(ComponentForm): - """ - Base form for the creation of device component templates (subclassed from ComponentTemplateModel). - """ - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - initial_params={ - 'device_types': 'device_type' - } - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - query_params={ - 'manufacturer_id': '$manufacturer' - } - ) - description = forms.CharField( - required=False - ) - - -class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') - - -class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') - - -class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum power draw (watts)" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated power draw (watts)" - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', - 'description', - ) - - -class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False - ) - power_port = forms.ModelChoiceField( - queryset=PowerPortTemplate.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False, - widget=StaticSelect() - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', - 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=device_type - ) - - -class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect() - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only' - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description') - - -class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect() - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device_type.frontporttemplates.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPortTemplate.objects.filter(device_type=device_type) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -class RearPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - initial=1, - help_text='The number of front ports which may be mapped to each rear port' - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description', - ) - - -class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') - - -# -# Device components -# - -class ComponentCreateForm(CustomFieldsMixin, ComponentForm): - """ - Base form for the creation of device components (models subclassed from ComponentModel). - """ - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - description = forms.CharField( - max_length=200, - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - -class ConsolePortCreateForm(ComponentCreateForm): - model = ConsolePort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class ConsoleServerPortCreateForm(ComponentCreateForm): - model = ConsoleServerPort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class PowerPortCreateForm(ComponentCreateForm): - model = PowerPort - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False, - widget=StaticSelect() - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum draw in watts" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated draw in watts" - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', - 'description', 'tags', - ) - - -class PowerOutletCreateForm(ComponentCreateForm): - model = PowerOutlet - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False, - widget=StaticSelect() - ) - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', - 'tags', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port queryset to PowerPorts which belong to the parent Device - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) - - -class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): - model = Interface - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect(), - ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - parent = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) - bridge = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) - lag = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - 'type': 'lag', - }, - label='LAG' - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - wwn = forms.CharField( - required=False, - label='WWN' - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only', - help_text='This interface is used only for out-of-band management' - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect() - ) - rf_role = forms.ChoiceField( - choices=add_blank_choice(WirelessRoleChoices), - required=False, - widget=StaticSelect(), - label='Wireless role' - ) - rf_channel = forms.ChoiceField( - choices=add_blank_choice(WirelessChannelChoices), - required=False, - widget=StaticSelect(), - label='Wireless channel' - ) - rf_channel_frequency = forms.DecimalField( - required=False, - label='Channel frequency (MHz)' - ) - rf_channel_width = forms.DecimalField( - required=False, - label='Channel width (MHz)' - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Untagged VLAN' - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Tagged VLANs' - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', - 'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit VLAN choices by device - device_id = self.initial.get('device') or self.data.get('device') - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) - - -class FrontPortCreateForm(ComponentCreateForm): - model = FrontPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description', - 'tags', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available - # mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device.frontports.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPort.objects.filter(device=device) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -class RearPortCreateForm(ComponentCreateForm): - model = RearPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - initial=1, - help_text='The number of front ports which may be mapped to each rear port' - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description', - 'tags', - ) - - -class DeviceBayCreateForm(ComponentCreateForm): - model = DeviceBay - field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') - - -class InventoryItemCreateForm(ComponentCreateForm): - model = InventoryItem - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - parent = DynamicModelChoiceField( - queryset=InventoryItem.objects.all(), - required=False, - query_params={ - 'device_id': '$device' - } - ) - part_id = forms.CharField( - max_length=50, - required=False, - label='Part ID' - ) - serial = forms.CharField( - max_length=50, - required=False, - ) - asset_tag = forms.CharField( - max_length=50, - required=False, - ) - field_order = ( - 'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'tags', - ) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 03f040a00..afbcd6543 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -11,6 +11,9 @@ __all__ = ( 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', + 'InventoryItemTemplateImportForm', + 'ModuleBayTemplateImportForm', + 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', @@ -31,31 +34,23 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): ] +class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleType + fields = ['manufacturer', 'model', 'part_number', 'comments'] + + # # Component template import forms # class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - - def __init__(self, device_type, data=None, *args, **kwargs): - - # Must pass the parent DeviceType on form initialization - data.update({ - 'device_type': device_type.pk, - }) - - super().__init__(data, *args, **kwargs) - - def clean_device_type(self): - - data = self.cleaned_data['device_type'] - - # Limit fields referencing other components to the parent DeviceType - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type': - field.queryset = field.queryset.filter(device_type=data) - - return data + pass class ConsolePortTemplateImportForm(ComponentTemplateImportForm): @@ -63,7 +58,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -72,7 +67,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -81,7 +76,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] @@ -95,9 +90,23 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(module_type=module_type) + + return module_type + class InterfaceTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( @@ -107,7 +116,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] @@ -120,10 +129,24 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): to_field_name='name' ) + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(module_type=module_type) + + return module_type + class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', ] @@ -135,7 +158,16 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description', + ] + + +class ModuleBayTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'position', 'description', ] @@ -146,3 +178,40 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): fields = [ 'device_type', 'name', 'label', 'description', ] + + +class InventoryItemTemplateImportForm(ComponentTemplateImportForm): + parent = forms.ModelChoiceField( + queryset=InventoryItemTemplate.objects.all(), + required=False + ) + role = forms.ModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + ] + + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(module_type=module_type) + + return module_type diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 13e0c20ec..1d5b6a580 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -50,12 +50,30 @@ class DCIMQuery(graphene.ObjectType): inventory_item = ObjectField(InventoryItemType) inventory_item_list = ObjectListField(InventoryItemType) + inventory_item_role = ObjectField(InventoryItemRoleType) + inventory_item_role_list = ObjectListField(InventoryItemRoleType) + + inventory_item_template = ObjectField(InventoryItemTemplateType) + inventory_item_template_list = ObjectListField(InventoryItemTemplateType) + location = ObjectField(LocationType) location_list = ObjectListField(LocationType) manufacturer = ObjectField(ManufacturerType) manufacturer_list = ObjectListField(ManufacturerType) + module = ObjectField(ModuleType) + module_list = ObjectListField(ModuleType) + + module_bay = ObjectField(ModuleBayType) + module_bay_list = ObjectListField(ModuleBayType) + + module_bay_template = ObjectField(ModuleBayTemplateType) + module_bay_template_list = ObjectListField(ModuleBayTemplateType) + + module_type = ObjectField(ModuleTypeType) + module_type_list = ObjectListField(ModuleTypeType) + platform = ObjectField(PlatformType) platform_list = ObjectListField(PlatformType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8ce10979e..d25a6bba6 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -6,7 +6,7 @@ from extras.graphql.mixins import ( ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt -from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( 'CableType', @@ -25,8 +25,14 @@ __all__ = ( 'InterfaceType', 'InterfaceTemplateType', 'InventoryItemType', + 'InventoryItemRoleType', + 'InventoryItemTemplateType', 'LocationType', 'ManufacturerType', + 'ModuleType', + 'ModuleBayType', + 'ModuleBayTemplateType', + 'ModuleTypeType', 'PlatformType', 'PowerFeedType', 'PowerOutletType', @@ -79,7 +85,7 @@ class ComponentTemplateObjectType( # Model types # -class CableType(PrimaryObjectType): +class CableType(NetBoxObjectType): class Meta: model = models.Cable @@ -137,7 +143,7 @@ class ConsoleServerPortTemplateType(ComponentTemplateObjectType): return self.type or None -class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, PrimaryObjectType): +class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, NetBoxObjectType): class Meta: model = models.Device @@ -167,6 +173,14 @@ class DeviceBayTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.InventoryItemTemplate + fields = '__all__' + filterset_class = filtersets.InventoryItemTemplateFilterSet + + class DeviceRoleType(OrganizationalObjectType): class Meta: @@ -175,7 +189,7 @@ class DeviceRoleType(OrganizationalObjectType): filterset_class = filtersets.DeviceRoleFilterSet -class DeviceTypeType(PrimaryObjectType): +class DeviceTypeType(NetBoxObjectType): class Meta: model = models.DeviceType @@ -238,6 +252,14 @@ class InventoryItemType(ComponentObjectType): filterset_class = filtersets.InventoryItemFilterSet +class InventoryItemRoleType(OrganizationalObjectType): + + class Meta: + model = models.InventoryItemRole + fields = '__all__' + filterset_class = filtersets.InventoryItemRoleFilterSet + + class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType): class Meta: @@ -254,6 +276,38 @@ class ManufacturerType(OrganizationalObjectType): filterset_class = filtersets.ManufacturerFilterSet +class ModuleType(ComponentObjectType): + + class Meta: + model = models.Module + fields = '__all__' + filterset_class = filtersets.ModuleFilterSet + + +class ModuleBayType(ComponentObjectType): + + class Meta: + model = models.ModuleBay + fields = '__all__' + filterset_class = filtersets.ModuleBayFilterSet + + +class ModuleBayTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.ModuleBayTemplate + fields = '__all__' + filterset_class = filtersets.ModuleBayTemplateFilterSet + + +class ModuleTypeType(NetBoxObjectType): + + class Meta: + model = models.ModuleType + fields = '__all__' + filterset_class = filtersets.ModuleTypeFilterSet + + class PlatformType(OrganizationalObjectType): class Meta: @@ -262,7 +316,7 @@ class PlatformType(OrganizationalObjectType): filterset_class = filtersets.PlatformFilterSet -class PowerFeedType(PrimaryObjectType): +class PowerFeedType(NetBoxObjectType): class Meta: model = models.PowerFeed @@ -298,7 +352,7 @@ class PowerOutletTemplateType(ComponentTemplateObjectType): return self.type or None -class PowerPanelType(PrimaryObjectType): +class PowerPanelType(NetBoxObjectType): class Meta: model = models.PowerPanel @@ -328,7 +382,7 @@ class PowerPortTemplateType(ComponentTemplateObjectType): return self.type or None -class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): +class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): class Meta: model = models.Rack @@ -342,7 +396,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): return self.outer_unit or None -class RackReservationType(PrimaryObjectType): +class RackReservationType(NetBoxObjectType): class Meta: model = models.RackReservation @@ -382,7 +436,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): filterset_class = filtersets.RegionFilterSet -class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): +class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): asn = graphene.Field(BigInt) class Meta: @@ -399,7 +453,7 @@ class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType): filterset_class = filtersets.SiteGroupFilterSet -class VirtualChassisType(PrimaryObjectType): +class VirtualChassisType(NetBoxObjectType): class Meta: model = models.VirtualChassis diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py index a1b6db30a..c7325210e 100644 --- a/netbox/dcim/migrations/0002_squashed.py +++ b/netbox/dcim/migrations/0002_squashed.py @@ -58,7 +58,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rearporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='rearport', @@ -73,7 +73,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rearport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='rearport', @@ -128,7 +128,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='powerporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='powerport', @@ -148,7 +148,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='powerport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='powerport', @@ -173,7 +173,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='poweroutlettemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='poweroutlettemplate', @@ -198,7 +198,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='poweroutlet', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='poweroutlet', @@ -258,7 +258,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='inventoryitem', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitems', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='inventoryitem', @@ -278,7 +278,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interfacetemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='interface', @@ -298,7 +298,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interface', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='interface', diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py index 48ea238d9..592aaf9a8 100644 --- a/netbox/dcim/migrations/0003_squashed_0130.py +++ b/netbox/dcim/migrations/0003_squashed_0130.py @@ -165,7 +165,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='frontporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='frontporttemplate', @@ -185,7 +185,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='frontport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='frontport', @@ -210,12 +210,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='devicebaytemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebaytemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='devicebay', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebays', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='devicebay', @@ -290,7 +290,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleserverporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='consoleserverport', @@ -310,7 +310,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleserverport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='consoleserverport', @@ -320,7 +320,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='consoleport', @@ -340,7 +340,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='consoleport', diff --git a/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py b/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py new file mode 100644 index 000000000..86918447d --- /dev/null +++ b/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py @@ -0,0 +1,59 @@ +import os + +from django.db import migrations +from django.db.utils import DataError + + +def check_legacy_data(apps, schema_editor): + """ + Abort the migration if any legacy site fields still contain data. + """ + Site = apps.get_model('dcim', 'Site') + + site_count = Site.objects.exclude(asn__isnull=True).count() + if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting asn field from Site model: Found {site_count} sites with " + f"legacy ASN data. Please ensure all legacy site ASN data has been migrated to ASN objects " + f"before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment variable to bypass " + f"this safeguard and delete all legacy site ASN data." + ) + + site_count = Site.objects.exclude(contact_name='', contact_phone='', contact_email='').count() + if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting contact fields from Site model: Found {site_count} sites " + f"with legacy contact data. Please ensure all legacy site contact data has been migrated to " + f"contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment " + f"variable to bypass this safeguard and delete all legacy site contact data." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0144_fix_cable_abs_length'), + ] + + operations = [ + migrations.RunPython( + code=check_legacy_data, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='site', + name='asn', + ), + migrations.RemoveField( + model_name='site', + name='contact_email', + ), + migrations.RemoveField( + model_name='site', + name='contact_name', + ), + migrations.RemoveField( + model_name='site', + name='contact_phone', + ), + ] diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py new file mode 100644 index 000000000..11324fc58 --- /dev/null +++ b/netbox/dcim/migrations/0146_modules.py @@ -0,0 +1,279 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0066_customfield_name_validation'), + ('dcim', '0145_site_remove_deprecated_fields'), + ] + + operations = [ + # Rename any indexes left over from the old Module model (now InventoryItem) (#8656) + migrations.RunSQL( + """ + DO $$ + DECLARE + idx record; + BEGIN + FOR idx IN + SELECT indexname AS old_name, + replace(indexname, 'module', 'inventoryitem') AS new_name + FROM pg_indexes + WHERE schemaname = 'public' AND + tablename = 'dcim_inventoryitem' AND + indexname LIKE 'dcim_module_%' + LOOP + EXECUTE format( + 'ALTER INDEX %I RENAME TO %I;', + idx.old_name, + idx.new_name + ); + END LOOP; + END$$; + """ + ), + + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.CreateModel( + name='ModuleType', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('model', models.CharField(max_length=100)), + ('part_number', models.CharField(blank=True, max_length=50)), + ('comments', models.TextField(blank=True)), + ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='module_types', to='dcim.manufacturer')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('manufacturer', 'model'), + 'unique_together': {('manufacturer', 'model')}, + }, + ), + migrations.CreateModel( + name='ModuleBay', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), + ('description', models.CharField(blank=True, max_length=200)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('device', '_name'), + 'unique_together': {('device', 'name')}, + }, + ), + migrations.CreateModel( + name='Module', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('local_context_data', models.JSONField(blank=True, null=True)), + ('serial', models.CharField(blank=True, max_length=50)), + ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')), + ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')), + ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('module_bay',), + }, + ), + migrations.AddField( + model_name='consoleport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='consoleserverport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='frontport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='frontporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='interface', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='interfacetemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='poweroutlet', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='powerport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='powerporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='rearport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='rearporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together={('device_type', 'name'), ('rear_port', 'rear_port_position'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.CreateModel( + name='ModuleBayTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), + ('description', models.CharField(blank=True, max_length=200)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), + ], + options={ + 'ordering': ('device_type', '_name'), + 'unique_together': {('device_type', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py new file mode 100644 index 000000000..f5e1f23f5 --- /dev/null +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -0,0 +1,38 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0068_configcontext_cluster_types'), + ('dcim', '0146_modules'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItemRole', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('color', utilities.fields.ColorField(default='9e9e9e', max_length=6)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='inventoryitem', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.inventoryitemrole'), + ), + ] diff --git a/netbox/dcim/migrations/0148_inventoryitem_component.py b/netbox/dcim/migrations/0148_inventoryitem_component.py new file mode 100644 index 000000000..a18f41d3d --- /dev/null +++ b/netbox/dcim/migrations/0148_inventoryitem_component.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0147_inventoryitemrole'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryitem', + name='component_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='component_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/dcim/migrations/0149_inventoryitem_templates.py b/netbox/dcim/migrations/0149_inventoryitem_templates.py new file mode 100644 index 000000000..f0b1f3cff --- /dev/null +++ b/netbox/dcim/migrations/0149_inventoryitem_templates.py @@ -0,0 +1,43 @@ +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0148_inventoryitem_component'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItemTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('component_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('part_id', models.CharField(blank=True, max_length=50)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), + ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')), + ], + options={ + 'ordering': ('device_type__id', 'parent__id', '_name'), + 'unique_together': {('device_type', 'parent', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/migrations/0150_interface_vrf.py b/netbox/dcim/migrations/0150_interface_vrf.py new file mode 100644 index 000000000..f8741e4a0 --- /dev/null +++ b/netbox/dcim/migrations/0150_interface_vrf.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.11 on 2022-01-07 18:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0054_vlangroup_min_max_vids'), + ('dcim', '0149_inventoryitem_templates'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces', to='ipam.vrf'), + ), + ] diff --git a/netbox/dcim/migrations/0151_interface_speed_duplex.py b/netbox/dcim/migrations/0151_interface_speed_duplex.py new file mode 100644 index 000000000..7e800f42a --- /dev/null +++ b/netbox/dcim/migrations/0151_interface_speed_duplex.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.10 on 2022-01-08 18:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0150_interface_vrf'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='duplex', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='interface', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0152_standardize_id_fields.py b/netbox/dcim/migrations/0152_standardize_id_fields.py new file mode 100644 index 000000000..6bf5b43f4 --- /dev/null +++ b/netbox/dcim/migrations/0152_standardize_id_fields.py @@ -0,0 +1,274 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0151_interface_speed_duplex'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemtemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='location', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='module', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='moduletype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sitegroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='cable', + name='termination_a_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cable', + name='termination_b_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cablepath', + name='destination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cablepath', + name='origin_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='consoleport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0153_created_datetimefield.py b/netbox/dcim/migrations/0153_created_datetimefield.py new file mode 100644 index 000000000..c1cc4132e --- /dev/null +++ b/netbox/dcim/migrations/0153_created_datetimefield.py @@ -0,0 +1,208 @@ +# Generated by Django 4.0.2 on 2022-02-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0152_standardize_id_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='cable', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='device', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicebay', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicerole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicetype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='inventoryitem', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='inventoryitemrole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='inventoryitemtemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='location', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='manufacturer', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='module', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='modulebay', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='moduletype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='platform', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerpanel', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rack', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rackreservation', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rackrole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='region', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='site', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='sitegroup', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='virtualchassis', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 333972b21..dcc564717 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,8 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from extras.utils import extras_features -from netbox.models import BigIDModel, PrimaryModel +from netbox.models import NetBoxModel from utilities.fields import ColorField from utilities.utils import to_meters from .devices import Device @@ -29,8 +28,7 @@ __all__ = ( # Cables # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Cable(PrimaryModel): +class Cable(NetBoxModel): """ A physical connection between two endpoints. """ @@ -40,7 +38,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_a_id = models.PositiveIntegerField() + termination_a_id = models.PositiveBigIntegerField() termination_a = GenericForeignKey( ct_field='termination_a_type', fk_field='termination_a_id' @@ -51,7 +49,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_b_id = models.PositiveIntegerField() + termination_b_id = models.PositiveBigIntegerField() termination_b = GenericForeignKey( ct_field='termination_b_type', fk_field='termination_b_id' @@ -288,8 +286,8 @@ class Cable(PrimaryModel): # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) self._pk = self.pk - def get_status_class(self): - return LinkStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return LinkStatusChoices.colors.get(self.status) def get_compatible_types(self): """ @@ -300,7 +298,7 @@ class Cable(PrimaryModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(BigIDModel): +class CablePath(models.Model): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do @@ -329,7 +327,7 @@ class CablePath(BigIDModel): on_delete=models.CASCADE, related_name='+' ) - origin_id = models.PositiveIntegerField() + origin_id = models.PositiveBigIntegerField() origin = GenericForeignKey( ct_field='origin_type', fk_field='origin_id' @@ -341,7 +339,7 @@ class CablePath(BigIDModel): blank=True, null=True ) - destination_id = models.PositiveIntegerField( + destination_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 42e453669..647abe148 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,15 +1,20 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.fields import ColorField, NaturalOrderingField +from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from .device_components import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, + RearPort, ) @@ -19,13 +24,15 @@ __all__ = ( 'DeviceBayTemplate', 'FrontPortTemplate', 'InterfaceTemplate', + 'InventoryItemTemplate', + 'ModuleBayTemplate', 'PowerOutletTemplate', 'PowerPortTemplate', 'RearPortTemplate', ) -class ComponentTemplateModel(ChangeLoggedModel): +class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -64,17 +71,66 @@ class ComponentTemplateModel(ChangeLoggedModel): raise NotImplementedError() def to_objectchange(self, action): - # Annotate the parent DeviceType - try: - device_type = self.device_type - except ObjectDoesNotExist: - # The parent DeviceType has already been deleted - device_type = None - return super().to_objectchange(action, related_object=device_type) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device_type + return objectchange -@extras_features('webhooks') -class ConsolePortTemplate(ComponentTemplateModel): +class ModularComponentTemplateModel(ComponentTemplateModel): + """ + A ComponentTemplateModel which supports optional assignment to a ModuleType. + """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + if self.device_type is not None: + objectchange.related_object = self.device_type + elif self.module_type is not None: + objectchange.related_object = self.module_type + return objectchange + + def clean(self): + super().clean() + + # A component template must belong to a DeviceType *or* to a ModuleType + if self.device_type and self.module_type: + raise ValidationError( + "A component template cannot be associated with both a device type and a module type." + ) + if not self.device_type and not self.module_type: + raise ValidationError( + "A component template must be associated with either a device type or a module type." + ) + + def resolve_name(self, module): + if module: + return self.name.replace('{module}', 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 + + +class ConsolePortTemplate(ModularComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. """ @@ -84,21 +140,25 @@ class ConsolePortTemplate(ComponentTemplateModel): blank=True ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = ConsolePort - def instantiate(self, device): - return ConsolePort( - device=device, - name=self.name, - label=self.label, - type=self.type + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.resolve_label(kwargs.get('module')), + type=self.type, + **kwargs ) -@extras_features('webhooks') -class ConsoleServerPortTemplate(ComponentTemplateModel): +class ConsoleServerPortTemplate(ModularComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. """ @@ -108,21 +168,25 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): blank=True ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = ConsoleServerPort - def instantiate(self, device): - return ConsoleServerPort( - device=device, - name=self.name, - label=self.label, - type=self.type + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.resolve_label(kwargs.get('module')), + type=self.type, + **kwargs ) -@extras_features('webhooks') -class PowerPortTemplate(ComponentTemplateModel): +class PowerPortTemplate(ModularComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. """ @@ -144,18 +208,23 @@ class PowerPortTemplate(ComponentTemplateModel): help_text="Allocated power draw (watts)" ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = PowerPort - def instantiate(self, device): - return PowerPort( - device=device, - name=self.name, - label=self.label, + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.resolve_label(kwargs.get('module')), type=self.type, maximum_draw=self.maximum_draw, - allocated_draw=self.allocated_draw + allocated_draw=self.allocated_draw, + **kwargs ) def clean(self): @@ -168,8 +237,7 @@ class PowerPortTemplate(ComponentTemplateModel): }) -@extras_features('webhooks') -class PowerOutletTemplate(ComponentTemplateModel): +class PowerOutletTemplate(ModularComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. """ @@ -192,36 +260,46 @@ class PowerOutletTemplate(ComponentTemplateModel): help_text="Phase (for three-phase feeds)" ) + component_model = PowerOutlet + class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def clean(self): super().clean() # Validate power port assignment - if self.power_port and self.power_port.device_type != self.device_type: - raise ValidationError( - "Parent power port ({}) must belong to the same device type".format(self.power_port) - ) - - def instantiate(self, device): if self.power_port: - power_port = PowerPort.objects.get(device=device, name=self.power_port.name) + if self.device_type and self.power_port.device_type != self.device_type: + raise ValidationError( + f"Parent power port ({self.power_port}) must belong to the same device type" + ) + if self.module_type and self.power_port.module_type != self.module_type: + raise ValidationError( + f"Parent power port ({self.power_port}) must belong to the same module type" + ) + + def instantiate(self, **kwargs): + if self.power_port: + power_port_name = self.power_port.resolve_name(kwargs.get('module')) + power_port = PowerPort.objects.get(name=power_port_name, **kwargs) else: power_port = None - return PowerOutlet( - device=device, - name=self.name, - label=self.label, + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.resolve_label(kwargs.get('module')), type=self.type, power_port=power_port, - feed_leg=self.feed_leg + feed_leg=self.feed_leg, + **kwargs ) -@extras_features('webhooks') -class InterfaceTemplate(ComponentTemplateModel): +class InterfaceTemplate(ModularComponentTemplateModel): """ A template for a physical data interface on a new Device. """ @@ -241,22 +319,26 @@ class InterfaceTemplate(ComponentTemplateModel): verbose_name='Management only' ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = Interface - def instantiate(self, device): - return Interface( - device=device, - name=self.name, - label=self.label, + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.resolve_label(kwargs.get('module')), type=self.type, - mgmt_only=self.mgmt_only + mgmt_only=self.mgmt_only, + **kwargs ) -@extras_features('webhooks') -class FrontPortTemplate(ComponentTemplateModel): +class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. """ @@ -280,10 +362,13 @@ class FrontPortTemplate(ComponentTemplateModel): ] ) + component_model = FrontPort + class Meta: - ordering = ('device_type', '_name') + ordering = ('device_type', 'module_type', '_name') unique_together = ( ('device_type', 'name'), + ('module_type', 'name'), ('rear_port', 'rear_port_position'), ) @@ -309,24 +394,24 @@ class FrontPortTemplate(ComponentTemplateModel): except RearPortTemplate.DoesNotExist: pass - def instantiate(self, device): + def instantiate(self, **kwargs): if self.rear_port: - rear_port = RearPort.objects.get(device=device, name=self.rear_port.name) + rear_port_name = self.rear_port.resolve_name(kwargs.get('module')) + rear_port = RearPort.objects.get(name=rear_port_name, **kwargs) else: rear_port = None - return FrontPort( - device=device, - name=self.name, - label=self.label, + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.resolve_label(kwargs.get('module')), type=self.type, color=self.color, rear_port=rear_port, - rear_port_position=self.rear_port_position + rear_port_position=self.rear_port_position, + **kwargs ) -@extras_features('webhooks') -class RearPortTemplate(ComponentTemplateModel): +class RearPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. """ @@ -345,32 +430,63 @@ class RearPortTemplate(ComponentTemplateModel): ] ) + component_model = RearPort + + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.resolve_label(kwargs.get('module')), + type=self.type, + color=self.color, + positions=self.positions, + **kwargs + ) + + +class ModuleBayTemplate(ComponentTemplateModel): + """ + A template for a ModuleBay to be created for a new parent Device. + """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + + component_model = ModuleBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return RearPort( + return self.component_model( device=device, name=self.name, label=self.label, - type=self.type, - color=self.color, - positions=self.positions + position=self.position ) -@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. """ + component_model = DeviceBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return DeviceBay( + return self.component_model( device=device, name=self.name, label=self.label @@ -381,3 +497,78 @@ class DeviceBayTemplate(ComponentTemplateModel): raise ValidationError( f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays." ) + + +class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): + """ + A template for an InventoryItem to be created for a new parent Device. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='child_items', + blank=True, + null=True, + db_index=True + ) + component_type = models.ForeignKey( + to=ContentType, + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + component_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + component = GenericForeignKey( + ct_field='component_type', + fk_field='component_id' + ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + part_id = models.CharField( + max_length=50, + verbose_name='Part ID', + blank=True, + help_text='Manufacturer-assigned part identifier' + ) + + objects = TreeManager() + component_model = InventoryItem + + class Meta: + ordering = ('device_type__id', 'parent__id', '_name') + unique_together = ('device_type', 'parent', 'name') + + def instantiate(self, **kwargs): + parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None + if self.component: + model = self.component.component_model + component = model.objects.get(name=self.component.name, **kwargs) + else: + component = None + return self.component_model( + parent=parent, + name=self.name, + label=self.label, + component=component, + role=self.role, + manufacturer=self.manufacturer, + part_id=self.part_id, + **kwargs + ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e105bd804..a3b182da1 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,8 +11,8 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG -from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel +from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -30,6 +30,8 @@ __all__ = ( 'FrontPort', 'Interface', 'InventoryItem', + 'InventoryItemRole', + 'ModuleBay', 'PathEndpoint', 'PowerOutlet', 'PowerPort', @@ -37,7 +39,7 @@ __all__ = ( ) -class ComponentModel(PrimaryModel): +class ComponentModel(NetBoxModel): """ An abstract model inherited by any model which has a parent Device. """ @@ -73,19 +75,34 @@ class ComponentModel(PrimaryModel): return self.name def to_objectchange(self, action): - # Annotate the parent Device - try: - device = self.device - except ObjectDoesNotExist: - # The parent Device has already been deleted - device = None - return super().to_objectchange(action, related_object=device) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device + return objectchange @property def parent_object(self): return self.device +class ModularComponentModel(ComponentModel): + module = models.ForeignKey( + to='dcim.Module', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + inventory_items = GenericRelation( + to='dcim.InventoryItem', + content_type_field='component_type', + object_id_field='component_id', + related_name='%(class)ss', + ) + + class Meta: + abstract = True + + class LinkTermination(models.Model): """ An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples @@ -109,7 +126,7 @@ class LinkTermination(models.Model): blank=True, null=True ) - _link_peer_id = models.PositiveIntegerField( + _link_peer_id = models.PositiveBigIntegerField( blank=True, null=True ) @@ -229,11 +246,10 @@ class PathEndpoint(models.Model): # -# Console ports +# Console components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): +class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -260,12 +276,7 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -# -# Console server ports -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): +class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -293,11 +304,10 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): # -# Power ports +# Power components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPort(ComponentModel, LinkTermination, PathEndpoint): +class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -389,12 +399,7 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint): } -# -# Power outlets -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): +class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -508,8 +513,7 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): +class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -537,6 +541,16 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): verbose_name='Management only', help_text='This interface is used only for out-of-band management' ) + speed = models.PositiveIntegerField( + blank=True, + null=True + ) + duplex = models.CharField( + max_length=50, + blank=True, + null=True, + choices=InterfaceDuplexChoices + ) wwn = WWNField( null=True, blank=True, @@ -602,6 +616,14 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): blank=True, verbose_name='Tagged VLANs' ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.SET_NULL, + related_name='interfaces', + null=True, + blank=True, + verbose_name='VRF' + ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', @@ -762,6 +784,10 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): def is_lag(self): return self.type == InterfaceTypeChoices.TYPE_LAG + @property + def is_bridge(self): + return self.type == InterfaceTypeChoices.TYPE_BRIDGE + @property def link(self): return self.cable or self.wireless_link @@ -771,8 +797,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): # Pass-through ports # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FrontPort(ComponentModel, LinkTermination): +class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. """ @@ -825,8 +850,7 @@ class FrontPort(ComponentModel, LinkTermination): }) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RearPort(ComponentModel, LinkTermination): +class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. """ @@ -866,10 +890,29 @@ class RearPort(ComponentModel, LinkTermination): # -# Device bays +# Bays # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ModuleBay(ComponentModel): + """ + An empty space within a Device which can house a child device + """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + + clone_fields = ['device'] + + class Meta: + ordering = ('device', '_name') + unique_together = ('device', 'name') + + def get_absolute_url(self): + return reverse('dcim:modulebay', kwargs={'pk': self.pk}) + + class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -919,7 +962,37 @@ class DeviceBay(ComponentModel): # Inventory items # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') + +class InventoryItemRole(OrganizationalModel): + """ + Inventory items may optionally be assigned a functional role. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + color = ColorField( + default=ColorChoices.COLOR_GREY + ) + description = models.CharField( + max_length=200, + blank=True, + ) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:inventoryitemrole', args=[self.pk]) + + class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. @@ -933,6 +1006,29 @@ class InventoryItem(MPTTModel, ComponentModel): null=True, db_index=True ) + component_type = models.ForeignKey( + to=ContentType, + limit_choices_to=MODULAR_COMPONENT_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + component_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + component = GenericForeignKey( + ct_field='component_type', + fk_field='component_id' + ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_items', + blank=True, + null=True + ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -966,7 +1062,7 @@ class InventoryItem(MPTTModel, ComponentModel): objects = TreeManager() - clone_fields = ['device', 'parent', 'manufacturer', 'part_id'] + clone_fields = ['device', 'parent', 'role', 'manufacturer', 'part_id'] class Meta: ordering = ('device__id', 'parent__id', '_name') @@ -974,3 +1070,12 @@ class InventoryItem(MPTTModel, ComponentModel): def get_absolute_url(self): return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) + + def clean(self): + super().clean() + + # An InventoryItem cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({ + "parent": "Cannot assign self as parent." + }) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6b8ff043d..6ed7b349f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -13,9 +13,8 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * @@ -26,6 +25,8 @@ __all__ = ( 'DeviceRole', 'DeviceType', 'Manufacturer', + 'Module', + 'ModuleType', 'Platform', 'VirtualChassis', ) @@ -35,7 +36,6 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -68,8 +68,7 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class DeviceType(PrimaryModel): +class DeviceType(NetBoxModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -253,6 +252,16 @@ class DeviceType(PrimaryModel): } for c in self.rearporttemplates.all() ] + if self.modulebaytemplates.exists(): + data['module-bays'] = [ + { + 'name': c.name, + 'label': c.label, + 'position': c.position, + 'description': c.description, + } + for c in self.modulebaytemplates.all() + ] if self.devicebaytemplates.exists(): data['device-bays'] = [ { @@ -342,11 +351,144 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD +class ModuleType(NetBoxModel): + """ + A ModuleType represents a hardware element that can be installed within a device and which houses additional + components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a + DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It + cannot, however house device bays or module bays. + """ + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='module_types' + ) + model = models.CharField( + max_length=100 + ) + part_number = models.CharField( + max_length=50, + blank=True, + help_text='Discrete part number (optional)' + ) + comments = models.TextField( + blank=True + ) + + # Generic relations + images = GenericRelation( + to='extras.ImageAttachment' + ) + + clone_fields = ('manufacturer',) + + class Meta: + ordering = ('manufacturer', 'model') + unique_together = ( + ('manufacturer', 'model'), + ) + + def __str__(self): + return self.model + + def get_absolute_url(self): + return reverse('dcim:moduletype', args=[self.pk]) + + def to_yaml(self): + data = OrderedDict(( + ('manufacturer', self.manufacturer.name), + ('model', self.model), + ('part_number', self.part_number), + ('comments', self.comments), + )) + + # Component templates + if self.consoleporttemplates.exists(): + data['console-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'label': c.label, + 'description': c.description, + } + for c in self.consoleporttemplates.all() + ] + if self.consoleserverporttemplates.exists(): + data['console-server-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'label': c.label, + 'description': c.description, + } + for c in self.consoleserverporttemplates.all() + ] + if self.powerporttemplates.exists(): + data['power-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'maximum_draw': c.maximum_draw, + 'allocated_draw': c.allocated_draw, + 'label': c.label, + 'description': c.description, + } + for c in self.powerporttemplates.all() + ] + if self.poweroutlettemplates.exists(): + data['power-outlets'] = [ + { + 'name': c.name, + 'type': c.type, + 'power_port': c.power_port.name if c.power_port else None, + 'feed_leg': c.feed_leg, + 'label': c.label, + 'description': c.description, + } + for c in self.poweroutlettemplates.all() + ] + if self.interfacetemplates.exists(): + data['interfaces'] = [ + { + 'name': c.name, + 'type': c.type, + 'mgmt_only': c.mgmt_only, + 'label': c.label, + 'description': c.description, + } + for c in self.interfacetemplates.all() + ] + if self.frontporttemplates.exists(): + data['front-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'rear_port': c.rear_port.name, + 'rear_port_position': c.rear_port_position, + 'label': c.label, + 'description': c.description, + } + for c in self.frontporttemplates.all() + ] + if self.rearporttemplates.exists(): + data['rear-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'positions': c.positions, + 'label': c.label, + 'description': c.description, + } + for c in self.rearporttemplates.all() + ] + + return yaml.dump(dict(data), sort_keys=False) + + # # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -384,7 +526,6 @@ class DeviceRole(OrganizationalModel): return reverse('dcim:devicerole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". @@ -434,8 +575,7 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Device(PrimaryModel, ConfigContextModel): +class Device(NetBoxModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -739,8 +879,8 @@ class Device(PrimaryModel, ConfigContextModel): if hasattr(self, 'device_type') and self.platform: if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer: raise ValidationError({ - 'platform': "The assigned platform is limited to {} device types, but this device's type belongs " - "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer) + 'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but " + f"this device's type belongs to {self.device_type.manufacturer}." }) # A Device can only be assigned to a Cluster in the same Site (or no Site) @@ -767,29 +907,35 @@ class Device(PrimaryModel, ConfigContextModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()] ) ConsoleServerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()] ) PowerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.powerporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()] ) PowerOutlet.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()] + [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()] ) Interface.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.interfacetemplates.all()] + [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()] ) RearPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.rearporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()] ) FrontPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.frontporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()] + ) + ModuleBay.objects.bulk_create( + [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()] ) DeviceBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] ) + # Avoid bulk_create to handle MPTT + for x in self.device_type.inventoryitemtemplates.all(): + x.instantiate(device=self).save() # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) @@ -862,16 +1008,94 @@ class Device(PrimaryModel, ConfigContextModel): """ return Device.objects.filter(parent_bay__device=self.pk) - def get_status_class(self): - return DeviceStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return DeviceStatusChoices.colors.get(self.status) + + +class Module(NetBoxModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', + help_text='A unique tag used to identify this device' + ) + comments = models.TextField( + blank=True + ) + + clone_fields = ('device', 'module_type') + + class Meta: + ordering = ('module_bay',) + + def __str__(self): + return f'{self.module_bay.name}: {self.module_type} ({self.pk})' + + def get_absolute_url(self): + return reverse('dcim:module', args=[self.pk]) + + def save(self, *args, **kwargs): + is_new = self.pk is None + + 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()] + ) # # Virtual chassis # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class VirtualChassis(PrimaryModel): +class VirtualChassis(NetBoxModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index b5d8d4c83..08f89e3b0 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,8 +6,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -21,8 +20,7 @@ __all__ = ( # Power # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPanel(PrimaryModel): +class PowerPanel(NetBoxModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -68,8 +66,7 @@ class PowerPanel(PrimaryModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): +class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. """ @@ -173,8 +170,8 @@ class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): def parent_object(self): return self.power_panel - def get_type_class(self): - return PowerFeedTypeChoices.CSS_CLASSES.get(self.type) + def get_type_color(self): + return PowerFeedTypeChoices.colors.get(self.type) - def get_status_class(self): - return PowerFeedStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return PowerFeedStatusChoices.colors.get(self.status) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 082ecfe57..81d699b11 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -13,9 +13,8 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from extras.utils import extras_features from netbox.config import get_config -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string @@ -34,7 +33,6 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. @@ -65,8 +63,7 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Rack(PrimaryModel): +class Rack(NetBoxModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -250,8 +247,8 @@ class Rack(PrimaryModel): else: return reversed(range(1, self.u_height + 1)) - def get_status_class(self): - return RackStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return RackStatusChoices.colors.get(self.status) def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): """ @@ -412,7 +409,7 @@ class Rack(PrimaryModel): available_units.remove(u) occupied_unit_count = self.u_height - len(available_units) - percentage = int(float(occupied_unit_count) / self.u_height * 100) + percentage = float(occupied_unit_count) / self.u_height * 100 return percentage @@ -438,8 +435,7 @@ class Rack(PrimaryModel): return int(allocated_draw_total / available_power_total * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RackReservation(PrimaryModel): +class RackReservation(NetBoxModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a19ae8050..d02bd0932 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,9 +7,7 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from dcim.fields import ASNField -from extras.utils import extras_features -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel from utilities.fields import NaturalOrderingField __all__ = ( @@ -24,7 +22,6 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ A region represents a geographic collection of sites. For example, you might create regions representing countries, @@ -111,7 +108,6 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and @@ -198,8 +194,7 @@ class SiteGroup(NestedGroupModel): # Sites # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Site(PrimaryModel): +class Site(NetBoxModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -248,12 +243,6 @@ class Site(PrimaryModel): blank=True, help_text='Local facility ID or description' ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) asns = models.ManyToManyField( to='ipam.ASN', related_name='sites', @@ -288,18 +277,6 @@ class Site(PrimaryModel): null=True, help_text='GPS coordinate (longitude)' ) - contact_name = models.CharField( - max_length=50, - blank=True - ) - contact_phone = models.CharField( - max_length=20, - blank=True - ) - contact_email = models.EmailField( - blank=True, - verbose_name='Contact E-mail' - ) comments = models.TextField( blank=True ) @@ -319,8 +296,8 @@ class Site(PrimaryModel): ) clone_fields = [ - 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', + 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', + 'shipping_address', 'latitude', 'longitude', ] class Meta: @@ -332,15 +309,14 @@ class Site(PrimaryModel): def get_absolute_url(self): return reverse('dcim:site', args=[self.pk]) - def get_status_class(self): - return SiteStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return SiteStatusChoices.colors.get(self.status) # # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Location(NestedGroupModel): """ A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a @@ -391,7 +367,7 @@ class Location(NestedGroupModel): to='extras.ImageAttachment' ) - clone_fields = ['site', 'parent', 'description'] + clone_fields = ['site', 'parent', 'tenant', 'description'] class Meta: ordering = ['site', 'name'] diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 2e1c68f78..601743685 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -146,10 +146,10 @@ class RackElevationSVG: class_='device-image' ) image.fit(scale='slice') - drawing.add(image) - drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) + link.add(image) + link.add(drawing.text(get_device_name(device), insert=text, stroke='black', + stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) + link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) @staticmethod def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 825e60d57..e3b2a42ba 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,11 +1,12 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, BooleanColumn +from netbox.tables import BaseTable, columns from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * +from .modules import * from .power import * from .racks import * from .sites import * @@ -35,7 +36,7 @@ class ConsoleConnectionTable(BaseTable): linkify=True, verbose_name='Console Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -43,7 +44,6 @@ class ConsoleConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = ConsolePort fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') - exclude = ('id', ) class PowerConnectionTable(BaseTable): @@ -66,7 +66,7 @@ class PowerConnectionTable(BaseTable): linkify=True, verbose_name='Power Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -74,7 +74,6 @@ class PowerConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPort fields = ('device', 'name', 'pdu', 'outlet', 'reachable') - exclude = ('id', ) class InterfaceConnectionTable(BaseTable): @@ -100,7 +99,7 @@ class InterfaceConnectionTable(BaseTable): linkify=True, verbose_name='Interface B' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -108,4 +107,3 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') - exclude = ('id', ) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 9f2c08342..4b062ad48 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,8 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT __all__ = ( @@ -15,14 +15,19 @@ __all__ = ( # Cables # -class CableTable(BaseTable): - pk = ToggleColumn() +class CableTable(NetBoxTable): termination_a_parent = tables.TemplateColumn( template_code=CABLE_TERMINATION_PARENT, accessor=Accessor('termination_a'), orderable=False, verbose_name='Side A' ) + rack_a = tables.Column( + accessor=Accessor('termination_a__device__rack'), + orderable=False, + linkify=True, + verbose_name='Rack A' + ) termination_a = tables.Column( accessor=Accessor('termination_a'), orderable=False, @@ -35,27 +40,33 @@ class CableTable(BaseTable): orderable=False, verbose_name='Side B' ) + rack_b = tables.Column( + accessor=Accessor('termination_b__device__rack'), + orderable=False, + linkify=True, + verbose_name='Rack B' + ) termination_b = tables.Column( accessor=Accessor('termination_b'), orderable=False, linkify=True, verbose_name='Termination B' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() - length = TemplateColumn( + length = columns.TemplateColumn( template_code=CABLE_LENGTH, order_by=('_abs_length', 'length_unit') ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:cable_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Cable fields = ( - 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', + 'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', 'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b6e664c91..1e6c9e2e2 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -2,14 +2,11 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, - PowerOutlet, PowerPort, RearPort, VirtualChassis, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, + InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, - MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, -) from .template_code import * __all__ = ( @@ -25,6 +22,7 @@ __all__ = ( 'DeviceImportTable', 'DeviceInterfaceTable', 'DeviceInventoryItemTable', + 'DeviceModuleBayTable', 'DevicePowerPortTable', 'DevicePowerOutletTable', 'DeviceRearPortTable', @@ -33,7 +31,9 @@ __all__ = ( 'DeviceAssignTable', 'FrontPortTable', 'InterfaceTable', + 'InventoryItemRoleTable', 'InventoryItemTable', + 'ModuleBayTable', 'PlatformTable', 'PowerOutletTable', 'PowerPortTable', @@ -46,7 +46,7 @@ def get_cabletermination_row_class(record): if record.mark_connected: return 'success' elif record.cable: - return record.cable.get_status_class() + return record.cable.get_status_color() return '' @@ -67,74 +67,70 @@ def get_interface_state_attribute(record): else: return "disabled" + # # Device roles # - -class DeviceRoleTable(BaseTable): - pk = ToggleColumn() +class DeviceRoleTable(NetBoxTable): name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'role_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'role_id': 'pk'}, verbose_name='VMs' ) - color = ColorColumn() - vm_role = BooleanColumn() - tags = TagColumn( + color = columns.ColorColumn() + vm_role = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:devicerole_list' ) - actions = ButtonsColumn(DeviceRole) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') + default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') # # Platforms # -class PlatformTable(BaseTable): - pk = ToggleColumn() +class PlatformTable(NetBoxTable): name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'platform_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'platform_id': 'pk'}, verbose_name='VMs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:platform_list' ) - actions = ButtonsColumn(Platform) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', ) @@ -142,13 +138,12 @@ class PlatformTable(BaseTable): # Devices # -class DeviceTable(BaseTable): - pk = ToggleColumn() +class DeviceTable(NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -159,7 +154,7 @@ class DeviceTable(BaseTable): rack = tables.Column( linkify=True ) - device_role = ColoredLabelColumn( + device_role = columns.ColoredLabelColumn( verbose_name='Role' ) manufacturer = tables.Column( @@ -195,18 +190,21 @@ class DeviceTable(BaseTable): vc_priority = tables.Column( verbose_name='VC Priority' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:device_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created', - 'last_updated', + 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -250,11 +248,11 @@ class DeviceAssignTable(BaseTable): orderable = False -class DeviceImportTable(BaseTable): +class DeviceImportTable(NetBoxTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -269,7 +267,7 @@ class DeviceImportTable(BaseTable): verbose_name='Type' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False @@ -279,8 +277,7 @@ class DeviceImportTable(BaseTable): # Device components # -class DeviceComponentTable(BaseTable): - pk = ToggleColumn() +class DeviceComponentTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -289,30 +286,43 @@ class DeviceComponentTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): order_by = ('device', 'name') -class CableTerminationTable(BaseTable): +class ModularDeviceComponentTable(DeviceComponentTable): + module_bay = tables.Column( + accessor=Accessor('module__module_bay'), + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + module = tables.Column( + linkify=True + ) + + +class CableTerminationTable(NetBoxTable): cable = tables.Column( linkify=True ) - cable_color = ColorColumn( + cable_color = columns.ColorColumn( accessor='cable__color', orderable=False, verbose_name='Cable Color' ) - link_peer = TemplateColumn( + link_peer = columns.TemplateColumn( accessor='_link_peer', template_code=LINKTERMINATION, orderable=False, verbose_name='Link Peer' ) - mark_connected = BooleanColumn() + mark_connected = columns.BooleanColumn() class PathEndpointTable(CableTerminationTable): - connection = TemplateColumn( + connection = columns.TemplateColumn( accessor='_path__last_node', template_code=LINKTERMINATION, verbose_name='Connection', @@ -320,22 +330,22 @@ class PathEndpointTable(CableTerminationTable): ) -class ConsolePortTable(DeviceComponentTable, PathEndpointTable): +class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleports', 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -346,40 +356,38 @@ class DeviceConsolePortTable(ConsolePortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsolePort, - buttons=('edit', 'delete'), - prepend_template=CONSOLEPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=CONSOLEPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions' + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } -class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): +class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleserverports', 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleserverport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -391,40 +399,39 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsoleServerPort, - buttons=('edit', 'delete'), - prepend_template=CONSOLESERVERPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=CONSOLESERVERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } -class PowerPortTable(DeviceComponentTable, PathEndpointTable): +class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_powerports', 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerport_list' ) class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', - 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', + 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -436,28 +443,25 @@ class DevicePowerPortTable(PowerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerPort, - buttons=('edit', 'delete'), - prepend_template=POWERPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=POWERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class } -class PowerOutletTable(DeviceComponentTable, PathEndpointTable): +class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_poweroutlets', @@ -467,15 +471,16 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): power_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:poweroutlet_list' ) class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', + 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -486,28 +491,26 @@ class DevicePowerOutletTable(PowerOutletTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerOutlet, - buttons=('edit', 'delete'), - prepend_template=POWEROUTLET_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=POWEROUTLET_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', + 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', ) row_attrs = { 'class': get_cabletermination_row_class } -class BaseInterfaceTable(BaseTable): - enabled = BooleanColumn() +class BaseInterfaceTable(NetBoxTable): + enabled = columns.BooleanColumn() ip_addresses = tables.TemplateColumn( template_code=INTERFACE_IPADDRESSES, orderable=False, @@ -520,40 +523,44 @@ class BaseInterfaceTable(BaseTable): verbose_name='FHRP Groups' ) untagged_vlan = tables.Column(linkify=True) - tagged_vlans = TemplateColumn( + tagged_vlans = columns.TemplateColumn( template_code=INTERFACE_TAGGED_VLANS, orderable=False, verbose_name='Tagged VLANs' ) -class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): +class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_interfaces', 'args': [Accessor('device_id')], } ) - mgmt_only = BooleanColumn() + mgmt_only = columns.BooleanColumn() wireless_link = tables.Column( linkify=True ) - wireless_lans = TemplateColumn( + wireless_lans = columns.TemplateColumn( template_code=INTERFACE_WIRELESS_LANS, orderable=False, verbose_name='Wireless LANs' ) - tags = TagColumn( + vrf = tables.Column( + linkify=True + ) + tags = columns.TagColumn( url_name='dcim:interface_list' ) class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', - 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', - 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', + 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -576,24 +583,23 @@ class DeviceInterfaceTable(InterfaceTable): linkify=True, verbose_name='LAG' ) - actions = ButtonsColumn( - model=Interface, - buttons=('edit', 'delete'), - prepend_template=INTERFACE_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=INTERFACE_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', - 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', - 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', + 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', - 'cable', 'connection', 'actions', + 'cable', 'connection', ) row_attrs = { 'class': get_interface_row_class, @@ -602,29 +608,30 @@ class DeviceInterfaceTable(InterfaceTable): } -class FrontPortTable(DeviceComponentTable, CableTerminationTable): +class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_frontports', 'args': [Accessor('device_id')], } ) - color = ColorColumn() + color = columns.ColorColumn() rear_port_position = tables.Column( verbose_name='Position' ) rear_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:frontport_list' ) class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -638,44 +645,41 @@ class DeviceFrontPortTable(FrontPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=FrontPort, - buttons=('edit', 'delete'), - prepend_template=FRONTPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=FRONTPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class } -class RearPortTable(DeviceComponentTable, CableTerminationTable): +class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_rearports', 'args': [Accessor('device_id')], } ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rearport_list' ) class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -687,20 +691,18 @@ class DeviceRearPortTable(RearPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=RearPort, - buttons=('edit', 'delete'), - prepend_template=REARPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=REARPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', ) row_attrs = { 'class': get_cabletermination_row_class @@ -714,6 +716,15 @@ class DeviceBayTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + device_role = columns.ColoredLabelColumn( + accessor=Accessor('installed_device__device_role'), + verbose_name='Role' + ) + device_type = tables.Column( + accessor=Accessor('installed_device__device_type'), + linkify=True, + verbose_name='Type' + ) status = tables.TemplateColumn( template_code=DEVICEBAY_STATUS, order_by=Accessor('installed_device__status') @@ -721,14 +732,14 @@ class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:devicebay_list' ) class Meta(DeviceComponentTable.Meta): model = DeviceBay fields = ( - 'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags', + 'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags', 'created', 'last_updated', ) @@ -742,10 +753,8 @@ class DeviceDeviceBayTable(DeviceBayTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=DEVICEBAY_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=DEVICEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -753,9 +762,51 @@ class DeviceDeviceBayTable(DeviceBayTable): fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) - default_columns = ( - 'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions', + default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description') + + +class ModuleBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + installed_module = tables.Column( + linkify=True, + verbose_name='Installed module' + ) + module_serial = tables.Column( + accessor=tables.A('installed_module__serial') + ) + module_asset_tag = tables.Column( + accessor=tables.A('installed_module__asset_tag') + ) + tags = columns.TagColumn( + url_name='dcim:modulebay_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ( + 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', + 'description', 'tags', ) + default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') + + +class DeviceModuleBayTable(ModuleBayTable): + actions = columns.ActionsColumn( + extra_buttons=MODULEBAY_BUTTONS + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ( + 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', + 'description', 'tags', 'actions', + ) + default_columns = ('pk', 'name', 'label', 'installed_module', 'description') class InventoryItemTable(DeviceComponentTable): @@ -765,22 +816,29 @@ class InventoryItemTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + role = columns.ColoredLabelColumn() manufacturer = tables.Column( linkify=True ) - discovered = BooleanColumn() - tags = TagColumn( + component = tables.Column( + orderable=False, + linkify=True + ) + discovered = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:inventoryitem_list' ) cable = None # Override DeviceComponentTable - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( - 'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'discovered', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', ) - default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') class DeviceInventoryItemTable(InventoryItemTable): @@ -790,45 +848,61 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( - 'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'tags', 'actions', + 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', + 'description', 'discovered', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'actions', + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', ) +class InventoryItemRoleTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + inventoryitem_count = columns.LinkedCountColumn( + viewname='dcim:inventoryitem_list', + url_params={'role_id': 'pk'}, + verbose_name='Items' + ) + color = columns.ColorColumn() + tags = columns.TagColumn( + url_name='dcim:inventoryitemrole_list' + ) + + class Meta(NetBoxTable.Meta): + model = InventoryItemRole + fields = ( + 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', + ) + default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description') + + # # Virtual chassis # -class VirtualChassisTable(BaseTable): - pk = ToggleColumn() +class VirtualChassisTable(NetBoxTable): name = tables.Column( linkify=True ) master = tables.Column( linkify=True ) - member_count = LinkedCountColumn( + member_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:virtualchassis_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VirtualChassis fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 5643edc37..f5f5ed7bf 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -2,11 +2,10 @@ import django_tables2 as tables from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, - Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, -) -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, + InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) +from netbox.tables import NetBoxTable, columns +from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( 'ConsolePortTemplateTable', @@ -15,7 +14,9 @@ __all__ = ( 'DeviceTypeTable', 'FrontPortTemplateTable', 'InterfaceTemplateTable', + 'InventoryItemTemplateTable', 'ManufacturerTable', + 'ModuleBayTemplateTable', 'PowerOutletTemplateTable', 'PowerPortTemplateTable', 'RearPortTemplateTable', @@ -26,8 +27,7 @@ __all__ = ( # Manufacturers # -class ManufacturerTable(BaseTable): - pk = ToggleColumn() +class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -41,19 +41,21 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - tags = TagColumn( + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:manufacturer_list' ) - actions = ButtonsColumn(Manufacturer) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', - 'actions', 'created', 'last_updated', + 'contacts', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', ) @@ -61,8 +63,7 @@ class ManufacturerTable(BaseTable): # Device types # -class DeviceTypeTable(BaseTable): - pk = ToggleColumn() +class DeviceTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Device Type' @@ -70,20 +71,20 @@ class DeviceTypeTable(BaseTable): manufacturer = tables.Column( linkify=True ) - is_full_depth = BooleanColumn( + is_full_depth = columns.BooleanColumn( verbose_name='Full Depth' ) - instance_count = LinkedCountColumn( + instance_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'device_type_id': 'pk'}, verbose_name='Instances' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:devicetype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', @@ -98,8 +99,7 @@ class DeviceTypeTable(BaseTable): # Device type components # -class ComponentTemplateTable(BaseTable): - pk = ToggleColumn() +class ComponentTemplateTable(NetBoxTable): id = tables.Column( verbose_name='ID' ) @@ -107,14 +107,14 @@ class ComponentTemplateTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): exclude = ('id', ) class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsolePortTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -124,9 +124,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsoleServerPortTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -136,9 +136,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerPortTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -148,9 +148,9 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerOutletTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -160,12 +160,12 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class InterfaceTemplateTable(ComponentTemplateTable): - mgmt_only = BooleanColumn( + mgmt_only = columns.BooleanColumn( verbose_name='Management Only' ) - actions = ButtonsColumn( - model=InterfaceTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -178,10 +178,10 @@ class FrontPortTemplateTable(ComponentTemplateTable): rear_port_position = tables.Column( verbose_name='Position' ) - color = ColorColumn() - actions = ButtonsColumn( - model=FrontPortTemplate, - buttons=('edit', 'delete') + color = columns.ColorColumn() + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -191,10 +191,10 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): - color = ColorColumn() - actions = ButtonsColumn( - model=RearPortTemplate, - buttons=('edit', 'delete') + color = columns.ColorColumn() + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -203,13 +203,45 @@ class RearPortTemplateTable(ComponentTemplateTable): empty_text = "None" +class ModuleBayTemplateTable(ComponentTemplateTable): + actions = columns.ActionsColumn( + actions=('edit', 'delete') + ) + + class Meta(ComponentTemplateTable.Meta): + model = ModuleBayTemplate + fields = ('pk', 'name', 'label', 'position', 'description', 'actions') + empty_text = "None" + + class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=DeviceBayTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): model = DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" + + +class InventoryItemTemplateTable(ComponentTemplateTable): + actions = columns.ActionsColumn( + actions=('edit', 'delete') + ) + role = tables.Column( + linkify=True + ) + manufacturer = tables.Column( + linkify=True + ) + component = tables.Column( + orderable=False + ) + + class Meta(ComponentTemplateTable.Meta): + model = InventoryItemTemplate + fields = ( + 'pk', 'name', 'label', 'parent', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions', + ) + empty_text = "None" diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py new file mode 100644 index 000000000..5b009e42e --- /dev/null +++ b/netbox/dcim/tables/modules.py @@ -0,0 +1,59 @@ +import django_tables2 as tables + +from dcim.models import Module, ModuleType +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'ModuleTable', + 'ModuleTypeTable', +) + + +class ModuleTypeTable(NetBoxTable): + model = tables.Column( + linkify=True, + verbose_name='Module Type' + ) + instance_count = columns.LinkedCountColumn( + viewname='dcim:module_list', + url_params={'module_type_id': 'pk'}, + verbose_name='Instances' + ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='dcim:moduletype_list' + ) + + class Meta(NetBoxTable.Meta): + model = ModuleType + fields = ( + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'model', 'manufacturer', 'part_number', + ) + + +class ModuleTable(NetBoxTable): + device = tables.Column( + linkify=True + ) + module_bay = tables.Column( + linkify=True + ) + module_type = tables.Column( + linkify=True + ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='dcim:module_list' + ) + + class Meta(NetBoxTable.Meta): + model = Module + fields = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', + ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index c1ea8a34c..cab95bb02 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import PowerFeed, PowerPanel -from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn +from netbox.tables import NetBoxTable, columns from .devices import CableTerminationTable __all__ = ( @@ -14,26 +14,28 @@ __all__ = ( # Power panels # -class PowerPanelTable(BaseTable): - pk = ToggleColumn() +class PowerPanelTable(NetBoxTable): name = tables.Column( linkify=True ) site = tables.Column( linkify=True ) - powerfeed_count = LinkedCountColumn( + powerfeed_count = columns.LinkedCountColumn( viewname='dcim:powerfeed_list', url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) - tags = TagColumn( + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerPanel - fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',) + fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -44,7 +46,6 @@ class PowerPanelTable(BaseTable): # We're not using PathEndpointTable for PowerFeed because power connections # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): - pk = ToggleColumn() name = tables.Column( linkify=True ) @@ -54,20 +55,20 @@ class PowerFeedTable(CableTerminationTable): rack = tables.Column( linkify=True ) - status = ChoiceFieldColumn() - type = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() + type = columns.ChoiceFieldColumn() max_utilization = tables.TemplateColumn( template_code="{{ value }}%" ) available_power = tables.Column( verbose_name='Available power (VA)' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:powerfeed_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerFeed fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index dba28603c..e5a1c8488 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -2,11 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, - TagColumn, ToggleColumn, UtilizationColumn, -) __all__ = ( 'RackTable', @@ -19,31 +16,28 @@ __all__ = ( # Rack roles # -class RackRoleTable(BaseTable): - pk = ToggleColumn() +class RackRoleTable(NetBoxTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rackrole_list' ) - actions = ButtonsColumn(RackRole) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackRole fields = ( - 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', - 'created', 'last_updated', + 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created', + 'last_updated', ) - default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'rack_count', 'color', 'description') # # Racks # -class RackTable(BaseTable): - pk = ToggleColumn() +class RackTable(NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -55,27 +49,30 @@ class RackTable(BaseTable): linkify=True ) tenant = TenantColumn() - status = ChoiceFieldColumn() - role = ColoredLabelColumn() + status = columns.ChoiceFieldColumn() + role = columns.ColoredLabelColumn() u_height = tables.TemplateColumn( template_code="{{ record.u_height }}U", verbose_name='Height' ) - comments = MarkdownColumn() - device_count = LinkedCountColumn( + comments = columns.MarkdownColumn() + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'rack_id': 'pk'}, verbose_name='Devices' ) - get_utilization = UtilizationColumn( + get_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Space' ) - get_power_utilization = UtilizationColumn( + get_power_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Power' ) - tags = TagColumn( + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:rack_list' ) outer_width = tables.TemplateColumn( @@ -87,12 +84,12 @@ class RackTable(BaseTable): verbose_name='Outer Depth' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', - 'get_power_utilization', 'tags', 'created', 'last_updated', + 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', @@ -104,8 +101,7 @@ class RackTable(BaseTable): # Rack reservations # -class RackReservationTable(BaseTable): - pk = ToggleColumn() +class RackReservationTable(NetBoxTable): reservation = tables.Column( accessor='pk', linkify=True @@ -122,17 +118,14 @@ class RackReservationTable(BaseTable): orderable=False, verbose_name='Units' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) - actions = ButtonsColumn(RackReservation) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackReservation fields = ( 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', - ) + default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index bf4812cfa..84522480f 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,11 +1,9 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, -) -from .template_code import LOCATION_ELEVATIONS +from .template_code import LOCATION_BUTTONS __all__ = ( 'LocationTable', @@ -19,90 +17,100 @@ __all__ = ( # Regions # -class RegionTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( +class RegionTable(NetBoxTable): + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'region_id': 'pk'}, verbose_name='Sites' ) - tags = TagColumn( + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:region_list' ) - actions = ButtonsColumn(Region) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Region - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'actions', + ) + default_columns = ('pk', 'name', 'site_count', 'description') # # Site groups # -class SiteGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( +class SiteGroupTable(NetBoxTable): + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'group_id': 'pk'}, verbose_name='Sites' ) - tags = TagColumn( + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) - actions = ButtonsColumn(SiteGroup) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = SiteGroup - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'actions', + ) + default_columns = ('pk', 'name', 'site_count', 'description') # # Sites # -class SiteTable(BaseTable): - pk = ToggleColumn() +class SiteTable(NetBoxTable): name = tables.Column( linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() region = tables.Column( linkify=True ) group = tables.Column( linkify=True ) - asn_count = LinkedCountColumn( + asns = tables.ManyToManyColumn( + linkify_item=True, + verbose_name='ASNs' + ) + asn_count = columns.LinkedCountColumn( accessor=tables.A('asns__count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name='ASN Count' ) - asns = tables.ManyToManyColumn( - linkify_item=True, - verbose_name='ASNs' - ) tenant = TenantColumn() - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:site_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Site fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', + 'contacts', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') @@ -111,37 +119,38 @@ class SiteTable(BaseTable): # Locations # -class LocationTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( +class LocationTable(NetBoxTable): + name = columns.MPTTColumn( linkify=True ) site = tables.Column( linkify=True ) tenant = TenantColumn() - rack_count = LinkedCountColumn( + rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, verbose_name='Racks' ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'location_id': 'pk'}, verbose_name='Devices' ) - tags = TagColumn( + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( url_name='dcim:location_list' ) - actions = ButtonsColumn( - model=Location, - prepend_template=LOCATION_ELEVATIONS + actions = columns.ActionsColumn( + extra_buttons=LOCATION_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', - 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts', + 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 12d2683b1..2728cd843 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -31,7 +31,7 @@ DEVICE_LINK = """ DEVICEBAY_STATUS = """ {% if record.installed_device_id %} - + {{ record.installed_device.get_status_display }} {% else %} @@ -43,7 +43,7 @@ INTERFACE_IPADDRESSES = """
    {% for ip in record.ip_addresses.all %} {% if ip.status != 'active' %} - {{ ip }} + {{ ip }} {% else %} {{ ip }} {% endif %} @@ -88,7 +88,7 @@ POWERFEED_CABLETERMINATION = """ {{ value }} """ -LOCATION_ELEVATIONS = """ +LOCATION_BUTTONS = """ @@ -112,11 +112,29 @@ DEVICE_ASSIGN_TENANT = """ {% endif %} """ +# +# Device component templatebuttons +# + +MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ +{% load helpers %} +{% if perms.dcim.add_inventoryitemtemplate %} + + + +{% endif %} +""" + # # Device component buttons # CONSOLEPORT_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -144,6 +162,11 @@ CONSOLEPORT_BUTTONS = """ """ CONSOLESERVERPORT_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -171,6 +194,11 @@ CONSOLESERVERPORT_BUTTONS = """ """ POWERPORT_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -197,6 +225,11 @@ POWERPORT_BUTTONS = """ """ POWEROUTLET_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -219,10 +252,20 @@ POWEROUTLET_BUTTONS = """ """ INTERFACE_BUTTONS = """ -{% if perms.ipam.add_ipaddress %} - - - +{% if perms.ipam.add_ipaddress or perms.dcim.add_inventoryitem %} + + + + {% endif %} {% if record.link %} @@ -245,7 +288,7 @@ INTERFACE_BUTTONS = """ {% if not record.mark_connected %} -
    @@ -45,7 +53,7 @@ {# Search bar #}
    - {% search_options %} + {% search_options request %}
    {# Proflie/login button #} @@ -110,7 +118,7 @@ @@ -162,4 +170,4 @@
  • -{% endblock layout %} \ No newline at end of file +{% endblock layout %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 22713b592..881b6cca6 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -18,9 +18,7 @@ - + @@ -28,25 +26,19 @@ - + - + diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 9ba41216d..f8393f945 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -2,31 +2,14 @@ {% load static %} {% load form_helpers %} -{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %} - {% block form %}
    Circuit Termination
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    + {% render_field form.provider %} + {% render_field form.circuit %} + {% render_field form.term_side %} {% render_field form.mark_connected %} {% with providernetwork_tab_active=form.initial.provider_network %}
    @@ -69,7 +52,7 @@ {# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #} {% block buttons %} Cancel - {% if obj.pk %} + {% if object.pk %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 89b7891a2..fdb01e803 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -5,7 +5,7 @@ Termination - {{ side }} Side
    {% if not termination and perms.circuits.add_circuittermination %} - + Add {% endif %} @@ -32,9 +32,9 @@
    @@ -48,11 +48,11 @@ {% with peer=termination.get_link_peer %} to {% if peer.device %} - {{ peer.device }}
    + {{ peer.device|linkify }}
    {% elif peer.circuit %} - {{ peer.circuit }}
    + {{ peer.circuit|linkify }}
    {% endif %} - {{ peer }} + {{ peer|linkify }} {% endwith %}
    @@ -82,9 +82,7 @@ {% else %}
    - + {% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 14fd00863..1bf63f2d5 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -16,14 +16,29 @@
    -
    - Provider -
    +
    Provider
    Provider - {{ object.provider }} - {{ object.provider|linkify }}
    Circuit ID
    Type{{ object.type }}{{ object.type|linkify }}
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    Site {% if termination.site.region %} - {{ termination.site.region }} / + {{ termination.site.region|linkify }} / {% endif %} - {{ termination.site }} + {{ termination.site|linkify }}
    Provider Network - {{ termination.provider_network }} - {{ termination.provider_network|linkify }}
    - - + + + + + + @@ -41,11 +56,11 @@ - + - + diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 970cd4a54..4987722a5 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -20,14 +20,16 @@
    ASN{{ object.asn|placeholder }}ASN + {% if object.asn %} +
    + +
    + {% endif %} + {{ object.asn|placeholder }} +
    ASNs + {% for asn in object.asns.all %} + {{ asn|linkify }}{% if not forloop.last %}, {% endif %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
    Account
    NOC Contact{{ object.noc_contact|render_markdown|placeholder }}{{ object.noc_contact|markdown|placeholder }}
    Admin Contact{{ object.admin_contact|render_markdown|placeholder }}{{ object.admin_contact|markdown|placeholder }}
    Circuits
    - + + + + + diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 00704e6ca..f1cf986e6 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -19,21 +19,15 @@ - + diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index d9dbc5b2d..ac0481925 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -25,7 +25,7 @@ {% if next_node.cable %}
  • {{ next_node }} - (Cable {{ next_node.cable }}) + (Cable {{ next_node.cable|linkify }})
  • {% else %}
  • {{ next_node }}
  • diff --git a/netbox/templates/dcim/component_create.html b/netbox/templates/dcim/component_create.html new file mode 100644 index 000000000..e078bf069 --- /dev/null +++ b/netbox/templates/dcim/component_create.html @@ -0,0 +1,9 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} + {% block replication_fields %} + {% render_form replication_form %} + {% endblock replication_fields %} + {{ block.super }} +{% endblock form %} diff --git a/netbox/templates/dcim/component_template_create.html b/netbox/templates/dcim/component_template_create.html new file mode 100644 index 000000000..d164db872 --- /dev/null +++ b/netbox/templates/dcim/component_template_create.html @@ -0,0 +1,38 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} + {% if form.module_type %} +
    +
    + +
    +
    +
    +
    + {% render_field replication_form.device_type %} +
    +
    + {% render_field replication_form.module_type %} +
    +
    + {% else %} + {% render_field replication_form.device_type %} + {% endif %} + {% block replication_fields %} + {% render_field replication_form.name_pattern %} + {% render_field replication_form.label_pattern %} + {% endblock replication_fields %} + {{ block.super }} +{% endblock form %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 38cfb90ae..ce2c1655d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -20,9 +20,11 @@
    Provider - {{ object.provider }} - {{ object.provider|linkify }}
    Name {{ object.name }}
    Service ID{{ object.service_id|placeholder }}
    Description {{ object.description|placeholder }}
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    - + + + + + @@ -58,91 +60,88 @@
    {% if object.mark_connected %} - Marked as connected + Marked as connected {% elif object.cable %} -
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    - - - - - {% if object.connected_endpoint %} +
    Cable - {{ object.cable }} - - - -
    - + - - - - - - - - - - - - - - - - - {% endif %} -
    DeviceCable - {{ object.connected_endpoint.device }} + {{ object.cable|linkify }} + + +
    Name - {{ object.connected_endpoint.name }} -
    Type{{ object.connected_endpoint.get_type_display|placeholder }}
    Description{{ object.connected_endpoint.description|placeholder }}
    Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
    - {% else %} -
    - Not Connected - {% if perms.dcim.add_cable %} - - {% endif %} -
    - {% endif %} - + {% if object.connected_endpoint %} + + Device + {{ object.connected_endpoint.device|linkify }} + + + Name + {{ object.connected_endpoint|linkify:"name" }} + + + Type + {{ object.connected_endpoint.get_type_display|placeholder }} + + + Description + {{ object.connected_endpoint.description|placeholder }} + + + Path Status + + {% if object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} + + + {% endif %} + + {% else %} +
    + Not Connected + {% if perms.dcim.add_cable %} + + {% endif %} +
    + {% endif %} + + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index b44c4a9b8..52b1a3229 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -20,9 +20,11 @@ - + + + + + @@ -64,7 +66,7 @@ - + @@ -143,6 +143,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 3beaed4b4..d075a801d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -19,28 +19,26 @@ - + @@ -127,7 +121,7 @@ {% for vc_member in vc_members %} diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index d5b9f6112..c0167ff0f 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,7 +1,7 @@ {% if path.destination_id %} {% with endpoint=path.destination %} - - + + {% endwith %} {% else %} diff --git a/netbox/templates/dcim/inc/interface_vlans_table.html b/netbox/templates/dcim/inc/interface_vlans_table.html index 67ffb2954..0c23b5702 100644 --- a/netbox/templates/dcim/inc/interface_vlans_table.html +++ b/netbox/templates/dcim/inc/interface_vlans_table.html @@ -8,9 +8,7 @@ {% with tagged_vlans=obj.tagged_vlans.all %} {% if obj.untagged_vlan and obj.untagged_vlan not in tagged_vlans %} - + - + - + - + - - + + @@ -90,6 +80,7 @@
    {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} + {% include 'dcim/inc/nonracked_devices.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index c79ec923e..43d16afcb 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -34,6 +34,12 @@ {{ devicetypes_table.rows|length }} + + + + - + @@ -73,7 +67,7 @@ NAPALM Arguments
    -
    {{ object.napalm_args|render_json }}
    +
    {{ object.napalm_args|json }}
    {% include 'inc/panels/custom_fields.html' %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 1824cac19..777af5563 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -24,37 +24,25 @@
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -74,14 +76,12 @@
    Device - {{ object.connected_endpoint.device }} + {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type {% if object.site.region %} {% for region in object.site.region.get_ancestors %} - {{ region }} / + {{ region|linkify }} / {% endfor %} - {{ object.site.region }} + {{ object.site.region|linkify }} {% else %} - None + None {% endif %}
    Site - {{ object.site }} - {{ object.site|linkify }}
    Location {% if object.location %} {% for location in object.location.get_ancestors %} - {{ location }} / + {{ location|linkify }} / {% endfor %} - {{ object.location }} + {{ object.location|linkify }} {% else %} None {% endif %} @@ -61,7 +59,7 @@ {% if object.parent_bay %} {% with object.parent_bay.device as parent %} - {{ parent }} / {{ object.parent_bay }} + {{ parent|linkify }} / {{ object.parent_bay }} {% if parent.position %} (U{{ parent.position }} / {{ parent.get_face_display }}) {% endif %} @@ -78,20 +76,16 @@
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    Device Type - {{ object.device_type }} ({{ object.device_type.u_height }}U) + {{ object.device_type|linkify }} ({{ object.device_type.u_height }}U)
    - {{ vc_member }} + {{ vc_member|linkify }} {% badge vc_member.vc_position show_empty=True %} @@ -163,9 +157,7 @@ - + @@ -175,13 +167,7 @@ - + @@ -189,9 +175,9 @@ {% if object.primary_ip4 %} {{ object.primary_ip4.address.ip }} {% if object.primary_ip4.nat_inside %} - (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) + (NAT for {{ object.primary_ip4.nat_inside.address.ip|linkify }}) {% elif object.primary_ip4.nat_outside %} - (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) + (NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) {% endif %} {% else %} @@ -204,9 +190,9 @@ {% if object.primary_ip6 %} {{ object.primary_ip6.address.ip }} {% if object.primary_ip6.nat_inside %} - (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) + (NAT for {{ object.primary_ip6.nat_inside.address.ip|linkify }}) {% elif object.primary_ip6.nat_outside %} - (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) + (NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) {% endif %} {% else %} @@ -218,9 +204,9 @@ {% endif %} diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 705a51eea..ea67154b1 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -16,84 +16,61 @@ {% block extra_controls %} {% if perms.dcim.change_device %} {% endif %} {% endblock %} -{% block tab_items %} - +{% block extra_tabs %} + {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} + {% if active_tab == tab_name or devicebay_count %} + + {% endif %} + {% endwith %} + + {% with tab_name='module-bays' modulebay_count=object.modulebays.count %} + {% if active_tab == tab_name or modulebay_count %} + + {% endif %} + {% endwith %} {% with tab_name='interfaces' interface_count=object.interfaces_count %} {% if active_tab == tab_name or interface_count %} @@ -151,13 +128,6 @@ {% endif %} {% endwith %} - {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} {% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %} {% if active_tab == tab_name or inventoryitem_count %} 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 new file mode 100644 index 000000000..fc1c9a60d --- /dev/null +++ b/netbox/templates/dcim/device/modulebays.html @@ -0,0 +1,44 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %} + + + {% csrf_token %} + +
    +
    + {% include 'htmx/table.html' %} +
    +
    + +
    +
    + {% if perms.dcim.change_modulebay %} + + + {% endif %} + {% if perms.dcim.delete_modulebay %} + + {% endif %} +
    + {% if perms.dcim.add_modulebay %} + + {% endif %} +
    + + {% table_config_form table %} +{% endblock %} 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/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html index 2eb2fcd96..44b93d870 100644 --- a/netbox/templates/dcim/device_component_edit.html +++ b/netbox/templates/dcim/device_component_edit.html @@ -1,14 +1,16 @@ {% extends 'generic/object_edit.html' %} {% load form_helpers %} -{% block form_fields %} +{% block form %} +
    {% if form.instance.device %} -
    - -
    - -
    +
    + +
    +
    +
    {% endif %} {% render_form form %} -{% endblock %} +
    +{% endblock form %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 5717f8c6e..d11e008bb 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -39,19 +39,19 @@ {% render_field form.location %} {% render_field form.rack %} - {% if obj.device_type.is_child_device and obj.parent_bay %} + {% if object.device_type.is_child_device and object.parent_bay %}
    - +
    @@ -69,7 +69,7 @@
    {% render_field form.status %} {% render_field form.platform %} - {% if obj.pk %} + {% if object.pk %} {% render_field form.primary_ip4 %} {% render_field form.primary_ip6 %} {% endif %} diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index caf94ada6..b30de60c2 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -1,4 +1,4 @@ -{% extends 'generic/object_bulk_import.html' %} +{% extends 'generic/bulk_import.html' %} {% block tabs %} {% include 'dcim/inc/device_import_header.html' %} diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html index bec8b21a8..d0dc72b61 100644 --- a/netbox/templates/dcim/device_import_child.html +++ b/netbox/templates/dcim/device_import_child.html @@ -1,4 +1,4 @@ -{% extends 'generic/object_bulk_import.html' %} +{% extends 'generic/bulk_import.html' %} {% block tabs %} {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 177a0fb36..60efc842e 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -56,6 +56,13 @@ {% endif %} + {% if perms.dcim.add_modulebay %} +
  • + +
  • + {% endif %} {% if perms.dcim.add_inventoryitem %}
  • {% endif %} + {{ block.super }} {% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 3a470bec5..38cc8f16b 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -20,9 +20,7 @@
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Role
    Platform - {% if object.platform %} - {{ object.platform }} - {% else %} - None - {% endif %} - {{ object.platform|linkify|placeholder }}
    Primary IPv4Cluster {% if object.cluster.group %} - {{ object.cluster.group }} / + {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster }} + {{ object.cluster|linkify }}
    - + @@ -54,9 +52,7 @@
    Device - {{ object.device }} - {{ object.device|linkify }}
    Name
    - + diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 4b9dc8db7..e717a48aa 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -14,7 +14,7 @@
    Device - {{ device }} - {{ device|linkify }}
    Device Type
    - + diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index b3c161002..83ee1f41e 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -38,20 +38,36 @@ {% if perms.dcim.add_rearporttemplate %}
  • Rear Ports
  • {% endif %} + {% if perms.dcim.add_modulebaytemplate %} +
  • Module Bays
  • + {% endif %} {% if perms.dcim.add_devicebaytemplate %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitemtemplate %} +
  • Inventory Items
  • + {% endif %} {% endif %} {% endblock %} -{% block tab_items %} - +{% block extra_tabs %} + {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} + {% if active_tab == tab_name or devicebay_count %} + + {% endif %} + {% endwith %} + + {% with tab_name='module-bay-templates' modulebay_count=object.modulebaytemplates.count %} + {% if active_tab == tab_name or modulebay_count %} + + {% endif %} + {% endwith %} {% with tab_name='interface-templates' interface_count=object.interfacetemplates.count %} {% if active_tab == tab_name or interface_count %} @@ -109,10 +125,10 @@ {% endif %} {% endwith %} - {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} - {% if active_tab == tab_name or devicebay_count %} + {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %} + {% if active_tab == tab_name or inventoryitem_count %} {% endif %} {% endwith %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 05be82fc9..891f217ee 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -20,9 +20,11 @@
    Manufacturer{{ object.manufacturer }}{{ object.manufacturer|linkify }}
    Model Name
    - + + + + + @@ -44,9 +46,7 @@ - + @@ -78,7 +78,7 @@ - + + + + + + {% if termination.device.rack %} + + + + + {% endif %} - + - + {% else %} {# Circuit termination #} - + - + + + + + {% endif %}
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Rear Port - {{ object.rear_port }} - {{ object.rear_port|linkify }}
    Rear Port Position
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -129,6 +129,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/frontporttemplate_create.html b/netbox/templates/dcim/frontporttemplate_create.html new file mode 100644 index 000000000..50e9d355c --- /dev/null +++ b/netbox/templates/dcim/frontporttemplate_create.html @@ -0,0 +1,7 @@ +{% extends 'dcim/component_template_create.html' %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% render_field replication_form.rear_port_set %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 1ba3d05c9..6d75aee85 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -4,35 +4,39 @@ {# Device component #}
    Device - {{ termination.device }} - {{ termination.device|linkify }}
    Site{{ termination.device.site|linkify }}
    Rack{{ termination.device.rack|linkify }}
    Type - {{ termination|meta:"verbose_name"|capfirst }} - {{ termination|meta:"verbose_name"|capfirst }}
    Component - {{ termination }} - {{ termination|linkify }}
    Provider - {{ termination.circuit.provider }} - {{ termination.circuit.provider|linkify }}
    Circuit - {{ termination.circuit }} ({{ termination }}) - {{ termination.circuit|linkify }}
    Termination{{ termination }}
    diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html index 26a7e1cd3..c7fa7918a 100644 --- a/netbox/templates/dcim/inc/cabletermination.html +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -6,9 +6,9 @@ {{ termination.parent_object }} {% else %} - {{ termination.parent_object }} + {{ termination.parent_object|linkify }} {% endif %}
    - {{ termination }} + {{ termination|linkify }} {{ endpoint.parent_object }}{{ endpoint }}{{ endpoint.parent_object|linkify }}{{ endpoint|linkify }}Not connected
    - {{ obj.untagged_vlan.vid }} - {{ obj.untagged_vlan|linkify:"vid" }} {{ obj.untagged_vlan.name }} @@ -22,9 +20,7 @@ {% endif %} {% for vlan in tagged_vlans %}
    - {{ vlan.vid }} - {{ vlan|linkify:"vid" }} {{ vlan.name }} diff --git a/netbox/templates/dcim/inc/nonracked_devices.html b/netbox/templates/dcim/inc/nonracked_devices.html new file mode 100644 index 000000000..d4cd58839 --- /dev/null +++ b/netbox/templates/dcim/inc/nonracked_devices.html @@ -0,0 +1,76 @@ +{% load helpers %} + +
    +
    + Non-Racked Devices +
    +
    + {% if nonracked_devices %} + + + + + + + + {% for device in nonracked_devices %} + + + + + {% if device.parent_bay %} + + + {% else %} + + {% endif %} + + {% endfor %} +
    NameRoleTypeParent Device
    + {{ device }} + {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device|linkify }}{{ device.parent_bay }}
    + + {% if total_nonracked_devices_count > nonracked_devices.count %} + {% if object|meta:'verbose_name' == 'site' %} +
    + Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (View full list) +
    + {% elif object|meta:'verbose_name' == 'location' %} +
    + Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (View full list) +
    + {% endif %} + {% endif %} + + {% else %} +
    + None +
    + {% endif %} +
    + + {% if perms.dcim.add_device %} + {% if object|meta:'verbose_name' == 'rack' %} + + {% elif object|meta:'verbose_name' == 'site' %} + + {% elif object|meta:'verbose_name' == 'location' %} + + {% endif %} + {% endif %} +
    \ No newline at end of file diff --git a/netbox/templates/dcim/inc/panels/inventory_items.html b/netbox/templates/dcim/inc/panels/inventory_items.html new file mode 100644 index 000000000..d89b64814 --- /dev/null +++ b/netbox/templates/dcim/inc/panels/inventory_items.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
    +
    Inventory Items
    +
    + + + + + + + + + + + {% for item in object.inventory_items.all %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    NameLabelRole
    {{ item|linkify:"name" }}{{ item.label|placeholder }}{{ item.role|linkify|placeholder }} + {% if perms.dcim.change_inventoryitem %} + + + + {% endif %} + {% if perms.ipam.delete_inventoryitem %} + + + + {% endif %} +
    None
    +
    + +
    diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 5eaa99c3f..358922730 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -20,461 +20,444 @@ {% endblock %} {% block content %} -
    -
    -
    -
    - Interface -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Device - {{ object.device }} -
    Name{{ object.name }}
    Label{{ object.label|placeholder }}
    Type{{ object.get_type_display }}
    Enabled{% checkmark object.enabled %}
    Management Only{% checkmark object.mgmt_only %}
    Parent - {% if object.parent %} - {{ object.parent }} - {% else %} - None - {% endif %} -
    Bridge - {% if object.bridge %} - {{ object.bridge }} - {% else %} - None - {% endif %} -
    LAG - {% if object.lag%} - {{ object.lag }} - {% else %} - None - {% endif %} -
    Description{{ object.description|placeholder }}
    MTU{{ object.mtu|placeholder }}
    MAC Address{{ object.mac_address|placeholder }}
    WWN{{ object.wwn|placeholder }}
    Transmit power (dBm){{ object.tx_power|placeholder }}
    802.1Q Mode{{ object.get_mode_display|placeholder }}
    -
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    +
    +
    Interface
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device{{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name{{ object.name }}
    Label{{ object.label|placeholder }}
    Type{{ object.get_type_display }}
    Speed/Duplex + {{ object.speed|humanize_speed|placeholder }} / + {{ object.get_duplex_display|placeholder }} +
    MTU{{ object.mtu|placeholder }}
    Enabled{% checkmark object.enabled %}
    Management Only{% checkmark object.mgmt_only %}
    Description{{ object.description|placeholder }}
    802.1Q Mode{{ object.get_mode_display|placeholder }}
    Transmit power (dBm){{ object.tx_power|placeholder }}
    -
    - {% if not object.is_virtual %} -
    -
    - Connection -
    -
    - {% if object.mark_connected %} -
    - Marked as Connected -
    - {% elif object.cable %} - - {% if object.connected_endpoint.device %} - - - - {% endif %} - - - - - {% if object.connected_endpoint.device %} - {% with iface=object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% endwith %} - {% elif object.connected_endpoint.circuit %} - {% with ct=object.connected_endpoint %} - - - - - - - - - - - - - {% endwith %} - {% endif %} - - - - -
    - {% if object.connected_endpoint.enabled %} - Enabled - {% else %} - Disabled - {% endif %} -
    Cable - {{ object.cable }} - - - -
    Device - {{ iface.device }} -
    Name - {{ iface.name }} -
    Type{{ iface.get_type_display }}
    LAG - {% if iface.lag%} - {{ iface.lag }} - {% else %} - None - {% endif %} -
    Description{{ iface.description|placeholder }}
    MTU{{ iface.mtu|placeholder }}
    MAC Address{{ iface.mac_address|placeholder }}
    802.1Q Mode{{ iface.get_mode_display }}
    Provider{{ ct.circuit.provider }}
    Circuit{{ ct.circuit }}
    Side{{ ct.term_side }}
    Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
    - {% elif object.wireless_link %} - - - - - - {% with peer_interface=object.connected_endpoint %} - - - - - - - - - - - - - {% endwith %} -
    Wireless Link - {{ object.wireless_link }} - - - -
    Device - {{ peer_interface.device }} -
    Name - {{ peer_interface }} -
    Type{{ peer_interface.get_type_display }}
    - {% else %} -
    - Not Connected - {% if object.is_wired and perms.dcim.add_cable %} - - {% elif object.is_wireless and perms.wireless.add_wirelesslink %} - - {% endif %} -
    - {% endif %} -
    -
    - {% endif %} - {% if object.is_wireless %} -
    -
    Wireless
    -
    - {% with peer=object.connected_endpoint %} - - - - - - {% if peer %} - - {% endif %} - - - - - - {% if peer %} - - {% endif %} - - - - - {% if peer %} - - {{ peer.get_rf_channel_display|placeholder }} - - {% endif %} - - - - - {% if peer %} - - {% if peer.rf_channel_frequency %} - {{ peer.rf_channel_frequency|simplify_decimal }} MHz - {% else %} - - {% endif %} - - {% endif %} - - - - - {% if peer %} - - {% if peer.rf_channel_width %} - {{ peer.rf_channel_width|simplify_decimal }} MHz - {% else %} - - {% endif %} - - {% endif %} - -
    LocalPeer
    Role{{ object.get_rf_role_display|placeholder }}{{ peer.get_rf_role_display|placeholder }}
    Channel{{ object.get_rf_channel_display|placeholder }}
    Channel Frequency - {% if object.rf_channel_frequency %} - {{ object.rf_channel_frequency|simplify_decimal }} MHz - {% else %} - - {% endif %} -
    Channel Width - {% if object.rf_channel_width %} - {{ object.rf_channel_width|simplify_decimal }} MHz - {% else %} - - {% endif %} -
    - {% endwith %} -
    -
    -
    -
    Wireless LANs
    -
    - - - - - - - - - {% for wlan in object.wireless_lans.all %} - - - - - {% empty %} - - - - {% endfor %} - -
    GroupSSID
    - {% if wlan.group %} - {{ wlan.group }} - {% else %} - — - {% endif %} - - {{ wlan.ssid }} -
    None
    -
    -
    - {% endif %} - {% if object.is_lag %} -
    -
    LAG Members
    -
    - - - - - - - - - - {% for member in object.member_interfaces.all %} - - - - - - {% empty %} - - - - {% endfor %} - -
    ParentInterfaceType
    - {{ member.device }} - - {{ member }} - - {{ member.get_type_display }} -
    No member interfaces
    -
    -
    - {% endif %} - {% include 'ipam/inc/panels/fhrp_groups.html' %} - {% plugin_right_page object %} +
    +
    +
    Related Interfaces
    +
    + + + + + + + + + + + + + +
    Parent{{ object.parent|linkify|placeholder }}
    Bridge{{ object.bridge|linkify|placeholder }}
    LAG{{ object.lag|linkify|placeholder }}
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    -
    -
    -
    - IP Addresses -
    -
    - {% if ipaddress_table.rows %} - {% render_table ipaddress_table 'inc/table.html' %} - {% else %} -
    None
    - {% endif %} -
    - {% if perms.ipam.add_ipaddress %} - +
    +
    +
    Addressing
    +
    + + + + + + + + + + + + + +
    MAC Address{{ object.mac_address|placeholder }}
    WWN{{ object.wwn|placeholder }}
    VRF{{ object.vrf|linkify|placeholder }}
    +
    +
    + {% if not object.is_virtual %} +
    +
    Connection
    +
    + {% if object.mark_connected %} +
    + Marked as Connected +
    + {% elif object.cable %} + + {% if object.connected_endpoint.device %} + + + {% endif %} - + + + + + {% if object.connected_endpoint.device %} + {% with iface=object.connected_endpoint %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endwith %} + {% elif object.connected_endpoint.circuit %} + {% with ct=object.connected_endpoint %} + + + + + + + + + + + + + {% endwith %} + {% endif %} + + + + +
    + {% if object.connected_endpoint.enabled %} + Enabled + {% else %} + Disabled + {% endif %} +
    Cable + {{ object.cable|linkify }} + + + +
    Device{{ iface.device|linkify }}
    Name{{ iface|linkify:"name" }}
    Type{{ iface.get_type_display }}
    LAG{{ iface.lag|linkify|placeholder }}
    Description{{ iface.description|placeholder }}
    MTU{{ iface.mtu|placeholder }}
    MAC Address{{ iface.mac_address|placeholder }}
    802.1Q Mode{{ iface.get_mode_display }}
    Provider{{ ct.circuit.provider|linkify }}
    Circuit{{ ct.circuit|linkify }}
    Side{{ ct.term_side }}
    Path Status + {% if object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    + {% elif object.wireless_link %} + + + + + + {% with peer_interface=object.connected_endpoint %} + + + + + + + + + + + + + {% endwith %} +
    Wireless Link + {{ object.wireless_link|linkify }} + + + +
    Device{{ peer_interface.device|linkify }}
    Name{{ peer_interface|linkify }}
    Type{{ peer_interface.get_type_display }}
    + {% else %} +
    + Not Connected + {% if object.is_wired and perms.dcim.add_cable %} + + {% elif object.is_wireless and perms.wireless.add_wirelesslink %} + + {% endif %} +
    + {% endif %} +
    + {% endif %} + {% if object.is_wireless %} +
    +
    Wireless
    +
    + {% with peer=object.connected_endpoint %} + + + + + + {% if peer %} + + {% endif %} + + + + + + {% if peer %} + + {% endif %} + + + + + {% if peer %} + + {{ peer.get_rf_channel_display|placeholder }} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_frequency %} + {{ peer.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_width %} + {{ peer.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + +
    LocalPeer
    Role{{ object.get_rf_role_display|placeholder }}{{ peer.get_rf_role_display|placeholder }}
    Channel{{ object.get_rf_channel_display|placeholder }}
    Channel Frequency + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} +
    Channel Width + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} +
    + {% endwith %} +
    +
    +
    +
    Wireless LANs
    +
    + + + + + + + + + {% for wlan in object.wireless_lans.all %} + + + + + {% empty %} + + + + {% endfor %} + +
    GroupSSID
    {{ wlan.group|linkify|placeholder }}{{ wlan|linkify:"ssid" }}
    None
    +
    +
    + {% endif %} + {% if object.is_lag %} +
    +
    LAG Members
    +
    + + + + + + + + + + {% for member in object.member_interfaces.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
    ParentInterfaceType
    {{ member.device|linkify }}{{ member|linkify }}{{ member.get_type_display }}
    No member interfaces
    +
    +
    + {% endif %} + {% include 'ipam/inc/panels/fhrp_groups.html' %} + {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
    +
    +
    +
    +
    +
    IP Addresses
    +
    + {% if ipaddress_table.rows %} + {% render_table ipaddress_table 'inc/table.html' %} + {% else %} +
    None
    + {% endif %} +
    + {% if perms.ipam.add_ipaddress %} + + {% endif %} +
    +
    +
    +
    +
    + {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    + {% if object.is_bridge %}
    -
    - {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} -
    +
    + {% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %} +
    -
    -
    - {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} -
    + {% endif %} +
    +
    + {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
    -
    -
    - {% plugin_full_width_page object %} -
    +
    +
    +
    + {% plugin_full_width_page object %}
    +
    {% endblock %} diff --git a/netbox/templates/dcim/interface_create.html b/netbox/templates/dcim/interface_create.html deleted file mode 100644 index 6b5486eff..000000000 --- a/netbox/templates/dcim/interface_create.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'generic/object_edit.html' %} - -{% block buttons %} - Cancel - {% if component_type == 'interface' and perms.ipam.add_ipaddress %} - - {% endif %} - - -{% endblock %} \ No newline at end of file diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index a5f686633..ddda1ae31 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -2,20 +2,28 @@ {% load form_helpers %} {% block form %} + {# Render hidden fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    Interface
    {% if form.instance.device %}
    - +
    {% endif %} + {% render_field form.module %} {% render_field form.name %} {% render_field form.type %} + {% render_field form.speed %} + {% render_field form.duplex %} {% render_field form.label %} {% render_field form.description %} {% render_field form.tags %} @@ -25,6 +33,7 @@
    Addressing
    + {% render_field form.vrf %} {% render_field form.mac_address %} {% render_field form.wwn %}
    @@ -85,8 +94,8 @@ {% block buttons %} Cancel - {% if obj.pk %} - + {% if object.pk %} + {% else %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 36ba0469f..b52a7457b 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -13,26 +13,16 @@
    -
    - Inventory Item -
    +
    Inventory Item
    - + - + @@ -42,15 +32,17 @@ + + + + + + + + - + diff --git a/netbox/templates/dcim/inventoryitem_bulk_delete.html b/netbox/templates/dcim/inventoryitem_bulk_delete.html index fc31537b3..6f5735c33 100644 --- a/netbox/templates/dcim/inventoryitem_bulk_delete.html +++ b/netbox/templates/dcim/inventoryitem_bulk_delete.html @@ -1,4 +1,4 @@ -{% extends 'generic/object_bulk_delete.html' %} +{% extends 'generic/bulk_delete.html' %} {% block message_extra %}

    This will also delete all child inventory items of those listed.

    diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html new file mode 100644 index 000000000..be910f143 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem_create.html @@ -0,0 +1,17 @@ +{% extends 'dcim/component_create.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% if object.component %} +
    + +
    + +
    +
    + {% endif %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitemrole.html b/netbox/templates/dcim/inventoryitemrole.html new file mode 100644 index 000000000..f750d74ce --- /dev/null +++ b/netbox/templates/dcim/inventoryitemrole.html @@ -0,0 +1,53 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    Inventory Item Role
    +
    +
    Device - {{ object.device }} - {{ object.device|linkify }}
    Parent Item - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - {{ object.parent|linkify|placeholder }}
    NameLabel {{ object.label|placeholder }}
    Role{{ object.role|linkify|placeholder }}
    Component{{ object.component|linkify|placeholder }}
    Manufacturer - {% if object.manufacturer %} - {{ object.manufacturer }} - {% else %} - - {% endif %} - {{ object.manufacturer|linkify|placeholder }}
    Part ID
    + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Inventory Items + {{ inventoryitem_count }} +
    +
    +
    + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/inventoryitemtemplate_create.html b/netbox/templates/dcim/inventoryitemtemplate_create.html new file mode 100644 index 000000000..9180cf6ab --- /dev/null +++ b/netbox/templates/dcim/inventoryitemtemplate_create.html @@ -0,0 +1,17 @@ +{% extends 'dcim/component_template_create.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% if object.component %} +
    + +
    + +
    +
    + {% endif %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index b684385a7..b2b2bc4cd 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -6,7 +6,7 @@ {% block breadcrumbs %} {{ block.super }} {% for location in object.get_ancestors %} - + {% endfor %} {% endblock %} @@ -37,30 +37,20 @@
    Site{{ object.site }}{{ object.site|linkify }}
    Parent - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - {{ object.parent|linkify|placeholder }}
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None - {% endif %} - Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
    Racks
    Module types + {{ module_type_count }} +
    Inventory Items diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html new file mode 100644 index 000000000..130cd046f --- /dev/null +++ b/netbox/templates/dcim/module.html @@ -0,0 +1,181 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load tz %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.change_module %} + + {% endif %} +{% endblock %} + +{% block content %} +
    +
    +
    +
    Module
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Device{{ object.device|linkify }}
    Device Type{{ object.device.device_type|linkify }}
    Module Type{{ object.module_type|linkify }}
    Serial Number{{ object.serial|placeholder }}
    Asset Tag{{ object.asset_tag|placeholder }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
    +
    +
    +
    Components
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Interfaces + {% with component_count=object.interfaces.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Console Ports + {% with component_count=object.consoleports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Console Server Ports + {% with component_count=object.consoleserverports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Power Ports + {% with component_count=object.powerports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Power Outlets + {% with component_count=object.poweroutlets.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Front Ports + {% with component_count=object.frontports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Rear Ports + {% with component_count=object.rearports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/modulebay.html b/netbox/templates/dcim/modulebay.html new file mode 100644 index 000000000..3ef539156 --- /dev/null +++ b/netbox/templates/dcim/modulebay.html @@ -0,0 +1,90 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    Module Bay
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ object.device }} +
    Name{{ object.name }}
    Label{{ object.label|placeholder }}
    Position{{ object.position|placeholder }}
    Description{{ object.description|placeholder }}
    +
    +
    + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} +
    +
    Installed Module
    +
    + {% if object.installed_module %} + {% with module=object.installed_module %} + + + + + + + + + + + + + + + + + + + + + +
    Module{{ module|linkify }}
    Manufacturer{{ module.module_type.manufacturer|linkify }}
    Module Type{{ module.module_type|linkify }}
    Serial Number{{ module.serial|placeholder }}
    Asset Tag{{ module.asset_tag|placeholder }}
    + {% endwith %} + {% else %} +
    None
    + {% endif %} +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/modulebaytemplate_create.html b/netbox/templates/dcim/modulebaytemplate_create.html new file mode 100644 index 000000000..74323ac4b --- /dev/null +++ b/netbox/templates/dcim/modulebaytemplate_create.html @@ -0,0 +1,7 @@ +{% extends 'dcim/component_template_create.html' %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% render_field replication_form.position_pattern %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html new file mode 100644 index 000000000..2c8e77be3 --- /dev/null +++ b/netbox/templates/dcim/moduletype.html @@ -0,0 +1,47 @@ +{% extends 'dcim/moduletype/base.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    Module Type
    +
    + + + + + + + + + + + + + + + + + +
    Manufacturer{{ object.manufacturer|linkify }}
    Model Name{{ object.model }}
    Part Number{{ object.part_number|placeholder }}
    Instances{{ instance_count }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html new file mode 100644 index 000000000..f5713efc3 --- /dev/null +++ b/netbox/templates/dcim/moduletype/base.html @@ -0,0 +1,102 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.change_devicetype %} + + {% endif %} +{% endblock %} + +{% block extra_tabs %} + {% with interface_count=object.interfacetemplates.count %} + {% if interface_count %} + + {% endif %} + {% endwith %} + + {% with frontport_count=object.frontporttemplates.count %} + {% if frontport_count %} + + {% endif %} + {% endwith %} + + {% with rearport_count=object.rearporttemplates.count %} + {% if rearport_count %} + + {% endif %} + {% endwith %} + + {% with consoleport_count=object.consoleporttemplates.count %} + {% if consoleport_count %} + + {% endif %} + {% endwith %} + + {% with consoleserverport_count=object.consoleserverporttemplates.count %} + {% if consoleserverport_count %} + + {% endif %} + {% endwith %} + + {% with powerport_count=object.powerporttemplates.count %} + {% if powerport_count %} + + {% endif %} + {% endwith %} + + {% with poweroutlet_count=object.poweroutlettemplates.count %} + {% if poweroutlet_count %} + + {% endif %} + {% endwith %} +{% endblock %} diff --git a/netbox/templates/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html new file mode 100644 index 000000000..d2806330a --- /dev/null +++ b/netbox/templates/dcim/moduletype/component_templates.html @@ -0,0 +1,44 @@ +{% extends 'dcim/moduletype/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} + +{% block content %} + {% if perms.dcim.change_moduletype %} + + {% csrf_token %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    + +
    + + {% else %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    +
    + {% endif %} +{% endblock content %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index d5642fd94..8d1e3797a 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -37,13 +37,7 @@
    Manufacturer - {% if object.manufacturer %} - {{ object.manufacturer }} - {% else %} - None - {% endif %} - {{ object.manufacturer|linkify|placeholder }}
    NAPALM Driver
    - + - + - + - + - + - + diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 3f2c469af..6408bc759 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -20,9 +20,11 @@
    Power Panel - {{ object.power_panel }} - {{ object.power_panel|linkify }}
    Rack - {% if object.rack %} - {{ object.rack }} - {% else %} - None - {% endif %} - {{ object.rack|linkify|placeholder }}
    Type - {{ object.get_type_display }} - {% badge object.get_type_display bg_color=object.get_type_color %}
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Connected Device {% if object.connected_endpoint %} - {{ object.connected_endpoint.device }} ({{ object.connected_endpoint }}) + {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) {% else %} None {% endif %} @@ -126,7 +114,7 @@
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -135,15 +123,11 @@ {% if object.connected_endpoint %}
    Device - {{ object.connected_endpoint.device }} - {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type
    - + + + + + @@ -70,7 +72,7 @@ - + - + @@ -121,6 +119,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 02db0e56f..b7fe8eb39 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -7,7 +7,7 @@ {{ block.super }} {% if object.location %} - + {% endif %} {% endblock %} @@ -22,19 +22,11 @@
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -79,15 +81,11 @@ {% if object.connected_endpoint %}
    Device - {{ object.connected_endpoint.device }} - {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type
    - + - +
    Site - {{ object.site }} - {{ object.site|linkify }}
    Location - {% if object.location %} - {{ object.location }} - {% else %} - None - {% endif %} - {{ object.location|linkify|placeholder }}
    diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index f38edec8e..6dc06f528 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -20,9 +20,11 @@ - + + + + + @@ -70,7 +72,7 @@ - + - + @@ -131,6 +129,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 93bd21fd9..6574e9b74 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -39,9 +39,9 @@ @@ -49,9 +49,9 @@ - + - + @@ -181,18 +169,10 @@ {% for powerfeed in power_feeds %} - - - + + + + {% with power_port=powerfeed.connected_endpoint %} {% if power_port %} @@ -222,16 +202,8 @@ {% for resv in reservations %} - - + + - + - + {% endwith %}
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -79,15 +81,11 @@ {% if object.connected_endpoint %}
    Device - {{ object.connected_endpoint.device }} - {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type Site {% if object.site.region %} - {{ object.site.region }} / + {{ object.site.region|linkify }} / {% endif %} - {{ object.site }} + {{ object.site|linkify }}
    {% if object.location %} {% for location in object.location.get_ancestors %} - {{ location }} / + {{ location|linkify }} / {% endfor %} - {{ object.location }} + {{ object.location|linkify }} {% else %} None {% endif %} @@ -64,31 +64,19 @@
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Role - {% if object.role %} - {{ object.role }} - {% else %} - None - {% endif %} - {{ object.role|linkify|placeholder }}
    Serial Number
    - {{ powerfeed.power_panel.name }} - - - {{ powerfeed.name }} - - {{ powerfeed.get_status_display }} - - {{ powerfeed.get_type_display }} - {{ powerfeed.power_panel|linkify }}{{ powerfeed|linkify }}{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}
    - {{ resv.unit_list }} - - {% if resv.tenant %} - {{ resv.tenant }} - {% else %} - None - {% endif %} - {{ resv|linkify:"unit_list" }}{{ resv.tenant|linkify|placeholder }} {{ resv.description }}
    {{ resv.user }} · {{ resv.created|annotated_date }} @@ -288,50 +260,7 @@ -
    -
    - Non-Racked Devices -
    -
    - {% if nonracked_devices %} - - - - - - - - {% for device in nonracked_devices %} - - - - - {% if device.parent_bay %} - - - {% else %} - - {% endif %} - - {% endfor %} -
    NameRoleTypeParent Device
    - {{ device }} - {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device }}{{ device.parent_bay }}
    - {% else %} -
    - None -
    - {% endif %} -
    - {% if perms.dcim.add_device %} - - {% endif %} -
    + {% include 'dcim/inc/nonracked_devices.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 1e16af675..ebdd1d845 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -24,26 +24,18 @@
    Site {% if rack.site.region %} - {{ rack.site.region }} / + {{ rack.site.region|linkify }} / {% endif %} - {{ rack.site }} + {{ rack.site|linkify }}
    Location - {% if rack.location %} - {{ rack.location }} - {% else %} - None - {% endif %} - {{ rack.location|linkify|placeholder }}
    Rack - {{ rack }} - {{ rack|linkify }}
    @@ -62,14 +54,10 @@ Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 311ccd7ff..3cdccbd7c 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -20,9 +20,11 @@ - + + + + + @@ -72,7 +74,7 @@ - + diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index f71105d1b..c15cab468 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -30,9 +30,9 @@ - + @@ -78,10 +72,6 @@ - - - - - {# Legacy contact fields #} - {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %} - {% if object.contact_name %} - - - - - {% endif %} - {% if object.contact_phone %} - - - - - {% endif %} - {% if object.contact_email %} - - - - - {% endif %} - {% endwith %}
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -117,6 +119,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 10ecb9ea6..8a548dd4a 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -37,13 +37,7 @@
    Parent - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - {{ object.parent|linkify|placeholder }}
    Sites {% if object.region %} {% for region in object.region.get_ancestors %} - {{ region }} / + {{ region|linkify }} / {% endfor %} - {{ object.region }} + {{ object.region|linkify }} {% else %} None {% endif %} @@ -43,9 +43,9 @@ {% if object.group %} {% for group in object.group.get_ancestors %} - {{ group }} / + {{ group|linkify }} / {% endfor %} - {{ object.group }} + {{ object.group|linkify }} {% else %} None {% endif %} @@ -53,21 +53,15 @@
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    Description {{ object.description|placeholder }}
    AS Number{{ object.asn|placeholder }}
    Time Zone @@ -127,52 +117,6 @@ {% endif %}
    Contact Name - {% if object.contact_name %} -
    - -
    - {% endif %} - {{ object.contact_name|placeholder }} -
    Contact Phone - {% if object.contact_phone %} -
    - -
    - {{ object.contact_phone }} - {% else %} - - {% endif %} -
    Contact E-Mail - {% if object.contact_email %} -
    - -
    - {{ object.contact_email }} - {% else %} - - {% endif %} -
    @@ -244,6 +188,16 @@ {% endif %} + + VLAN Groups + + {% if stats.vlangroup_count %} + {{ stats.vlangroup_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + VLANs @@ -277,6 +231,7 @@ + {% include 'dcim/inc/nonracked_devices.html' %} {% include 'inc/panels/contacts.html' %}
    Locations
    @@ -293,7 +248,7 @@ {% for i in location.level|as_range %}{% endfor %} - {{ location }} + {{ location|linkify }} {{ location.rack_count }} @@ -332,7 +287,7 @@ {% for asn in asns %} - {{ asn }} + {{ asn|linkify }} {{ asn.description|placeholder }} {% endfor %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 49f91b812..396271147 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -37,13 +37,7 @@ Parent - - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - + {{ object.parent|linkify|placeholder }} Sites diff --git a/netbox/templates/dcim/trace/cable.html b/netbox/templates/dcim/trace/cable.html index 5f8fb01eb..0289e1091 100644 --- a/netbox/templates/dcim/trace/cable.html +++ b/netbox/templates/dcim/trace/cable.html @@ -12,7 +12,7 @@ {% if cable.length %} ({{ cable.length|floatformat }} {{ cable.get_length_unit_display }})
    {% endif %} - {{ cable.get_status_display }}
    + {% badge object.get_status_display bg_color=object.get_status_color %}
    {% for tag in cable.tags.all %} {% tag tag 'dcim:cable_list' %} {% endfor %} diff --git a/netbox/templates/dcim/trace/circuit.html b/netbox/templates/dcim/trace/circuit.html index 70e191dd2..330099235 100644 --- a/netbox/templates/dcim/trace/circuit.html +++ b/netbox/templates/dcim/trace/circuit.html @@ -1,5 +1,5 @@
    - {{ circuit }}
    + {{ circuit|linkify }}
    Circuit
    - {{ circuit.provider }} + {{ circuit.provider|linkify }}
    diff --git a/netbox/templates/dcim/trace/device.html b/netbox/templates/dcim/trace/device.html index b1d37b76c..30d1d7149 100644 --- a/netbox/templates/dcim/trace/device.html +++ b/netbox/templates/dcim/trace/device.html @@ -1,11 +1,11 @@
    - {{ device }}
    + {{ device|linkify }}
    {{ device.device_type.manufacturer }} {{ device.device_type }}
    - {{ device.site }} + {{ device.site|linkify }} {% if device.location %} - / {{ device.location }} + / {{ device.location|linkify }} {% endif %} {% if device.rack %} - / {{ device.rack }} + / {{ device.rack|linkify }} {% endif %}
    diff --git a/netbox/templates/dcim/trace/object.html b/netbox/templates/dcim/trace/object.html index 72e5b5787..fe9fe002b 100644 --- a/netbox/templates/dcim/trace/object.html +++ b/netbox/templates/dcim/trace/object.html @@ -1,3 +1,3 @@
    - {{ object }} + {{ object|linkify }}
    diff --git a/netbox/templates/dcim/trace/powerpanel.html b/netbox/templates/dcim/trace/powerpanel.html index f5b6230a7..d89b408fb 100644 --- a/netbox/templates/dcim/trace/powerpanel.html +++ b/netbox/templates/dcim/trace/powerpanel.html @@ -1,5 +1,5 @@
    - {{ powerpanel }}
    + {{ powerpanel|linkify }}
    Power Panel
    - {{ powerpanel.site }} + {{ powerpanel.site|linkify }}
    diff --git a/netbox/templates/dcim/trace/termination.html b/netbox/templates/dcim/trace/termination.html index f2dbca87f..3a2dbded2 100644 --- a/netbox/templates/dcim/trace/termination.html +++ b/netbox/templates/dcim/trace/termination.html @@ -1,6 +1,6 @@ {% load helpers %}
    - {{ termination }}
    + {{ termination|linkify }}
    {{ termination|meta:"verbose_name"|bettertitle }} {% if termination.type %} {{ termination.get_type_display }} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 2e1344f71..4683b775b 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -27,13 +27,7 @@ Master - - {% if object.master %} - {{ object.master }} - {% else %} - - {% endif %} - + {{ object.master|linkify }}
    @@ -58,7 +52,7 @@ {% for vc_member in members %} - {{ vc_member }} + {{ vc_member|linkify }} {% badge vc_member.vc_position show_empty=True %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index a37ff102a..327f20531 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -51,9 +51,7 @@ {% endfor %} {% with device=form.instance virtual_chassis=vc_form.instance %} - - {{ device }} - + {{ device|linkify }} {{ device.pk }} {% if device.rack %} diff --git a/netbox/templates/exceptions/import_error.html b/netbox/templates/exceptions/import_error.html index e8ee7ae2c..3e25b5369 100644 --- a/netbox/templates/exceptions/import_error.html +++ b/netbox/templates/exceptions/import_error.html @@ -1,19 +1,19 @@ {% extends '500.html' %} {% block message %} -

    - A module import error occurred during this request. Common causes include the following: -

    -

    - Missing required packages - This installation of NetBox might be - missing one or more required Python packages. These packages are listed in requirements.txt and - local_requirements.txt, and are normally installed as part of the installation or upgrade process. - To verify installed packages, run pip freeze from the console and compare the output to the list of - required packages. -

    -

    - WSGI service not restarted after upgrade - If this installation - has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This - ensures that the new code is running. -

    +

    + A module import error occurred during this request. Common causes include the following: +

    +

    + Missing required packages - This installation of NetBox might be + missing one or more required Python packages. These packages are listed in requirements.txt and + local_requirements.txt, and are normally installed as part of the installation or upgrade process. To + verify installed packages, run pip freeze from the console and compare the output to the list of + required packages. +

    +

    + WSGI service not restarted after upgrade - If this installation has + recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that + the new code is running. +

    {% endblock message %} diff --git a/netbox/templates/exceptions/permission_error.html b/netbox/templates/exceptions/permission_error.html index dcff62bf9..6108fde52 100644 --- a/netbox/templates/exceptions/permission_error.html +++ b/netbox/templates/exceptions/permission_error.html @@ -1,12 +1,12 @@ {% extends '500.html' %} {% block message %} -

    - A file permission error was detected while processing this request. Common causes include the following: -

    -

    - Insufficient write permission to the media root - The configured - media root is {{ settings.MEDIA_ROOT }}. Ensure that the user NetBox runs as has access to write - files to all locations within this path. -

    +

    + A file permission error was detected while processing this request. Common causes include the following: +

    +

    + Insufficient write permission to the media root - The configured + media root is {{ settings.MEDIA_ROOT }}. Ensure that the user NetBox runs as has access to write files + to all locations within this path. +

    {% endblock message %} diff --git a/netbox/templates/exceptions/programming_error.html b/netbox/templates/exceptions/programming_error.html index 3b9e84567..38175b187 100644 --- a/netbox/templates/exceptions/programming_error.html +++ b/netbox/templates/exceptions/programming_error.html @@ -1,17 +1,17 @@ {% extends '500.html' %} {% block message %} -

    - A database programming error was detected while processing this request. Common causes include the following: -

    -

    - Database migrations missing - When upgrading to a new NetBox release, the upgrade script must - be run to apply any new database migrations. You can run migrations manually by executing - python3 manage.py migrate from the command line. -

    -

    - Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.6 or higher is in use. You - can check this by connecting to the database using NetBox's credentials and issuing a query for - SELECT VERSION(). -

    +

    + A database programming error was detected while processing this request. Common causes include the following: +

    +

    + Database migrations missing - When upgrading to a new NetBox release, + the upgrade script must be run to apply any new database migrations. You can run migrations manually by executing + python3 manage.py migrate from the command line. +

    +

    + Unsupported PostgreSQL version - Ensure that PostgreSQL version 10 + or later is in use. You can check this by connecting to the database using NetBox's credentials and issuing a query + for SELECT VERSION(). +

    {% endblock message %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 3a9cc2e0c..56ec52c07 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -56,7 +56,7 @@