Merge branch 'feature' into issue_8233

This commit is contained in:
PieterL75 2022-06-17 16:31:13 +02:00 committed by GitHub
commit 536239d272
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
162 changed files with 2096 additions and 596 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.2.1 placeholder: v3.2.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.2.1 placeholder: v3.2.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -8,7 +8,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v4 - uses: actions/stale@v5
with: with:
close-issue-message: > close-issue-message: >
This issue has been automatically closed due to lack of activity. In an This issue has been automatically closed due to lack of activity. In an
@ -27,7 +27,10 @@ jobs:
This issue has been automatically marked as stale because it has not had This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. NetBox recent activity. It will be closed if no further activity occurs. NetBox
is governed by a small group of core maintainers which means not all opened is governed by a small group of core maintainers which means not all opened
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). issues may receive direct feedback. **Do not** attempt to circumvent this
process by "bumping" the issue; doing so will result in its immediate closure
and you may be barred from participating in any future discussions. Please see
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
stale-pr-label: 'pending closure' stale-pr-label: 'pending closure'
stale-pr-message: > stale-pr-message: >
This PR has been automatically marked as stale because it has not had This PR has been automatically marked as stale because it has not had

View File

@ -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/).

View File

@ -99,7 +99,7 @@ appropriate labels will be applied for categorization.
## Submitting Pull Requests ## Submitting Pull Requests
* If you're interested in contributing to NetBox, be sure to check out our * 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. documentation for tips on setting up your development environment.
* Be sure to open an issue **before** starting work on a pull request, and * Be sure to open an issue **before** starting work on a pull request, and
@ -160,9 +160,9 @@ to aid in issue management.
It is natural that some new issues get more attention than others. The stale It is natural that some new issues get more attention than others. The stale
bot helps bring renewed attention to potentially valuable issues that may have bot helps bring renewed attention to potentially valuable issues that may have
been overlooked. **Do not** comment on an issue that has been marked stale in been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
an effort to circumvent the bot: Doing so will not remove the stale label. effort to circumvent the bot: This will result in the immediate closure of the
(Stale labels can be removed only by maintainers.) issue, and you may be barred from participating in future discussions.
## Maintainer Guidance ## Maintainer Guidance
@ -171,7 +171,7 @@ an effort to circumvent the bot: Doing so will not remove the stale label.
the understanding that all contributions are submitted under the Apache 2.0 the understanding that all contributions are submitted under the Apache 2.0
license and that your employer may not make claim to any contributions. license and that your employer may not make claim to any contributions.
Contributions include code work, issue management, and community support. All 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 * Maintainers are expected to attend (where feasible) our biweekly ~30-minute
sync to review agenda items. This meeting provides opportunity to present and sync to review agenda items. This meeting provides opportunity to present and

View File

@ -49,7 +49,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a 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). 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.
<div align="center"> <div align="center">
<h4>Thank you to our sponsors!</h4> <h4>Thank you to our sponsors!</h4>
@ -60,6 +60,8 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/) [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
<br /> <br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/) [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
</div> </div>
@ -71,7 +73,7 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
### Installation ### 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 instructions on installing NetBox. To upgrade NetBox, please download the
[latest release](https://github.com/netbox-community/netbox/releases) and [latest release](https://github.com/netbox-community/netbox/releases) and
run `upgrade.sh`. run `upgrade.sh`.

31
SECURITY.md Normal file
View File

@ -0,0 +1,31 @@
# Security Policy
## No Warranty
Per the terms of the Apache 2 license, NetBox is offered "as is" and without any guarantee or warranty pertaining to its operation. While every reasonable effort is made by its maintainers to ensure the product remains free of security vulnerabilities, users are ultimately responsible for conducting their own evaluations of each software release.
## Recommendations
Administrators are encouraged to adhere to industry best practices concerning the secure operation of software, such as:
* Do not expose your NetBox installation to the public Internet
* Do not permit multiple users to share an account
* Enforce minimum password complexity requirements for local accounts
* Prohibit access to your database from clients other than the NetBox application
* Keep your deployment updated to the most recent stable release
## Reporting a Suspected Vulnerability
If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions:
* Affects the most recent stable release of NetBox, or a current beta release
* Affects a NetBox instance installed and configured per the official documentation
* Is reproducible following a prescribed set of instructions
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
### Bug Bounties
As NetBox is provided as free open source software, we do not offer any monetary compensation for vulnerability or bug reports, however your contributions are greatly appreciated.

View File

@ -30,10 +30,14 @@ django-pglocks
# https://github.com/korfuri/django-prometheus # https://github.com/korfuri/django-prometheus
django-prometheus django-prometheus
# Django chaching backend using Redis # Django caching backend using Redis
# https://github.com/jazzband/django-redis # https://github.com/jazzband/django-redis
django-redis django-redis
# Django extensions for Rich (terminal text rendering)
# https://github.com/adamchainz/django-rich
django-rich
# Django integration for RQ (Reqis queuing) # Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq # https://github.com/rq/django-rq
django-rq django-rq
@ -68,8 +72,7 @@ gunicorn
# Platform-agnostic template rendering engine # Platform-agnostic template rendering engine
# https://github.com/pallets/jinja # https://github.com/pallets/jinja
# Pin to v3.0 for mkdocstrings Jinja2
Jinja2<3.1
# Simple markup language for rendering HTML # Simple markup language for rendering HTML
# https://github.com/Python-Markdown/markdown # https://github.com/Python-Markdown/markdown
@ -85,7 +88,7 @@ mkdocs-material
# Introspection for embedded code # Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings # https://github.com/mkdocstrings/mkdocstrings
mkdocstrings<=0.17.0 mkdocstrings[python-legacy]
# Library for manipulating IP prefixes and addresses # Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr # https://github.com/netaddr/netaddr
@ -103,6 +106,10 @@ psycopg2-binary
# https://github.com/yaml/pyyaml # https://github.com/yaml/pyyaml
PyYAML PyYAML
# Sentry SDK
# https://github.com/getsentry/sentry-python
sentry-sdk
# Social authentication framework # Social authentication framework
# https://github.com/python-social-auth/social-core # https://github.com/python-social-auth/social-core
social-auth-core social-auth-core

View File

@ -1,6 +1,6 @@
[Unit] [Unit]
Description=NetBox Request Queue Worker Description=NetBox Request Queue Worker
Documentation=https://netbox.readthedocs.io/en/stable/ Documentation=https://docs.netbox.dev/
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target

View File

@ -1,6 +1,6 @@
[Unit] [Unit]
Description=NetBox WSGI Service Description=NetBox WSGI Service
Documentation=https://netbox.readthedocs.io/en/stable/ Documentation=https://docs.netbox.dev/
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target

View File

@ -75,5 +75,14 @@ If successful, you will be redirected back to the NetBox UI, and will be logged
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI. This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI.
!!! note "Troubleshooting" ## Troubleshooting
If you are redirected to the NetBox UI after authenticating, but are _not_ logged in, double-check the configured backend and app registration. The instructions in this guide pertain only to the `azuread.AzureADOAuth2` backend using a single-tenant app registration.
### 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.

View File

@ -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 <https://developer.okta.com/>.
### 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.

View File

@ -0,0 +1,46 @@
# Error Reporting
## Sentry
### Enabling Error Reporting
NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
```python
SENTRY_ENABLED = True
```
### Using a Custom DSN
If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
```
https://examplePublicKey@o0.ingest.sentry.io/0
```
Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
```python
SENTRY_ENABLED = True
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
```
### Assigning Tags
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
```python
SENTRY_TAGS = {
"custom.foo": "123",
"custom.bar": "abc",
}
```
!!! warning "Reserved tag prefixes"
Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
### Testing
Once the configuration has been saved, restart the NetBox service.
To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. (Be sure that debug mode has been disabled.) After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry.

View File

@ -0,0 +1,54 @@
# Error Reporting Settings
## SENTRY_DSN
Default: None
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
```
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
```
---
## SENTRY_ENABLED
Default: False
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
---
## SENTRY_SAMPLE_RATE
Default: 1.0 (all)
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
---
## SENTRY_TAGS
An optional dictionary of tag names and values to apply to Sentry error reports.For example:
```
SENTRY_TAGS = {
"custom.foo": "123",
"custom.bar": "abc",
}
```
!!! warning "Reserved tag prefixes"
Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
---
## SENTRY_TRACES_SAMPLE_RATE
Default: 0 (disabled)
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
!!! warning "Consider performance implications"
A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2).

View File

@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [
--- ---
## CSRF_COOKIE_NAME
Default: `csrftoken`
The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail.
---
## CSRF_TRUSTED_ORIGINS ## CSRF_TRUSTED_ORIGINS
Default: `[]` Default: `[]`

View File

@ -105,11 +105,11 @@ from my_validators import Validator1, Validator2, Validator3
CUSTOM_VALIDATORS = { CUSTOM_VALIDATORS = {
'dcim.site': ( 'dcim.site': (
Validator1, Validator1(),
Validator2, Validator2(),
), ),
'dcim.device': ( 'dcim.device': (
Validator3, Validator3(),
) )
} }
``` ```

View File

@ -40,7 +40,7 @@ You should see output similar to the following:
● netbox.service - NetBox WSGI Service ● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) 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 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) Main PID: 1140492 (gunicorn)
Tasks: 19 (limit: 4683) Tasks: 19 (limit: 4683)
Memory: 666.2M Memory: 666.2M

View File

@ -39,7 +39,7 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
● netbox.service - NetBox WSGI Service ● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) 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 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) Main PID: 11993 (gunicorn)
Tasks: 6 (limit: 2362) Tasks: 6 (limit: 2362)
CGroup: /system.slice/netbox.service CGroup: /system.slice/netbox.service

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -1,5 +1,5 @@
# Clusters # Clusters
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.

View File

@ -1,6 +1,6 @@
# Virtual Machines # Virtual Machines
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster. A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster.
Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:

View File

@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl
::: netbox.tables.TemplateColumn ::: netbox.tables.TemplateColumn
selection: selection:
members: false members:
- __init__

View File

@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes/<pk>/available-ips/`. A
#### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348)) #### 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 ### Enhancements

View File

@ -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)) #### 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 ### Enhancements

View File

@ -295,7 +295,7 @@ This release upgrades the Django framework to version 2.2.
#### Python 3 Required #### 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 #### Removed Deprecated User Activity Log

View File

@ -218,7 +218,7 @@
#### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415)) #### 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. Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release.

View File

@ -67,7 +67,7 @@
## v2.7.9 (2020-03-06) ## 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 ### 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)) #### 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 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. install supervisor and simplifies administration of the processes.

View File

@ -235,14 +235,14 @@ This release introduces support for custom plugins, which can be used to extend
* Introduce new API endpoints * Introduce new API endpoints
* Add custom request/response middleware * 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 ### Enhancements
* [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups * [#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 * [#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 * [#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 ### Bug Fixes

View File

@ -1,10 +1,100 @@
# NetBox v3.2 # NetBox v3.2
## v3.2.2 (FUTURE) ## v3.2.5 (FUTURE)
### Enhancements
* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes
* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view
### Bug Fixes ### Bug Fixes
* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers
* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view
* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view
* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column
---
## v3.2.4 (2022-05-31)
### Enhancements
* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated
* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view
* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports
* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment
* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter
* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search
* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device
* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn
### Bug Fixes
* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters
* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields
* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view
* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed
* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis
* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list
* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance
* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
---
## v3.2.3 (2022-05-12)
### Enhancements
* [#8805](https://github.com/netbox-community/netbox/issues/8805) - Add "mixed" option for device airflow indication
* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users
* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group
* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade
* [#9221](https://github.com/netbox-community/netbox/issues/9221) - Add definition list support for Markdown
* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views
* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list
* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module
* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments
* [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry
* [#9343](https://github.com/netbox-community/netbox/issues/9343) - Add Ubiquiti SmartPower power outlet type
### Bug Fixes
* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device
* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices
* [#9296](https://github.com/netbox-community/netbox/issues/9296) - Improve Markdown link sanitization
* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface
* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API
* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships
* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates
---
## v3.2.2 (2022-04-28)
### Enhancements
* [#9060](https://github.com/netbox-community/netbox/issues/9060) - Add device type filters for device bays, module bays, and inventory items
* [#9152](https://github.com/netbox-community/netbox/issues/9152) - Annotate related object type under custom field view
* [#9192](https://github.com/netbox-community/netbox/issues/9192) - Add Ubiquiti SmartPower connector type
* [#9214](https://github.com/netbox-community/netbox/issues/9214) - Linkify cluster counts in cluster type & group tables
### Bug Fixes
* [#4264](https://github.com/netbox-community/netbox/issues/4264) - Treat 0th IP as unusable for IPv6 prefixes (excluding /127s)
* [#8941](https://github.com/netbox-community/netbox/issues/8941) - Fix dynamic dropdown behavior when browser is zoomed
* [#8959](https://github.com/netbox-community/netbox/issues/8959) - Prevent exception when refreshing scripts list (avoid race condition)
* [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link
* [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later * [#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
--- ---

View File

@ -2,13 +2,36 @@
## v3.3.0 (FUTURE) ## v3.3.0 (FUTURE)
### Breaking Changes
* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
### Enhancements ### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP * [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP
* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
### Other Changes
* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset
* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output
### REST API Changes ### REST API Changes
* extras.CustomField * extras.CustomField
* Added `group_name` field * Added `group_name` and `ui_visibility` fields
* ipam.IPAddress
* The `nat_inside` field no longer requires a unique value
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
* virtualization.Cluster
* Added required `status` field (default value: `active`)
* virtualization.VirtualMachine
* Added `device` field
* The `site` field is now directly writable (rather than being inferred from the assigned cluster)
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.

View File

@ -1,6 +1,6 @@
site_name: NetBox Documentation site_name: NetBox Documentation
site_dir: netbox/project-static/docs site_dir: netbox/project-static/docs
site_url: https://netbox.readthedocs.io/ site_url: https://docs.netbox.dev/
repo_name: netbox-community/netbox repo_name: netbox-community/netbox
repo_url: https://github.com/netbox-community/netbox repo_url: https://github.com/netbox-community/netbox
theme: theme:
@ -73,6 +73,7 @@ nav:
- Required Settings: 'configuration/required-settings.md' - Required Settings: 'configuration/required-settings.md'
- Optional Settings: 'configuration/optional-settings.md' - Optional Settings: 'configuration/optional-settings.md'
- Dynamic Settings: 'configuration/dynamic-settings.md' - Dynamic Settings: 'configuration/dynamic-settings.md'
- Error Reporting: 'configuration/error-reporting.md'
- Remote Authentication: 'configuration/remote-authentication.md' - Remote Authentication: 'configuration/remote-authentication.md'
- Core Functionality: - Core Functionality:
- IP Address Management: 'core-functionality/ipam.md' - IP Address Management: 'core-functionality/ipam.md'
@ -121,7 +122,9 @@ nav:
- Authentication: - Authentication:
- Overview: 'administration/authentication/overview.md' - Overview: 'administration/authentication/overview.md'
- Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md' - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md'
- Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md' - Permissions: 'administration/permissions.md'
- Error Reporting: 'administration/error-reporting.md'
- Housekeeping: 'administration/housekeeping.md' - Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md' - NetBox Shell: 'administration/netbox-shell.md'

View File

@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)), ('ASN', ('asn',)),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
('Attributes', ('type_id', 'status', 'commit_rate')), ('Attributes', ('type_id', 'status', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
type_id = DynamicModelMultipleChoiceField( type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),

View File

@ -59,7 +59,7 @@ class CircuitTable(NetBoxTable):
) )
commit_rate = CommitRateColumn() commit_rate = CommitRateColumn()
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
asns = tables.ManyToManyColumn( asns = columns.ManyToManyColumn(
linkify_item=True, linkify_item=True,
verbose_name='ASNs' verbose_name='ASNs'
) )
@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable):
verbose_name='Circuits' verbose_name='Circuits'
) )
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -32,7 +32,7 @@ class ProviderView(generic.ObjectView):
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request) circuits_table.configure(request)
return { return {
@ -93,7 +93,7 @@ class ProviderNetworkView(generic.ObjectView):
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits) circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request) circuits_table.configure(request)
return { return {
@ -147,7 +147,7 @@ class CircuitTypeView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
circuits_table = tables.CircuitTable(circuits, exclude=('type',)) circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',))
circuits_table.configure(request) circuits_table.configure(request)
return { return {

View File

@ -315,7 +315,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
class ConsolePortTemplateSerializer(ValidatedModelSerializer): class ConsolePortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
allow_blank=True, allow_blank=True,
@ -325,13 +334,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = ConsolePortTemplate model = ConsolePortTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
] ]
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
allow_blank=True, allow_blank=True,
@ -341,13 +360,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
] ]
class PowerPortTemplateSerializer(ValidatedModelSerializer): class PowerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField( type = ChoiceField(
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
allow_blank=True, allow_blank=True,
@ -357,14 +386,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = PowerPortTemplate model = PowerPortTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
'description', 'created', 'last_updated', 'allocated_draw', 'description', 'created', 'last_updated',
] ]
class PowerOutletTemplateSerializer(ValidatedModelSerializer): class PowerOutletTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField( type = ChoiceField(
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
allow_blank=True, allow_blank=True,
@ -383,48 +421,75 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = PowerOutletTemplate model = PowerOutletTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
'created', 'last_updated', 'description', 'created', 'last_updated',
] ]
class InterfaceTemplateSerializer(ValidatedModelSerializer): class InterfaceTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices) type = ChoiceField(choices=InterfaceTypeChoices)
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'last_updated', 'created', 'last_updated',
] ]
class RearPortTemplateSerializer(ValidatedModelSerializer): class RearPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
class Meta: class Meta:
model = RearPortTemplate model = RearPortTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'created', 'last_updated', 'description', 'created', 'last_updated',
] ]
class FrontPortTemplateSerializer(ValidatedModelSerializer): class FrontPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
rear_port = NestedRearPortTemplateSerializer() rear_port = NestedRearPortTemplateSerializer()
class Meta: class Meta:
model = FrontPortTemplate model = FrontPortTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
'description', 'created', 'last_updated', 'rear_port_position', 'description', 'created', 'last_updated',
] ]

View File

@ -159,6 +159,7 @@ class DeviceAirflowChoices(ChoiceSet):
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left' AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
AIRFLOW_SIDE_TO_REAR = 'side-to-rear' AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
AIRFLOW_PASSIVE = 'passive' AIRFLOW_PASSIVE = 'passive'
AIRFLOW_MIXED = 'mixed'
CHOICES = ( CHOICES = (
(AIRFLOW_FRONT_TO_REAR, 'Front to rear'), (AIRFLOW_FRONT_TO_REAR, 'Front to rear'),
@ -167,6 +168,7 @@ class DeviceAirflowChoices(ChoiceSet):
(AIRFLOW_RIGHT_TO_LEFT, 'Right to left'), (AIRFLOW_RIGHT_TO_LEFT, 'Right to left'),
(AIRFLOW_SIDE_TO_REAR, 'Side to rear'), (AIRFLOW_SIDE_TO_REAR, 'Side to rear'),
(AIRFLOW_PASSIVE, 'Passive'), (AIRFLOW_PASSIVE, 'Passive'),
(AIRFLOW_MIXED, 'Mixed'),
) )
@ -349,8 +351,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32' TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32'
TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other # Other
TYPE_HARDWIRED = 'hardwired' TYPE_HARDWIRED = 'hardwired'
TYPE_OTHER = 'other'
CHOICES = ( CHOICES = (
('IEC 60320', ( ('IEC 60320', (
@ -464,9 +468,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
(TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)), )),
('Other', ( ('Other', (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'),
)), )),
) )
@ -573,8 +579,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a' TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a'
TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other # Other
TYPE_HARDWIRED = 'hardwired' TYPE_HARDWIRED = 'hardwired'
TYPE_OTHER = 'other'
CHOICES = ( CHOICES = (
('IEC 60320', ( ('IEC 60320', (
@ -681,9 +689,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
(TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)), )),
('Other', ( ('Other', (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'),
)), )),
) )
@ -1041,6 +1051,7 @@ class PortTypeChoices(ChoiceSet):
TYPE_URM_P2 = 'urm-p2' TYPE_URM_P2 = 'urm-p2'
TYPE_URM_P4 = 'urm-p4' TYPE_URM_P4 = 'urm-p4'
TYPE_URM_P8 = 'urm-p8' TYPE_URM_P8 = 'urm-p8'
TYPE_OTHER = 'other'
CHOICES = ( CHOICES = (
( (
@ -1093,6 +1104,12 @@ class PortTypeChoices(ChoiceSet):
(TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P4, 'URM-P4'),
(TYPE_URM_P8, 'URM-P8'), (TYPE_URM_P8, 'URM-P8'),
(TYPE_SPLICE, 'Splice'), (TYPE_SPLICE, 'Splice'),
),
),
(
'Other',
(
(TYPE_OTHER, 'Other'),
) )
) )
) )

View File

@ -62,6 +62,8 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
# Device components # Device components
# #
MODULE_TOKEN = '{module}'
MODULAR_COMPONENT_TEMPLATE_MODELS = Q( MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
app_label='dcim', app_label='dcim',
model__in=( model__in=(

View File

@ -346,6 +346,32 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='rack__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='rack__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='rack__site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='rack__site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
location_id = TreeNodeMultipleChoiceFilter( location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(), queryset=Location.objects.all(),
field_name='rack__location', field_name='rack__location',
@ -435,6 +461,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
method='_device_bays', method='_device_bays',
label='Has device bays', label='Has device bays',
) )
inventory_items = django_filters.BooleanFilter(
method='_inventory_items',
label='Has inventory items',
)
class Meta: class Meta:
model = DeviceType model = DeviceType
@ -479,6 +509,9 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
def _device_bays(self, queryset, name, value): def _device_bays(self, queryset, name, value):
return queryset.exclude(devicebaytemplates__isnull=value) return queryset.exclude(devicebaytemplates__isnull=value)
def _inventory_items(self, queryset, name, value):
return queryset.exclude(inventoryitemtemplates__isnull=value)
class ModuleTypeFilterSet(NetBoxModelFilterSet): class ModuleTypeFilterSet(NetBoxModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(

View File

@ -3,7 +3,7 @@ from django import forms
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldsMixin from extras.forms import CustomFieldsMixin
from extras.models import Tag from extras.models import Tag
from utilities.forms import DynamicModelMultipleChoiceField, form_from_model from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
from .object_create import ComponentCreateForm from .object_create import ComponentCreateForm
__all__ = ( __all__ = (
@ -98,7 +98,13 @@ class RearPortBulkCreateForm(
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
model = ModuleBay model = ModuleBay
field_order = ('name_pattern', 'label_pattern', 'description', 'tags') field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags')
position_pattern = ExpandableNameField(
label='Position',
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
)
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):

View File

@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region model = Region
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'parent_id')), (None, ('q', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup model = SiteGroup
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'parent_id')), (None, ('q', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
status = MultipleChoiceField( status = MultipleChoiceField(
choices=SiteStatusChoices, choices=SiteStatusChoices,
@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -210,11 +210,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
model = Rack model = Rack
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Location', ('region_id', 'site_id', 'location_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Function', ('status', 'role_id')), ('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -229,6 +229,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
}, },
label=_('Site') label=_('Site')
) )
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
location_id = DynamicModelMultipleChoiceField( location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
@ -282,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('User', ('user_id',)), ('User', ('user_id',)),
('Rack', ('region_id', 'site_id', 'location_id')), ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@ -298,6 +303,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
}, },
label=_('Site') label=_('Site')
) )
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
location_id = DynamicModelMultipleChoiceField( location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'), queryset=Location.objects.prefetch_related('site'),
required=False, required=False,
@ -319,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer model = Manufacturer
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Contacts', ('contact', 'contact_role')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -331,7 +341,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
)), )),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
@ -392,6 +402,27 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES 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) tag = TagFilterField(model)
@ -487,7 +518,7 @@ class DeviceFilterForm(
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)), )),
@ -757,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -1071,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem model = InventoryItem
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
) )
role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(

View File

@ -385,6 +385,12 @@ class ModuleTypeForm(NetBoxModelForm):
) )
comments = CommentField() comments = CommentField()
fieldsets = (
('Module Type', (
'manufacturer', 'model', 'part_number', 'tags',
)),
)
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
@ -627,11 +633,26 @@ class ModuleForm(NetBoxModelForm):
help_text="Automatically populate components associated with this module type" help_text="Automatically populate components associated with this module type"
) )
adopt_components = forms.BooleanField(
required=False,
initial=False,
help_text="Adopt already existing components"
)
fieldsets = (
('Module', (
'device', 'module_bay', 'manufacturer', 'module_type', 'tags',
)),
('Hardware', (
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
)),
)
class Meta: class Meta:
model = Module model = Module
fields = [ fields = [
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
'replicate_components', 'comments', 'replicate_components', 'adopt_components', 'comments',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -640,6 +661,8 @@ class ModuleForm(NetBoxModelForm):
if self.instance.pk: if self.instance.pk:
self.fields['replicate_components'].initial = False self.fields['replicate_components'].initial = False
self.fields['replicate_components'].disabled = True self.fields['replicate_components'].disabled = True
self.fields['adopt_components'].initial = False
self.fields['adopt_components'].disabled = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -647,8 +670,62 @@ class ModuleForm(NetBoxModelForm):
if self.instance.pk or not self.cleaned_data['replicate_components']: if self.instance.pk or not self.cleaned_data['replicate_components']:
self.instance._disable_replication = True self.instance._disable_replication = True
if self.cleaned_data['adopt_components']:
self.instance._adopt_components = True
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def clean(self):
super().clean()
replicate_components = self.cleaned_data.get("replicate_components")
adopt_components = self.cleaned_data.get("adopt_components")
device = self.cleaned_data['device']
module_type = self.cleaned_data['module_type']
module_bay = self.cleaned_data['module_bay']
# Bail out if we are not installing a new module or if we are not replicating components
if self.instance.pk or not replicate_components:
return
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports")
]:
# Prefetch installed components
installed_components = {
component.name: component for component in getattr(device, component_attribute).all()
}
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position:
raise forms.ValidationError(
"Cannot install module with placeholder values in a module bay with no position defined"
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
existing_item = installed_components.get(resolved_name)
# It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError(
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
f"to a module"
)
# If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError(
f"{template.component_model.__name__} - {resolved_name} already exists"
)
class CableForm(TenancyForm, NetBoxModelForm): class CableForm(TenancyForm, NetBoxModelForm):
@ -1269,6 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'rf_channel_width': "Populated by selected channel (if set)", 'rf_channel_width': "Populated by selected channel (if set)",
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Restrict LAG/bridge interface assignment by device/VC
device_id = self.data['device'] if self.is_bound else self.initial.get('device')
device = Device.objects.filter(pk=device_id).first()
if device and device.virtual_chassis and device.virtual_chassis.master:
self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
class FrontPortForm(NetBoxModelForm): class FrontPortForm(NetBoxModelForm):
module = DynamicModelChoiceField( module = DynamicModelChoiceField(

View File

@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm):
] ]
def clean(self): def clean(self):
super().clean()
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({ raise forms.ValidationError({
'initial_position': "A position must be specified for the first VC member." 'initial_position': "A position must be specified for the first VC member."

View File

@ -121,12 +121,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
def resolve_name(self, module): def resolve_name(self, module):
if module: if module:
return self.name.replace('{module}', module.module_bay.position) return self.name.replace(MODULE_TOKEN, module.module_bay.position)
return self.name return self.name
def resolve_label(self, module): def resolve_label(self, module):
if module: if module:
return self.label.replace('{module}', module.module_bay.position) return self.label.replace(MODULE_TOKEN, module.module_bay.position)
return self.label return self.label

View File

@ -77,7 +77,7 @@ class ComponentModel(NetBoxModel):
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
objectchange.related_object = self.device objectchange.related_object = self.device
return super().to_objectchange(action) return objectchange
@property @property
def parent_object(self): def parent_object(self):
@ -543,7 +543,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
) )
speed = models.PositiveIntegerField( speed = models.PositiveIntegerField(
blank=True, blank=True,
null=True null=True,
verbose_name='Speed (Kbps)'
) )
duplex = models.CharField( duplex = models.CharField(
max_length=50, max_length=50,

View File

@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel):
return f'{self.name} ({self.asset_tag})' return f'{self.name} ({self.asset_tag})'
elif self.name: elif self.name:
return self.name return self.name
elif self.virtual_chassis and self.asset_tag:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
elif self.virtual_chassis: elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})' return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
elif self.device_type and self.asset_tag:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
elif self.device_type: elif self.device_type:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__() return super().__str__()
@ -1065,30 +1069,52 @@ class Module(NetBoxModel, ConfigContextModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# If this is a new Module and component replication has not been disabled, instantiate all its adopt_components = getattr(self, '_adopt_components', False)
# related components per the ModuleType definition disable_replication = getattr(self, '_disable_replication', False)
if is_new and not getattr(self, '_disable_replication', False):
ConsolePort.objects.bulk_create( # We skip adding components if the module is being edited or
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] # both replication and component adoption is disabled
) if not is_new or (disable_replication and not adopt_components):
ConsoleServerPort.objects.bulk_create( return
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()]
) # Iterate all component types
PowerPort.objects.bulk_create( for templates, component_attribute, component_model in [
[x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] ("consoleporttemplates", "consoleports", ConsolePort),
) ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
PowerOutlet.objects.bulk_create( ("interfacetemplates", "interfaces", Interface),
[x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] ("powerporttemplates", "powerports", PowerPort),
) ("poweroutlettemplates", "poweroutlets", PowerOutlet),
Interface.objects.bulk_create( ("rearporttemplates", "rearports", RearPort),
[x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] ("frontporttemplates", "frontports", FrontPort)
) ]:
RearPort.objects.bulk_create( create_instances = []
[x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] update_instances = []
)
FrontPort.objects.bulk_create( # Prefetch installed components
[x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] installed_components = {
) component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True)
}
# Get the template for the module type.
for template in getattr(self.module_type, templates).all():
template_instance = template.instantiate(device=self.device, module=self)
if adopt_components:
existing_item = installed_components.get(template_instance.name)
# Check if there's a component with the same name already
if existing_item:
# Assign it to the module
existing_item.module = self
update_instances.append(existing_item)
continue
# Only create new components if replication is enabled
if not disable_replication:
create_instances.append(template_instance)
component_model.objects.bulk_create(create_instances)
component_model.objects.bulk_update(update_instances, ['module'])
# #

View File

@ -190,7 +190,7 @@ class DeviceTable(NetBoxTable):
verbose_name='VC Priority' verbose_name='VC Priority'
) )
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -31,7 +31,9 @@ class ManufacturerTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
devicetype_count = tables.Column( devicetype_count = columns.LinkedCountColumn(
viewname='dcim:devicetype_list',
url_params={'manufacturer_id': 'pk'},
verbose_name='Device Types' verbose_name='Device Types'
) )
inventoryitem_count = tables.Column( inventoryitem_count = tables.Column(
@ -41,7 +43,7 @@ class ManufacturerTable(NetBoxTable):
verbose_name='Platforms' verbose_name='Platforms'
) )
slug = tables.Column() slug = tables.Column()
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -26,7 +26,7 @@ class PowerPanelTable(NetBoxTable):
url_params={'power_panel_id': 'pk'}, url_params={'power_panel_id': 'pk'},
verbose_name='Feeds' verbose_name='Feeds'
) )
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -69,7 +69,7 @@ class RackTable(NetBoxTable):
orderable=False, orderable=False,
verbose_name='Power' verbose_name='Power'
) )
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -26,7 +26,7 @@ class RegionTable(NetBoxTable):
url_params={'region_id': 'pk'}, url_params={'region_id': 'pk'},
verbose_name='Sites' verbose_name='Sites'
) )
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(
@ -55,7 +55,7 @@ class SiteGroupTable(NetBoxTable):
url_params={'group_id': 'pk'}, url_params={'group_id': 'pk'},
verbose_name='Sites' verbose_name='Sites'
) )
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(
@ -86,7 +86,7 @@ class SiteTable(NetBoxTable):
group = tables.Column( group = tables.Column(
linkify=True linkify=True
) )
asns = tables.ManyToManyColumn( asns = columns.ManyToManyColumn(
linkify_item=True, linkify_item=True,
verbose_name='ASNs' verbose_name='ASNs'
) )
@ -98,7 +98,7 @@ class SiteTable(NetBoxTable):
) )
tenant = TenantColumn() tenant = TenantColumn()
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(
@ -137,7 +137,7 @@ class LocationTable(NetBoxTable):
url_params={'location_id': 'pk'}, url_params={'location_id': 'pk'},
verbose_name='Devices' verbose_name='Devices'
) )
contacts = tables.ManyToManyColumn( contacts = columns.ManyToManyColumn(
linkify_item=True linkify_item=True
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -523,6 +523,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
console_port_templates = ( console_port_templates = (
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'), ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
@ -541,9 +544,13 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Console Port Template 5', 'name': 'Console Port Template 5',
}, },
{ {
'device_type': devicetype.pk, 'module_type': moduletype.pk,
'name': 'Console Port Template 6', 'name': 'Console Port Template 6',
}, },
{
'module_type': moduletype.pk,
'name': 'Console Port Template 7',
},
] ]
@ -560,6 +567,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
console_server_port_templates = ( console_server_port_templates = (
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'), ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
@ -578,9 +588,13 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Console Server Port Template 5', 'name': 'Console Server Port Template 5',
}, },
{ {
'device_type': devicetype.pk, 'module_type': moduletype.pk,
'name': 'Console Server Port Template 6', 'name': 'Console Server Port Template 6',
}, },
{
'module_type': moduletype.pk,
'name': 'Console Server Port Template 7',
},
] ]
@ -597,6 +611,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
power_port_templates = ( power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
@ -615,9 +632,13 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Power Port Template 5', 'name': 'Power Port Template 5',
}, },
{ {
'device_type': devicetype.pk, 'module_type': moduletype.pk,
'name': 'Power Port Template 6', 'name': 'Power Port Template 6',
}, },
{
'module_type': moduletype.pk,
'name': 'Power Port Template 7',
},
] ]
@ -634,6 +655,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
power_port_templates = ( power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
@ -664,6 +688,14 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Power Outlet Template 6', 'name': 'Power Outlet Template 6',
'power_port': None, 'power_port': None,
}, },
{
'module_type': moduletype.pk,
'name': 'Power Outlet Template 7',
},
{
'module_type': moduletype.pk,
'name': 'Power Outlet Template 8',
},
] ]
@ -680,6 +712,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
interface_templates = ( interface_templates = (
InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'), InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
@ -700,10 +735,15 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
'type': '1000base-t', 'type': '1000base-t',
}, },
{ {
'device_type': devicetype.pk, 'module_type': moduletype.pk,
'name': 'Interface Template 6', 'name': 'Interface Template 6',
'type': '1000base-t', 'type': '1000base-t',
}, },
{
'module_type': moduletype.pk,
'name': 'Interface Template 7',
'type': '1000base-t',
},
] ]
@ -720,14 +760,19 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
rear_port_templates = ( rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C),
) )
RearPortTemplate.objects.bulk_create(rear_port_templates) RearPortTemplate.objects.bulk_create(rear_port_templates)
@ -745,15 +790,28 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
rear_port=rear_port_templates[1] rear_port=rear_port_templates[1]
), ),
FrontPortTemplate( FrontPortTemplate(
device_type=devicetype, module_type=moduletype,
name='Front Port Template 3', name='Front Port Template 5',
type=PortTypeChoices.TYPE_8P8C, type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port_templates[2] rear_port=rear_port_templates[4]
),
FrontPortTemplate(
module_type=moduletype,
name='Front Port Template 6',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port_templates[5]
), ),
) )
FrontPortTemplate.objects.bulk_create(front_port_templates) FrontPortTemplate.objects.bulk_create(front_port_templates)
cls.create_data = [ cls.create_data = [
{
'device_type': devicetype.pk,
'name': 'Front Port Template 3',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[2].pk,
'rear_port_position': 1,
},
{ {
'device_type': devicetype.pk, 'device_type': devicetype.pk,
'name': 'Front Port Template 4', 'name': 'Front Port Template 4',
@ -762,17 +820,17 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
'rear_port_position': 1, 'rear_port_position': 1,
}, },
{ {
'device_type': devicetype.pk, 'module_type': moduletype.pk,
'name': 'Front Port Template 5', 'name': 'Front Port Template 7',
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[4].pk, 'rear_port': rear_port_templates[6].pk,
'rear_port_position': 1, 'rear_port_position': 1,
}, },
{ {
'device_type': devicetype.pk, 'module_type': moduletype.pk,
'name': 'Front Port Template 6', 'name': 'Front Port Template 8',
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[5].pk, 'rear_port': rear_port_templates[7].pk,
'rear_port_position': 1, 'rear_port_position': 1,
}, },
] ]
@ -791,6 +849,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
rear_port_templates = ( rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
@ -811,10 +872,15 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
}, },
{ {
'device_type': devicetype.pk, 'module_type': moduletype.pk,
'name': 'Rear Port Template 6', 'name': 'Rear Port Template 6',
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
}, },
{
'module_type': moduletype.pk,
'name': 'Rear Port Template 7',
'type': PortTypeChoices.TYPE_8P8C,
},
] ]

View File

@ -521,10 +521,26 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = ( sites = (
Site(name='Site 1', slug='site-1'), Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2'), Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3'), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
@ -572,6 +588,20 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
RackReservation.objects.bulk_create(reservations) RackReservation.objects.bulk_create(reservations)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self): def test_site(self):
sites = Site.objects.all()[:2] sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]} params = {'site_id': [sites[0].pk, sites[1].pk]}
@ -698,6 +728,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'), DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'),
DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'),
)) ))
# Assigned DeviceType must have parent subdevice_role
inventory_item = InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 1')
inventory_item.save()
def test_model(self): def test_model(self):
params = {'model': ['Model 1', 'Model 2']} params = {'model': ['Model 1', 'Model 2']}
@ -784,6 +817,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'module_bays': 'false'} params = {'module_bays': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_inventory_items(self):
params = {'inventory_items': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'inventory_items': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()

View File

@ -1869,6 +1869,44 @@ class ModuleTestCase(
self.assertHttpStatus(self.client.post(**request), 302) self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(Interface.objects.filter(device=device).count(), 5) self.assertEqual(Interface.objects.filter(device=device).count(), 5)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_component_adoption(self):
self.add_permissions('dcim.add_module')
interface_name = "Interface-1"
# Add an interface to the ModuleType
module_type = ModuleType.objects.first()
InterfaceTemplate(module_type=module_type, name=interface_name).save()
form_data = self.form_data.copy()
device = Device.objects.get(pk=form_data['device'])
# Create an interface to be adopted
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
interface.save()
# Ensure that interface is created with no module
self.assertIsNone(interface.module)
# Create a module with adopted components
form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
form_data['module_type'] = module_type
form_data['replicate_components'] = False
form_data['adopt_components'] = True
request = {
'path': self._get_url('add'),
'data': post_data(form_data),
}
self.assertHttpStatus(self.client.post(**request), 302)
# Re-retrieve interface to get new module id
interface.refresh_from_db()
# Check that the Interface now has a module
self.assertIsNotNone(interface.module)
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort model = ConsolePort

View File

@ -166,7 +166,7 @@ class RegionView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter( sites = Site.objects.restrict(request.user, 'view').filter(
region=instance region=instance
) )
sites_table = tables.SiteTable(sites, exclude=('region',)) sites_table = tables.SiteTable(sites, user=request.user, exclude=('region',))
sites_table.configure(request) sites_table.configure(request)
return { return {
@ -251,7 +251,7 @@ class SiteGroupView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter( sites = Site.objects.restrict(request.user, 'view').filter(
group=instance group=instance
) )
sites_table = tables.SiteTable(sites, exclude=('group',)) sites_table = tables.SiteTable(sites, user=request.user, exclude=('group',))
sites_table.configure(request) sites_table.configure(request)
return { return {
@ -435,7 +435,7 @@ class LocationView(generic.ObjectView):
'rack_count', 'rack_count',
cumulative=True cumulative=True
).filter(pk__in=location_ids).exclude(pk=instance.pk) ).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations) child_locations_table = tables.LocationTable(child_locations, user=request.user)
child_locations_table.configure(request) child_locations_table.configure(request)
nonracked_devices = Device.objects.filter( nonracked_devices = Device.objects.filter(
@ -514,7 +514,9 @@ class RackRoleView(generic.ObjectView):
role=instance role=instance
) )
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) racks_table = tables.RackTable(racks, user=request.user, exclude=(
'role', 'get_utilization', 'get_power_utilization',
))
racks_table.configure(request) racks_table.configure(request)
return { return {
@ -767,7 +769,7 @@ class ManufacturerView(generic.ObjectView):
manufacturer=instance manufacturer=instance
) )
devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',)) devicetypes_table = tables.DeviceTypeTable(device_types, user=request.user, exclude=('manufacturer',))
devicetypes_table.configure(request) devicetypes_table.configure(request)
return { return {
@ -1480,7 +1482,7 @@ class DeviceRoleView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter( devices = Device.objects.restrict(request.user, 'view').filter(
device_role=instance device_role=instance
) )
devices_table = tables.DeviceTable(devices, exclude=('device_role',)) devices_table = tables.DeviceTable(devices, user=request.user, exclude=('device_role',))
devices_table.configure(request) devices_table.configure(request)
return { return {
@ -1544,7 +1546,7 @@ class PlatformView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter( devices = Device.objects.restrict(request.user, 'view').filter(
platform=instance platform=instance
) )
devices_table = tables.DeviceTable(devices, exclude=('platform',)) devices_table = tables.DeviceTable(devices, user=request.user, exclude=('platform',))
devices_table.configure(request) devices_table.configure(request)
return { return {

View File

@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer):
) )
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField() data_type = serializers.SerializerMethodField()
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
class Meta: class Meta:
model = CustomField model = CustomField
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum',
'validation_regex', 'choices', 'created', 'last_updated', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
] ]
def get_data_type(self, obj): def get_data_type(self, obj):

View File

@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
) )
class CustomFieldVisibilityChoices(ChoiceSet):
VISIBILITY_READ_WRITE = 'read-write'
VISIBILITY_READ_ONLY = 'read-only'
VISIBILITY_HIDDEN = 'hidden'
CHOICES = (
(VISIBILITY_READ_WRITE, 'Read/Write'),
(VISIBILITY_READ_ONLY, 'Read-only'),
(VISIBILITY_HIDDEN, 'Hidden'),
)
# #
# CustomLinks # CustomLinks
# #

View File

@ -62,7 +62,10 @@ class CustomFieldFilterSet(BaseFilterSet):
class Meta: class Meta:
model = CustomField model = CustomField
fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description'] fields = [
'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight',
'description',
]
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -37,6 +37,13 @@ class CustomFieldBulkEditForm(BulkEditForm):
weight = forms.IntegerField( weight = forms.IntegerField(
required=False required=False
) )
ui_visibility = forms.ChoiceField(
label="UI visibility",
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
initial='',
widget=StaticSelect()
)
nullable_fields = ('group_name', 'description',) nullable_fields = ('group_name', 'description',)

View File

@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm):
choices=CustomFieldTypeChoices, choices=CustomFieldTypeChoices,
help_text='Field data type (e.g. text, integer, etc.)' help_text='Field data type (e.g. text, integer, etc.)'
) )
object_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False,
help_text="Object type (for object or multi-object fields)"
)
choices = SimpleArrayField( choices = SimpleArrayField(
base_field=forms.CharField(), base_field=forms.CharField(),
required=False, required=False,
@ -36,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
class Meta: class Meta:
model = CustomField model = CustomField
fields = ( fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'ui_visibility',
) )

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from extras.models import * from extras.models import *
from extras.choices import CustomFieldVisibilityChoices
__all__ = ( __all__ = (
'CustomFieldsMixin', 'CustomFieldsMixin',
@ -42,8 +43,18 @@ class CustomFieldsMixin:
Append form fields for all CustomFields assigned to this object type. Append form fields for all CustomFields assigned to this object type.
""" """
for customfield in self._get_custom_fields(self._get_content_type()): for customfield in self._get_custom_fields(self._get_content_type()):
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
field_name = f'cf_{customfield.name}' field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield) self.fields[field_name] = self._get_form_field(customfield)
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
self.fields[field_name].disabled = True
if self.fields[field_name].help_text:
self.fields[field_name].help_text += '<br />'
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
'Field is set to read-only.'
# Annotate the field in the list of CustomField form fields # Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield self.custom_fields[field_name] = customfield

View File

@ -32,7 +32,7 @@ __all__ = (
class CustomFieldFilterForm(FilterForm): class CustomFieldFilterForm(FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q',)),
('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')), ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')),
) )
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
@ -56,6 +56,12 @@ class CustomFieldFilterForm(FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
label=_('UI visibility'),
widget=StaticSelect()
)
class CustomLinkFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm):

View File

@ -43,7 +43,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
('Custom Field', ( ('Custom Field', (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
)), )),
('Behavior', ('filter_logic',)), ('Behavior', ('filter_logic', 'ui_visibility')),
('Values', ('default', 'choices')), ('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
) )
@ -58,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
widgets = { widgets = {
'type': StaticSelect(), 'type': StaticSelect(),
'filter_logic': StaticSelect(), 'filter_logic': StaticSelect(),
'ui_visibility': StaticSelect(),
} }

View File

@ -0,0 +1,11 @@
from django.core.cache import cache
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""Command to clear the entire cache."""
help = 'Clears the cache.'
def handle(self, *args, **kwargs):
cache.clear()
self.stdout.write('Cache has been cleared.', ending="\n")

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.4 on 2022-05-23 20:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0074_customfield_group_name'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='ui_visibility',
field=models.CharField(default='read-write', max_length=50),
),
]

View File

@ -136,6 +136,13 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
null=True, null=True,
help_text='Comma-separated list of available choices (for selection fields)' help_text='Comma-separated list of available choices (for selection fields)'
) )
ui_visibility = models.CharField(
max_length=50,
choices=CustomFieldVisibilityChoices,
default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
verbose_name='UI visibility',
help_text='Specifies the visibility of custom field in the UI'
)
objects = CustomFieldManager() objects = CustomFieldManager()
class Meta: class Meta:

View File

@ -5,6 +5,7 @@ import os
import pkgutil import pkgutil
import sys import sys
import traceback import traceback
import threading
from collections import OrderedDict from collections import OrderedDict
import yaml import yaml
@ -13,11 +14,9 @@ from django.conf import settings
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import transaction from django.db import transaction
from django.utils.functional import classproperty from django.utils.functional import classproperty
from django_rq import job
from extras.api.serializers import ScriptOutputSerializer from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices, LogLevelChoices from extras.choices import JobResultStatusChoices, LogLevelChoices
from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction from utilities.exceptions import AbortTransaction
@ -42,6 +41,8 @@ __all__ = [
'TextVar', 'TextVar',
] ]
lock = threading.Lock()
# #
# Script variables # Script variables
@ -305,7 +306,14 @@ class BaseScript:
@classmethod @classmethod
def _get_vars(cls): def _get_vars(cls):
vars = {} vars = {}
for name, attr in cls.__dict__.items():
# Iterate all base classes looking for ScriptVariables
for base_class in inspect.getmro(cls):
# When object is reached there's no reason to continue
if base_class is object:
break
for name, attr in base_class.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable): if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr vars[name] = attr
@ -491,11 +499,14 @@ def get_scripts(use_names=False):
# Iterate through all modules within the scripts path. These are the user-created files in which reports are # Iterate through all modules within the scripts path. These are the user-created files in which reports are
# defined. # defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
# Use a lock as removing and loading modules is not thread safe
with lock:
# Remove cached module to ensure consistency with filesystem # Remove cached module to ensure consistency with filesystem
if module_name in sys.modules: if module_name in sys.modules:
del sys.modules[module_name] del sys.modules[module_name]
module = importer.find_module(module_name).load_module(module_name) module = importer.find_module(module_name).load_module(module_name)
if use_names and hasattr(module, 'name'): if use_names and hasattr(module, 'name'):
module_name = module.name module_name = module.name
module_scripts = OrderedDict() module_scripts = OrderedDict()

View File

@ -28,12 +28,13 @@ class CustomFieldTable(NetBoxTable):
) )
content_types = columns.ContentTypesColumn() content_types = columns.ContentTypesColumn()
required = columns.BooleanColumn() required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CustomField model = CustomField
fields = ( fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
'description', 'filter_logic', 'choices', 'created', 'last_updated', 'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')

View File

@ -36,13 +36,15 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'default': None, 'default': None,
'weight': 200, 'weight': 200,
'required': True, 'required': True,
'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
} }
cls.csv_data = ( cls.csv_data = (
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,100,exact,,1,100,', 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write',
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write',
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {

View File

@ -360,7 +360,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
) )
assigned_object = serializers.SerializerMethodField(read_only=True) assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(required=False, read_only=True) nat_outside = NestedIPAddressSerializer(many=True, read_only=True)
class Meta: class Meta:
model = IPAddress model = IPAddress
@ -369,7 +369,6 @@ class IPAddressSerializer(NetBoxModelSerializer):
'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
read_only_fields = ['family', 'nat_outside']
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, obj): def get_assigned_object(self, obj):

View File

@ -91,7 +91,7 @@ class IPAddressRoleChoices(ChoiceSet):
(ROLE_VRRP, 'VRRP', 'green'), (ROLE_VRRP, 'VRRP', 'green'),
(ROLE_HSRP, 'HSRP', 'green'), (ROLE_HSRP, 'HSRP', 'green'),
(ROLE_GLBP, 'GLBP', 'green'), (ROLE_GLBP, 'GLBP', 'green'),
(ROLE_CARP, 'CARP'), 'green', (ROLE_CARP, 'CARP', 'green'),
) )

View File

@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
if not value.strip(): if not value.strip():
return queryset return queryset
qs_filter = Q(description__icontains=value) qs_filter = Q(description__icontains=value)
qs_filter |= Q(prefix__contains=value.strip())
try: try:
prefix = str(netaddr.IPNetwork(value.strip()).cidr) prefix = str(netaddr.IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix) qs_filter |= Q(prefix__net_contains_or_equals=prefix)
qs_filter |= Q(prefix__contains=value.strip())
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
if not value.strip(): if not value.strip():
return queryset return queryset
qs_filter = Q(description__icontains=value) qs_filter = Q(description__icontains=value)
qs_filter |= Q(prefix__contains=value.strip())
try: try:
prefix = str(netaddr.IPNetwork(value.strip()).cidr) prefix = str(netaddr.IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix) qs_filter |= Q(prefix__net_contains_or_equals=prefix)
qs_filter |= Q(prefix__contains=value.strip())
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
@ -460,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
field_name='address', field_name='address',
lookup_expr='family' lookup_expr='family'
) )
parent = django_filters.CharFilter( parent = MultiValueCharFilter(
method='search_by_parent', method='search_by_parent',
label='Parent prefix', label='Parent prefix',
) )
@ -567,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
def search_by_parent(self, queryset, name, value): def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value: if not value:
return queryset return queryset
q = Q()
for prefix in value:
try: try:
query = str(netaddr.IPNetwork(value.strip()).cidr) query = str(netaddr.IPNetwork(prefix.strip()).cidr)
return queryset.filter(address__net_host_contained=query) q |= Q(address__net_host_contained=query)
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
return queryset.none() return queryset.none()
return queryset.filter(q)
def filter_address(self, queryset, name, value): def filter_address(self, queryset, name, value):
try: try:
@ -681,11 +687,53 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
queryset=FHRPGroup.objects.all(), queryset=FHRPGroup.objects.all(),
label='Group (ID)', label='Group (ID)',
) )
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
label='Device (name)',
)
device_id = MultiValueNumberFilter(
method='filter_device',
field_name='pk',
label='Device (ID)',
)
virtual_machine = MultiValueCharFilter(
method='filter_virtual_machine',
field_name='name',
label='Virtual machine (name)',
)
virtual_machine_id = MultiValueNumberFilter(
method='filter_virtual_machine',
field_name='pk',
label='Virtual machine (ID)',
)
class Meta: class Meta:
model = FHRPGroupAssignment model = FHRPGroupAssignment
fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority']
def filter_device(self, queryset, name, value):
devices = Device.objects.filter(**{f'{name}__in': value})
if not devices.exists():
return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
return queryset.filter(
Q(interface_type=ContentType.objects.get_for_model(Interface), interface_id__in=interface_ids)
)
def filter_virtual_machine(self, queryset, name, value):
virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value})
if not virtual_machines.exists():
return queryset.none()
interface_ids = []
for vm in virtual_machines:
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
return queryset.filter(
Q(interface_type=ContentType.objects.get_for_model(VMInterface), interface_id__in=interface_ids)
)
class VLANGroupFilterSet(OrganizationalModelFilterSet): class VLANGroupFilterSet(OrganizationalModelFilterSet):
scope_type = ContentTypeFilter() scope_type = ContentTypeFilter()

View File

@ -0,0 +1,17 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0057_created_datetimefield'),
]
operations = [
migrations.AlterField(
model_name='ipaddress',
name='nat_inside',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.ipaddress'),
),
]

View File

@ -507,16 +507,20 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel):
child_ranges.add(iprange.range) child_ranges.add(iprange.range)
available_ips = prefix - child_ips - child_ranges available_ips = prefix - child_ips - child_ranges
# IPv6, pool, or IPv4 /31-/32 sets are fully usable # IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
if self.family == 6 or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31): if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
return available_ips return available_ips
if self.family == 4:
# For "normal" IPv4 prefixes, omit first and last addresses # For "normal" IPv4 prefixes, omit first and last addresses
available_ips -= netaddr.IPSet([ available_ips -= netaddr.IPSet([
netaddr.IPAddress(self.prefix.first), netaddr.IPAddress(self.prefix.first),
netaddr.IPAddress(self.prefix.last), netaddr.IPAddress(self.prefix.last),
]) ])
else:
# For IPv6 prefixes, omit the Subnet-Router anycast address
# per RFC 4291
available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
return available_ips return available_ips
def get_first_available_ip(self): def get_first_available_ip(self):
@ -809,7 +813,7 @@ class IPAddress(NetBoxModel):
ct_field='assigned_object_type', ct_field='assigned_object_type',
fk_field='assigned_object_id' fk_field='assigned_object_id'
) )
nat_inside = models.OneToOneField( nat_inside = models.ForeignKey(
to='self', to='self',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='nat_outside', related_name='nat_outside',

View File

@ -118,7 +118,7 @@ class ASNTable(NetBoxTable):
url_params={'asn_id': 'pk'}, url_params={'asn_id': 'pk'},
verbose_name='Provider Count' verbose_name='Provider Count'
) )
sites = tables.ManyToManyColumn( sites = columns.ManyToManyColumn(
linkify_item=True, linkify_item=True,
verbose_name='Sites' verbose_name='Sites'
) )
@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
class PrefixTable(NetBoxTable): class PrefixTable(NetBoxTable):
prefix = tables.TemplateColumn( prefix = columns.TemplateColumn(
template_code=PREFIX_LINK, template_code=PREFIX_LINK,
export_raw=True,
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
prefix_flat = tables.TemplateColumn( prefix_flat = tables.TemplateColumn(

View File

@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable):
linkify=True linkify=True
) )
ports = tables.Column( ports = tables.Column(
accessor=tables.A('port_list') accessor=tables.A('port_list'),
order_by=tables.A('ports'),
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:servicetemplate_list' url_name='ipam:servicetemplate_list'
@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable):
order_by=('device', 'virtual_machine') order_by=('device', 'virtual_machine')
) )
ports = tables.Column( ports = tables.Column(
accessor=tables.A('port_list') accessor=tables.A('port_list'),
order_by=tables.A('ports'),
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:service_list' url_name='ipam:service_list'

View File

@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self): def test_parent(self):
params = {'parent': '10.0.0.0/24'} params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'parent': '2001:db8::/64'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_filter_address(self): def test_filter_address(self):
# Check IPv4 and IPv6, with and without a mask # Check IPv4 and IPv6, with and without a mask
@ -1024,6 +1022,20 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'priority': [10, 20]} params = {'priority': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self):
device = Device.objects.first()
params = {'device': [device.name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'device_id': [device.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_virtual_machine(self):
vm = VirtualMachine.objects.first()
params = {'virtual_machine': [vm.name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'virtual_machine_id': [vm.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANGroup.objects.all() queryset = VLANGroup.objects.all()

View File

@ -185,6 +185,18 @@ class TestPrefix(TestCase):
IPAddress.objects.create(address=IPNetwork('10.0.0.4/24')) IPAddress.objects.create(address=IPNetwork('10.0.0.4/24'))
self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24') self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24')
def test_get_first_available_ip_ipv6(self):
parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500::/64'))
self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500::1/64')
def test_get_first_available_ip_ipv6_rfc3627(self):
parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:4::/126'))
self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:4::1/126')
def test_get_first_available_ip_ipv6_rfc6164(self):
parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:5::/127'))
self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:5::/127')
def test_get_utilization_container(self): def test_get_utilization_container(self):
prefixes = ( prefixes = (
Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER), Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER),

View File

@ -4,15 +4,15 @@ from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from circuits.models import Provider from circuits.models import Provider, Circuit
from circuits.tables import ProviderTable from circuits.tables import ProviderTable
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site from dcim.models import Interface, Site, Device
from dcim.tables import SiteTable from dcim.tables import SiteTable
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import VMInterfaceFilterSet from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VMInterface from virtualization.models import VMInterface, VirtualMachine
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .constants import * from .constants import *
from .models import * from .models import *
@ -158,10 +158,10 @@ class RIRView(generic.ObjectView):
queryset = RIR.objects.all() queryset = RIR.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
aggregates = Aggregate.objects.restrict(request.user, 'view').filter( aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate(
rir=instance child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
) )
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) aggregates_table = tables.AggregateTable(aggregates, user=request.user, exclude=('rir', 'utilization'))
aggregates_table.configure(request) aggregates_table.configure(request)
return { return {
@ -221,12 +221,14 @@ class ASNView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
# Gather assigned Sites # Gather assigned Sites
sites = instance.sites.restrict(request.user, 'view') sites = instance.sites.restrict(request.user, 'view')
sites_table = SiteTable(sites) sites_table = SiteTable(sites, user=request.user)
sites_table.configure(request) sites_table.configure(request)
# Gather assigned Providers # Gather assigned Providers
providers = instance.providers.restrict(request.user, 'view') providers = instance.providers.restrict(request.user, 'view').annotate(
providers_table = ProviderTable(providers) count_circuits=count_related(Circuit, 'provider')
)
providers_table = ProviderTable(providers, user=request.user)
providers_table.configure(request) providers_table.configure(request)
return { return {
@ -366,7 +368,7 @@ class RoleView(generic.ObjectView):
role=instance role=instance
) )
prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) prefixes_table = tables.PrefixTable(prefixes, user=request.user, exclude=('role', 'utilization'))
prefixes_table.configure(request) prefixes_table.configure(request)
return { return {
@ -585,7 +587,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
def get_children(self, request, parent): def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant', 'vrf', 'tenant',
) )
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
@ -674,11 +676,26 @@ class IPAddressView(generic.ObjectView):
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request) related_ips_table.configure(request)
# Find services belonging to the IP
service_filter = Q(ipaddresses=instance)
# Find services listening on all IPs on the assigned device/vm
if instance.assigned_object and instance.assigned_object.parent_object:
parent_object = instance.assigned_object.parent_object
if isinstance(parent_object, VirtualMachine):
service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None))
elif isinstance(parent_object, Device):
service_filter |= (Q(device=parent_object) & Q(ipaddresses=None))
services = Service.objects.restrict(request.user, 'view').filter(service_filter)
return { return {
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table, 'duplicate_ips_table': duplicate_ips_table,
'more_duplicate_ips': duplicate_ips.count() > 10, 'more_duplicate_ips': duplicate_ips.count() > 10,
'related_ips_table': related_ips_table, 'related_ips_table': related_ips_table,
'services': services,
} }
@ -805,7 +822,7 @@ class VLANGroupView(generic.ObjectView):
vlans_count = vlans.count() vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance) vlans = add_available_vlans(vlans, vlan_group=instance)
vlans_table = tables.VLANTable(vlans, exclude=('group',)) vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk') vlans_table.columns.show('pk')
vlans_table.configure(request) vlans_table.configure(request)

View File

@ -39,6 +39,7 @@ AUTH_BACKEND_ATTRS = {
'keycloak': ('Keycloak', None), 'keycloak': ('Keycloak', None),
'microsoft-graph': ('Microsoft Graph', 'microsoft'), 'microsoft-graph': ('Microsoft Graph', 'microsoft'),
'okta': ('Okta', None), 'okta': ('Okta', None),
'okta-openidconnect': ('Okta (OIDC)', None),
'salesforce-oauth2': ('Salesforce', 'salesforce'), 'salesforce-oauth2': ('Salesforce', 'salesforce'),
} }

View File

@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300
# this setting is derived from the installed location. # this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
# The name to use for the csrf token cookie.
CSRF_COOKIE_NAME = 'csrftoken'
# The name to use for the session cookie. # The name to use for the session cookie.
SESSION_COOKIE_NAME = 'sessionid' SESSION_COOKIE_NAME = 'sessionid'

View File

@ -1,32 +1,24 @@
from collections import OrderedDict from collections import OrderedDict
from typing import Dict from typing import Dict
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet import circuits.filtersets
import circuits.tables
import dcim.filtersets
import dcim.tables
import ipam.filtersets
import ipam.tables
import tenancy.filtersets
import tenancy.tables
import virtualization.filtersets
import virtualization.tables
from circuits.models import Circuit, ProviderNetwork, Provider from circuits.models import Circuit, ProviderNetwork, Provider
from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
from dcim.filtersets import (
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet,
PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
)
from dcim.models import ( from dcim.models import (
Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
) )
from dcim.tables import ( from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable,
RackReservationTable, SiteTable, VirtualChassisTable,
)
from ipam.filtersets import (
AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
)
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
from tenancy.models import Contact, Tenant, ContactAssignment from tenancy.models import Contact, Tenant, ContactAssignment
from tenancy.tables import ContactTable, TenantTable
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineTable
SEARCH_MAX_RESULTS = 15 SEARCH_MAX_RESULTS = 15
@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict(
'queryset': Provider.objects.annotate( 'queryset': Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider') count_circuits=count_related(Circuit, 'provider')
), ),
'filterset': ProviderFilterSet, 'filterset': circuits.filtersets.ProviderFilterSet,
'table': ProviderTable, 'table': circuits.tables.ProviderTable,
'url': 'circuits:provider_list', 'url': 'circuits:provider_list',
}), }),
('circuit', { ('circuit', {
'queryset': Circuit.objects.prefetch_related( 'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site' 'type', 'provider', 'tenant', 'terminations__site'
), ),
'filterset': CircuitFilterSet, 'filterset': circuits.filtersets.CircuitFilterSet,
'table': CircuitTable, 'table': circuits.tables.CircuitTable,
'url': 'circuits:circuit_list', 'url': 'circuits:circuit_list',
}), }),
('providernetwork', { ('providernetwork', {
'queryset': ProviderNetwork.objects.prefetch_related('provider'), 'queryset': ProviderNetwork.objects.prefetch_related('provider'),
'filterset': ProviderNetworkFilterSet, 'filterset': circuits.filtersets.ProviderNetworkFilterSet,
'table': ProviderNetworkTable, 'table': circuits.tables.ProviderNetworkTable,
'url': 'circuits:providernetwork_list', 'url': 'circuits:providernetwork_list',
}), }),
) )
@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict(
( (
('site', { ('site', {
'queryset': Site.objects.prefetch_related('region', 'tenant'), 'queryset': Site.objects.prefetch_related('region', 'tenant'),
'filterset': SiteFilterSet, 'filterset': dcim.filtersets.SiteFilterSet,
'table': SiteTable, 'table': dcim.tables.SiteTable,
'url': 'dcim:site_list', 'url': 'dcim:site_list',
}), }),
('rack', { ('rack', {
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
device_count=count_related(Device, 'rack') device_count=count_related(Device, 'rack')
), ),
'filterset': RackFilterSet, 'filterset': dcim.filtersets.RackFilterSet,
'table': RackTable, 'table': dcim.tables.RackTable,
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',
}), }),
('rackreservation', { ('rackreservation', {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'filterset': RackReservationFilterSet, 'filterset': dcim.filtersets.RackReservationFilterSet,
'table': RackReservationTable, 'table': dcim.tables.RackReservationTable,
'url': 'dcim:rackreservation_list', 'url': 'dcim:rackreservation_list',
}), }),
('location', { ('location', {
@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict(
'rack_count', 'rack_count',
cumulative=True cumulative=True
).prefetch_related('site'), ).prefetch_related('site'),
'filterset': LocationFilterSet, 'filterset': dcim.filtersets.LocationFilterSet,
'table': LocationTable, 'table': dcim.tables.LocationTable,
'url': 'dcim:location_list', 'url': 'dcim:location_list',
}), }),
('devicetype', { ('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Device, 'device_type') instance_count=count_related(Device, 'device_type')
), ),
'filterset': DeviceTypeFilterSet, 'filterset': dcim.filtersets.DeviceTypeFilterSet,
'table': DeviceTypeTable, 'table': dcim.tables.DeviceTypeTable,
'url': 'dcim:devicetype_list', 'url': 'dcim:devicetype_list',
}), }),
('device', { ('device', {
'queryset': Device.objects.prefetch_related( 'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
), ),
'filterset': DeviceFilterSet, 'filterset': dcim.filtersets.DeviceFilterSet,
'table': DeviceTable, 'table': dcim.tables.DeviceTable,
'url': 'dcim:device_list', 'url': 'dcim:device_list',
}), }),
('moduletype', { ('moduletype', {
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Module, 'module_type') instance_count=count_related(Module, 'module_type')
), ),
'filterset': ModuleTypeFilterSet, 'filterset': dcim.filtersets.ModuleTypeFilterSet,
'table': ModuleTypeTable, 'table': dcim.tables.ModuleTypeTable,
'url': 'dcim:moduletype_list', 'url': 'dcim:moduletype_list',
}), }),
('module', { ('module', {
'queryset': Module.objects.prefetch_related( 'queryset': Module.objects.prefetch_related(
'module_type__manufacturer', 'device', 'module_bay', 'module_type__manufacturer', 'device', 'module_bay',
), ),
'filterset': ModuleFilterSet, 'filterset': dcim.filtersets.ModuleFilterSet,
'table': ModuleTable, 'table': dcim.tables.ModuleTable,
'url': 'dcim:module_list', 'url': 'dcim:module_list',
}), }),
('virtualchassis', { ('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate( 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=count_related(Device, 'virtual_chassis') member_count=count_related(Device, 'virtual_chassis')
), ),
'filterset': VirtualChassisFilterSet, 'filterset': dcim.filtersets.VirtualChassisFilterSet,
'table': VirtualChassisTable, 'table': dcim.tables.VirtualChassisTable,
'url': 'dcim:virtualchassis_list', 'url': 'dcim:virtualchassis_list',
}), }),
('cable', { ('cable', {
'queryset': Cable.objects.all(), 'queryset': Cable.objects.all(),
'filterset': CableFilterSet, 'filterset': dcim.filtersets.CableFilterSet,
'table': CableTable, 'table': dcim.tables.CableTable,
'url': 'dcim:cable_list', 'url': 'dcim:cable_list',
}), }),
('powerfeed', { ('powerfeed', {
'queryset': PowerFeed.objects.all(), 'queryset': PowerFeed.objects.all(),
'filterset': PowerFeedFilterSet, 'filterset': dcim.filtersets.PowerFeedFilterSet,
'table': PowerFeedTable, 'table': dcim.tables.PowerFeedTable,
'url': 'dcim:powerfeed_list', 'url': 'dcim:powerfeed_list',
}), }),
) )
@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict(
( (
('vrf', { ('vrf', {
'queryset': VRF.objects.prefetch_related('tenant'), 'queryset': VRF.objects.prefetch_related('tenant'),
'filterset': VRFFilterSet, 'filterset': ipam.filtersets.VRFFilterSet,
'table': VRFTable, 'table': ipam.tables.VRFTable,
'url': 'ipam:vrf_list', 'url': 'ipam:vrf_list',
}), }),
('aggregate', { ('aggregate', {
'queryset': Aggregate.objects.prefetch_related('rir'), 'queryset': Aggregate.objects.prefetch_related('rir'),
'filterset': AggregateFilterSet, 'filterset': ipam.filtersets.AggregateFilterSet,
'table': AggregateTable, 'table': ipam.tables.AggregateTable,
'url': 'ipam:aggregate_list', 'url': 'ipam:aggregate_list',
}), }),
('prefix', { ('prefix', {
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filterset': PrefixFilterSet, 'filterset': ipam.filtersets.PrefixFilterSet,
'table': PrefixTable, 'table': ipam.tables.PrefixTable,
'url': 'ipam:prefix_list', 'url': 'ipam:prefix_list',
}), }),
('ipaddress', { ('ipaddress', {
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
'filterset': IPAddressFilterSet, 'filterset': ipam.filtersets.IPAddressFilterSet,
'table': IPAddressTable, 'table': ipam.tables.IPAddressTable,
'url': 'ipam:ipaddress_list', 'url': 'ipam:ipaddress_list',
}), }),
('vlan', { ('vlan', {
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'filterset': VLANFilterSet, 'filterset': ipam.filtersets.VLANFilterSet,
'table': VLANTable, 'table': ipam.tables.VLANTable,
'url': 'ipam:vlan_list', 'url': 'ipam:vlan_list',
}), }),
('asn', { ('asn', {
'queryset': ASN.objects.prefetch_related('rir', 'tenant'), 'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
'filterset': ASNFilterSet, 'filterset': ipam.filtersets.ASNFilterSet,
'table': ASNTable, 'table': ipam.tables.ASNTable,
'url': 'ipam:asn_list', 'url': 'ipam:asn_list',
}), }),
('service', {
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
'filterset': ipam.filtersets.ServiceFilterSet,
'table': ipam.tables.ServiceTable,
'url': 'ipam:service_list',
}),
) )
) )
@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict(
( (
('tenant', { ('tenant', {
'queryset': Tenant.objects.prefetch_related('group'), 'queryset': Tenant.objects.prefetch_related('group'),
'filterset': TenantFilterSet, 'filterset': tenancy.filtersets.TenantFilterSet,
'table': TenantTable, 'table': tenancy.tables.TenantTable,
'url': 'tenancy:tenant_list', 'url': 'tenancy:tenant_list',
}), }),
('contact', { ('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
assignment_count=count_related(ContactAssignment, 'contact')), assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': ContactFilterSet, 'filterset': tenancy.filtersets.ContactFilterSet,
'table': ContactTable, 'table': tenancy.tables.ContactTable,
'url': 'tenancy:contact_list', 'url': 'tenancy:contact_list',
}), }),
) )
@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict(
device_count=count_related(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
), ),
'filterset': ClusterFilterSet, 'filterset': virtualization.filtersets.ClusterFilterSet,
'table': ClusterTable, 'table': virtualization.tables.ClusterTable,
'url': 'virtualization:cluster_list', 'url': 'virtualization:cluster_list',
}), }),
('virtualmachine', { ('virtualmachine', {
'queryset': VirtualMachine.objects.prefetch_related( 'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
), ),
'filterset': VirtualMachineFilterSet, 'filterset': virtualization.filtersets.VirtualMachineFilterSet,
'table': VirtualMachineTable, 'table': virtualization.tables.VirtualMachineTable,
'url': 'virtualization:virtualmachine_list', 'url': 'virtualization:virtualmachine_list',
}), }),
) )

View File

@ -61,6 +61,8 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
""" """
Base form for creating a NetBox objects from CSV data. Used for bulk importing. Base form for creating a NetBox objects from CSV data. Used for bulk importing.
""" """
tags = None # Temporary fix in lieu of tag import support (see #9158)
def _get_form_field(self, customfield): def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True) return customfield.to_form_field(for_csv_import=True)

View File

@ -9,7 +9,7 @@ from django.core.validators import ValidationError
from django.db import models from django.db import models
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from extras.choices import ObjectChangeActionChoices from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
from extras.utils import register_features from extras.utils import register_features
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.utils import serialize_object from utilities.utils import serialize_object
@ -100,7 +100,7 @@ class CustomFieldsMixin(models.Model):
""" """
return self.custom_field_data return self.custom_field_data
def get_custom_fields(self): def get_custom_fields(self, omit_hidden=False):
""" """
Return a dictionary of custom fields for a single object in the form `{field: value}`. Return a dictionary of custom fields for a single object in the form `{field: value}`.
@ -114,6 +114,10 @@ class CustomFieldsMixin(models.Model):
data = {} data = {}
for field in CustomField.objects.get_for_model(self): for field in CustomField.objects.get_for_model(self):
# Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
value = self.custom_field_data.get(field.name) value = self.custom_field_data.get(field.name)
data[field] = field.deserialize(value) data[field] = field.deserialize(value)
@ -121,10 +125,10 @@ class CustomFieldsMixin(models.Model):
def get_custom_fields_by_group(self): def get_custom_fields_by_group(self):
""" """
Return a dictionary of custom field/value mappings organized by group. Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted.
""" """
grouped_custom_fields = defaultdict(dict) grouped_custom_fields = defaultdict(dict)
for cf, value in self.get_custom_fields().items(): for cf, value in self.get_custom_fields(omit_hidden=True).items():
grouped_custom_fields[cf.group_name][cf] = value grouped_custom_fields[cf.group_name][cf] = value
return dict(grouped_custom_fields) return dict(grouped_custom_fields)

View File

@ -1,3 +1,4 @@
import hashlib
import importlib import importlib
import logging import logging
import os import os
@ -8,9 +9,11 @@ import sys
import warnings import warnings
from urllib.parse import urlsplit from urllib.parse import urlsplit
import sentry_sdk
from django.contrib.messages import constants as messages from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from sentry_sdk.integrations.django import DjangoIntegration
from netbox.config import PARAMS from netbox.config import PARAMS
@ -40,6 +43,7 @@ if sys.version_info < (3, 8):
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})" f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
) )
DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485'
# #
# Configuration import # Configuration import
@ -68,6 +72,9 @@ DATABASE = getattr(configuration, 'DATABASE')
REDIS = getattr(configuration, 'REDIS') REDIS = getattr(configuration, 'REDIS')
SECRET_KEY = getattr(configuration, 'SECRET_KEY') SECRET_KEY = getattr(configuration, 'SECRET_KEY')
# Calculate a unique deployment ID from the secret key
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
# Set static config parameters # Set static config parameters
ADMINS = getattr(configuration, 'ADMINS', []) ADMINS = getattr(configuration, 'ADMINS', [])
AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [])
@ -77,6 +84,7 @@ if BASE_PATH:
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
@ -113,6 +121,11 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@ -410,6 +423,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
TEST_RUNNER = "django_rich.test.RichRunner"
# Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
EXEMPT_EXCLUDE_MODELS = ( EXEMPT_EXCLUDE_MODELS = (
@ -428,6 +443,36 @@ EXEMPT_PATHS = (
) )
#
# Sentry
#
if SENTRY_ENABLED:
if not SENTRY_DSN:
raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
# If using the default DSN, force sampling rates
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
SENTRY_SAMPLE_RATE = 1.0
SENTRY_TRACES_SAMPLE_RATE = 0
# Initialize the SDK
sentry_sdk.init(
dsn=SENTRY_DSN,
release=VERSION,
integrations=[DjangoIntegration()],
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True,
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
)
# Assign any configured tags
for k, v in SENTRY_TAGS.items():
sentry_sdk.set_tag(k, v)
# If using the default DSN, append a unique deployment ID tag for error correlation
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID)
# #
# Django social auth # Django social auth
# #

View File

@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.db.models import DateField, DateTimeField from django.db.models import DateField, DateTimeField
from django.template import Context, Template from django.template import Context, Template
from django.urls import NoReverseMatch, reverse from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_tables2.columns import library from django_tables2.columns import library
@ -27,6 +27,7 @@ __all__ = (
'CustomLinkColumn', 'CustomLinkColumn',
'LinkedCountColumn', 'LinkedCountColumn',
'MarkdownColumn', 'MarkdownColumn',
'ManyToManyColumn',
'MPTTColumn', 'MPTTColumn',
'TagColumn', 'TagColumn',
'TemplateColumn', 'TemplateColumn',
@ -35,6 +36,10 @@ __all__ = (
) )
#
# Django-tables2 overrides
#
@library.register @library.register
class DateColumn(tables.DateColumn): class DateColumn(tables.DateColumn):
""" """
@ -42,7 +47,6 @@ class DateColumn(tables.DateColumn):
tables and null when exporting data. It is registered in the tables library to use this class instead of the tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateField. default, making this behavior consistent in all fields of type DateField.
""" """
def value(self, value): def value(self, value):
return value return value
@ -59,7 +63,6 @@ class DateTimeColumn(tables.DateTimeColumn):
tables and null when exporting data. It is registered in the tables library to use this class instead of the tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateTimeField. default, making this behavior consistent in all fields of type DateTimeField.
""" """
def value(self, value): def value(self, value):
if value: if value:
return date_format(value, format="SHORT_DATETIME_FORMAT") return date_format(value, format="SHORT_DATETIME_FORMAT")
@ -71,6 +74,52 @@ class DateTimeColumn(tables.DateTimeColumn):
return cls(**kwargs) return cls(**kwargs)
class ManyToManyColumn(tables.ManyToManyColumn):
"""
Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
"""
def value(self, value):
items = [self.transform(item) for item in self.filter(value)]
return self.separator.join(items)
class TemplateColumn(tables.TemplateColumn):
"""
Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
is an empty string.
"""
PLACEHOLDER = mark_safe('&mdash;')
def __init__(self, export_raw=False, **kwargs):
"""
Args:
export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
False)
"""
super().__init__(**kwargs)
self.export_raw = export_raw
def render(self, *args, **kwargs):
ret = super().render(*args, **kwargs)
if not ret.strip():
return self.PLACEHOLDER
return ret
def value(self, **kwargs):
if self.export_raw:
# Skip template rendering and export raw value
return kwargs.get('value')
ret = super().value(**kwargs)
if ret == self.PLACEHOLDER:
return ''
return ret
#
# Custom columns
#
class ToggleColumn(tables.CheckBoxColumn): class ToggleColumn(tables.CheckBoxColumn):
""" """
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
@ -112,26 +161,6 @@ class BooleanColumn(tables.Column):
return str(value) return str(value)
class TemplateColumn(tables.TemplateColumn):
"""
Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
is an empty string.
"""
PLACEHOLDER = mark_safe('&mdash;')
def render(self, *args, **kwargs):
ret = super().render(*args, **kwargs)
if not ret.strip():
return self.PLACEHOLDER
return ret
def value(self, **kwargs):
ret = super().value(**kwargs)
if ret == self.PLACEHOLDER:
return ''
return ret
@dataclass @dataclass
class ActionsItem: class ActionsItem:
title: str title: str
@ -176,32 +205,35 @@ class ActionsColumn(tables.Column):
model = table.Meta.model model = table.Meta.model
request = getattr(table, 'context', {}).get('request') request = getattr(table, 'context', {}).get('request')
url_appendix = f'?return_url={request.path}' if request else '' url_appendix = f'?return_url={request.path}' if request else ''
html = ''
# Compile actions menu
links = [] links = []
user = getattr(request, 'user', AnonymousUser()) user = getattr(request, 'user', AnonymousUser())
for action, attrs in self.actions.items(): for action, attrs in self.actions.items():
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
if attrs.permission is None or user.has_perm(permission): if attrs.permission is None or user.has_perm(permission):
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
links.append(f'<li><a class="dropdown-item" href="{url}{url_appendix}">' links.append(
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>') f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
if not links: )
return '' if links:
html += (
menu = f'<span class="dropdown">' \ f'<span class="dropdown">'
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \ f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">'
f'<i class="mdi mdi-wrench"></i></a>' \ f'<i class="mdi mdi-wrench"></i></a>'
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>' f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
)
# Render any extra buttons from template code # Render any extra buttons from template code
if self.extra_buttons: if self.extra_buttons:
template = Template(self.extra_buttons) template = Template(self.extra_buttons)
context = getattr(table, "context", Context()) context = getattr(table, "context", Context())
context.update({'record': record}) context.update({'record': record})
menu = template.render(context) + menu html = template.render(context) + html
return mark_safe(menu) return mark_safe(html)
class ChoiceFieldColumn(tables.Column): class ChoiceFieldColumn(tables.Column):

View File

@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField
from django_tables2.data import TableQuerysetData from django_tables2.data import TableQuerysetData
from extras.models import CustomField, CustomLink from extras.models import CustomField, CustomLink
from extras.choices import CustomFieldVisibilityChoices
from netbox.tables import columns from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@ -97,7 +98,7 @@ class BaseTable(tables.Table):
break break
if prefetch_path: if prefetch_path:
prefetch_fields.append('__'.join(prefetch_path)) prefetch_fields.append('__'.join(prefetch_path))
self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) self.data.data = self.data.data.prefetch_related(*prefetch_fields)
def _get_columns(self, visible=True): def _get_columns(self, visible=True):
columns = [] columns = []
@ -178,7 +179,10 @@ class NetBoxTable(BaseTable):
# Add custom field & custom link columns # Add custom field & custom link columns
content_type = ContentType.objects.get_for_model(self._meta.model) content_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(content_types=content_type) custom_fields = CustomField.objects.filter(
content_types=content_type
).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
extra_columns.extend([ extra_columns.extend([
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
]) ])

View File

@ -100,4 +100,5 @@ urlpatterns = [
path('{}'.format(settings.BASE_PATH), include(_patterns)) path('{}'.format(settings.BASE_PATH), include(_patterns))
] ]
handler404 = 'netbox.views.handler_404'
handler500 = 'netbox.views.server_error' handler500 = 'netbox.views.server_error'

View File

@ -2,7 +2,6 @@ import platform
import sys import sys
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.db.models import F from django.db.models import F
from django.http import HttpResponseServerError from django.http import HttpResponseServerError
@ -11,9 +10,10 @@ from django.template import loader
from django.template.exceptions import TemplateDoesNotExist from django.template.exceptions import TemplateDoesNotExist
from django.urls import reverse from django.urls import reverse
from django.views.decorators.csrf import requires_csrf_token from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import ERROR_500_TEMPLATE_NAME from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View from django.views.generic import View
from packaging import version from packaging import version
from sentry_sdk import capture_message
from circuits.models import Circuit, Provider from circuits.models import Circuit, Provider
from dcim.models import ( from dcim.models import (
@ -190,13 +190,21 @@ class StaticMediaFailureView(View):
""" """
Display a user-friendly error message with troubleshooting tips when a static media file fails to load. Display a user-friendly error message with troubleshooting tips when a static media file fails to load.
""" """
def get(self, request): def get(self, request):
return render(request, 'media_failure.html', { return render(request, 'media_failure.html', {
'filename': request.GET.get('filename') 'filename': request.GET.get('filename')
}) })
def handler_404(request, exception):
"""
Wrap Django's default 404 handler to enable Sentry reporting.
"""
capture_message("Page not found", level="error")
return page_not_found(request, exception)
@requires_csrf_token @requires_csrf_token
def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
""" """

Binary file not shown.

Binary file not shown.

View File

@ -570,8 +570,9 @@ export class APISelect {
* additional paginated options. * additional paginated options.
*/ */
private handleScroll(): void { private handleScroll(): void {
// Floor scrollTop as chrome can return fractions on some zoom levels.
const atBottom = const atBottom =
this.slim.slim.list.scrollTop + this.slim.slim.list.offsetHeight === Math.floor(this.slim.slim.list.scrollTop) + this.slim.slim.list.offsetHeight ===
this.slim.slim.list.scrollHeight; this.slim.slim.list.scrollHeight;
if (this.atBottom && !atBottom) { if (this.atBottom && !atBottom) {

View File

@ -4,9 +4,10 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
<div class="card"> <div class="card">
<div class="card-body" id="object_list"> <div class="card-body" id="object_list">

View File

@ -4,9 +4,10 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
<div class="card"> <div class="card">
<div class="card-body" id="object_list"> <div class="card-body" id="object_list">

View File

@ -4,9 +4,10 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
<div class="card"> <div class="card">
<div class="card-body" id="object_list"> <div class="card-body" id="object_list">

View File

@ -4,9 +4,10 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
<div class="card"> <div class="card">
<div class="card-body" id="object_list"> <div class="card-body" id="object_list">

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