Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c09259b7d | ||
|
|
7ba268946a | ||
|
|
8074ca95bd | ||
|
|
d691ea92d0 | ||
|
|
903a3e1a9c | ||
|
|
4109113319 | ||
|
|
872c11502f | ||
|
|
10cb4f359a | ||
|
|
45babf162e | ||
|
|
3434428357 | ||
|
|
25128bd06f | ||
|
|
e7620b0dd0 | ||
|
|
65683d0df1 | ||
|
|
ff2ccfd670 | ||
|
|
b1ec703ba9 | ||
|
|
3d785d836d | ||
|
|
7c79c90cd2 | ||
|
|
a6e285316a | ||
|
|
e6018cd38f | ||
|
|
92a6523bf3 | ||
|
|
2815eca260 | ||
|
|
896ebf01b1 | ||
|
|
a71b2e231b | ||
|
|
56f3aaf7c8 | ||
|
|
87a9cc0b9e | ||
|
|
972a1fdd14 | ||
|
|
723954f0d9 | ||
|
|
cf76d5c46a | ||
|
|
e8b970608e | ||
|
|
83fdfaa0eb | ||
|
|
86c35a403a | ||
|
|
e96c382138 | ||
|
|
8ef74192ec | ||
|
|
135ce93d03 | ||
|
|
f13b090b5c | ||
|
|
d1aa820856 | ||
|
|
c81c3d11ed | ||
|
|
36c65b7b22 | ||
|
|
8a4c808be5 | ||
|
|
6ed2dbf172 | ||
|
|
1b8350fe48 | ||
|
|
15080aad66 | ||
|
|
7a7f7c5dec | ||
|
|
c958208c47 | ||
|
|
9f4e565b8e | ||
|
|
bb2d21abdd | ||
|
|
e2eb7fdfb5 | ||
|
|
3fbf1f7e71 | ||
|
|
9d308e6246 | ||
|
|
6c035eb13d | ||
|
|
b0a56a71bb | ||
|
|
201b9f635e | ||
|
|
f1d0d8e57a | ||
|
|
5838a9f3a0 | ||
|
|
998a392bd3 | ||
|
|
a0a87fc4c0 | ||
|
|
6c0b4c66c0 | ||
|
|
2c8a1ed69c | ||
|
|
fe899d9d7c | ||
|
|
6d3cded579 | ||
|
|
2e5a5f71ba | ||
|
|
72516c00fb | ||
|
|
d34d5869be | ||
|
|
72726c784a | ||
|
|
662b02e2d8 | ||
|
|
a9ec1a7b4e | ||
|
|
f03c5037c4 | ||
|
|
a52c68f4c2 | ||
|
|
a73dda35e8 | ||
|
|
0570203891 | ||
|
|
3b3247592e | ||
|
|
17292324a3 | ||
|
|
e5aa9d47f7 | ||
|
|
9e1d8beaf0 | ||
|
|
17fb562740 | ||
|
|
2910aaeec0 | ||
|
|
aeef12cdc0 | ||
|
|
8ad203f97a | ||
|
|
aba4e03d3b | ||
|
|
6a99b36cce | ||
|
|
f415d81049 | ||
|
|
24ff360ee0 | ||
|
|
2a4c728375 | ||
|
|
752a497218 | ||
|
|
1d4409c703 | ||
|
|
3c7c8c8776 | ||
|
|
bb2235b05e | ||
|
|
a6aec9ebac | ||
|
|
5f3695d2d0 | ||
|
|
ad12ad4a77 | ||
|
|
37903776fd | ||
|
|
c4c93ee346 | ||
|
|
72b2ab03cc | ||
|
|
4cefe26f80 | ||
|
|
991950650b | ||
|
|
8cc94689d8 | ||
|
|
312d6c890e | ||
|
|
c146596564 | ||
|
|
6f5c2f1e29 | ||
|
|
1726593fb0 | ||
|
|
e8575495db | ||
|
|
cffc064a33 | ||
|
|
3dda7e2da2 | ||
|
|
22f1863475 | ||
|
|
bdb21da26e | ||
|
|
e759e123ac | ||
|
|
d858eceb38 | ||
|
|
af126fe7e3 | ||
|
|
124e93f737 | ||
|
|
fbd933b56a | ||
|
|
9c5355a300 | ||
|
|
491a4e7d78 | ||
|
|
39a9ebaeee | ||
|
|
9b4e016fe4 | ||
|
|
422ec7ecec | ||
|
|
a06a280534 | ||
|
|
1358469375 | ||
|
|
90d8395a2c | ||
|
|
11f7e3099d | ||
|
|
ef29bffb72 | ||
|
|
3effa37fa7 | ||
|
|
1493c920fd | ||
|
|
ea9258d36c | ||
|
|
db142061ff | ||
|
|
c536944a10 | ||
|
|
ae7ddecaa6 | ||
|
|
2e38e62101 | ||
|
|
2979a64ce3 | ||
|
|
bddca8e232 | ||
|
|
e9bf6a7bc5 | ||
|
|
9c3dfdfd14 | ||
|
|
c52aa2196d | ||
|
|
81c7fe2084 | ||
|
|
0301aec409 | ||
|
|
015bc48345 | ||
|
|
da1aabdfc1 | ||
|
|
c2fe2ba56f | ||
|
|
52b18393eb | ||
|
|
b172ae65d2 | ||
|
|
eab187fb6b | ||
|
|
502a14e820 | ||
|
|
7de27c69c0 | ||
|
|
f455f91ea3 | ||
|
|
bdaefc0e4d | ||
|
|
8040804c75 | ||
|
|
7cd840610b | ||
|
|
15e91908e8 | ||
|
|
0a9ba3b2e6 | ||
|
|
535606a185 | ||
|
|
25c266e4de | ||
|
|
977ccb01f2 | ||
|
|
c2a6a1c125 | ||
|
|
f6402a8b62 | ||
|
|
30d4097fd8 | ||
|
|
3fb967b482 | ||
|
|
9f3846ec5f | ||
|
|
7b5625a722 | ||
|
|
152d5a3b9a | ||
|
|
50b6ded6f0 | ||
|
|
5ee3ee6181 | ||
|
|
cd3e901a60 | ||
|
|
314c41f47f | ||
|
|
a1c1532614 | ||
|
|
ffef78d426 | ||
|
|
8153406dd0 | ||
|
|
b5613a2cc6 | ||
|
|
6a225e53f5 | ||
|
|
6b73d22da1 | ||
|
|
8b81c10f84 | ||
|
|
a6a1bec437 | ||
|
|
562d1bfcd0 | ||
|
|
4f86d6a690 | ||
|
|
e63a191373 | ||
|
|
74b5e55643 | ||
|
|
405d0ab972 | ||
|
|
84e4156259 | ||
|
|
50428c3f01 | ||
|
|
a91c46b4c0 | ||
|
|
8315883db9 | ||
|
|
d22f9000d6 | ||
|
|
bb99cee48a | ||
|
|
a3805fe04d | ||
|
|
d4f1cb5d6a | ||
|
|
118bf5152c | ||
|
|
41244dc677 | ||
|
|
671e1aed9f | ||
|
|
1636508a6a | ||
|
|
bc2491e6b7 | ||
|
|
69a1cc8759 | ||
|
|
c21db0ff6a | ||
|
|
a889b3a4be | ||
|
|
10c7fdb618 | ||
|
|
7be0a1a55f | ||
|
|
a98b2fabe0 | ||
|
|
7779b66972 | ||
|
|
7463c40c40 | ||
|
|
996221147e |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.2.1
|
||||
placeholder: v3.2.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.2.1
|
||||
placeholder: v3.2.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
7
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
@@ -27,7 +27,10 @@ jobs:
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. NetBox
|
||||
is governed by a small group of core maintainers which means not all opened
|
||||
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
issues may receive direct feedback. **Do not** attempt to circumvent this
|
||||
process by "bumping" the issue; doing so will result in its immediate closure
|
||||
and you may be barred from participating in any future discussions. Please see
|
||||
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
stale-pr-label: 'pending closure'
|
||||
stale-pr-message: >
|
||||
This PR has been automatically marked as stale because it has not had
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -99,7 +99,7 @@ appropriate labels will be applied for categorization.
|
||||
## Submitting Pull Requests
|
||||
|
||||
* If you're interested in contributing to NetBox, be sure to check out our
|
||||
[getting started](https://netbox.readthedocs.io/en/stable/development/getting-started/)
|
||||
[getting started](https://docs.netbox.dev/en/stable/development/getting-started/)
|
||||
documentation for tips on setting up your development environment.
|
||||
|
||||
* Be sure to open an issue **before** starting work on a pull request, and
|
||||
@@ -160,9 +160,9 @@ to aid in issue management.
|
||||
|
||||
It is natural that some new issues get more attention than others. The stale
|
||||
bot helps bring renewed attention to potentially valuable issues that may have
|
||||
been overlooked. **Do not** comment on an issue that has been marked stale in
|
||||
an effort to circumvent the bot: Doing so will not remove the stale label.
|
||||
(Stale labels can be removed only by maintainers.)
|
||||
been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
|
||||
effort to circumvent the bot: This will result in the immediate closure of the
|
||||
issue, and you may be barred from participating in future discussions.
|
||||
|
||||
## Maintainer Guidance
|
||||
|
||||
@@ -171,7 +171,7 @@ an effort to circumvent the bot: Doing so will not remove the stale label.
|
||||
the understanding that all contributions are submitted under the Apache 2.0
|
||||
license and that your employer may not make claim to any contributions.
|
||||
Contributions include code work, issue management, and community support. All
|
||||
development must be in accordance with our [development guidance](https://netbox.readthedocs.io/en/stable/development/).
|
||||
development must be in accordance with our [development guidance](https://docs.netbox.dev/en/stable/development/).
|
||||
|
||||
* Maintainers are expected to attend (where feasible) our biweekly ~30-minute
|
||||
sync to review agenda items. This meeting provides opportunity to present and
|
||||
|
||||
@@ -49,7 +49,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com
|
||||
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
|
||||
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). A public demo instance is available at https://demo.netbox.dev.
|
||||
The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/). A public demo instance is available at https://demo.netbox.dev.
|
||||
|
||||
<div align="center">
|
||||
<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
|
||||
|
||||
[](https://ns1.com/)
|
||||
<br />
|
||||
[](https://sentry.io/)
|
||||
|
||||
[](https://stellar.tech/)
|
||||
|
||||
</div>
|
||||
@@ -71,7 +73,7 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
|
||||
|
||||
### Installation
|
||||
|
||||
Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
|
||||
Please see [the documentation](https://docs.netbox.dev/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the
|
||||
[latest release](https://github.com/netbox-community/netbox/releases) and
|
||||
run `upgrade.sh`.
|
||||
|
||||
31
SECURITY.md
Normal 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.
|
||||
@@ -44,7 +44,8 @@ django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/alex/django-taggit
|
||||
django-taggit
|
||||
# Will evaluate v3.0 during NetBox v3.3 beta
|
||||
django-taggit>=2.1.0,<3.0
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
@@ -68,8 +69,7 @@ gunicorn
|
||||
|
||||
# Platform-agnostic template rendering engine
|
||||
# https://github.com/pallets/jinja
|
||||
# Pin to v3.0 for mkdocstrings
|
||||
Jinja2<3.1
|
||||
Jinja2
|
||||
|
||||
# Simple markup language for rendering HTML
|
||||
# https://github.com/Python-Markdown/markdown
|
||||
@@ -85,7 +85,7 @@ mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings
|
||||
mkdocstrings<=0.17.0
|
||||
mkdocstrings[python-legacy]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr
|
||||
@@ -103,6 +103,10 @@ psycopg2-binary
|
||||
# https://github.com/yaml/pyyaml
|
||||
PyYAML
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python
|
||||
sentry-sdk
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core
|
||||
social-auth-core
|
||||
@@ -122,3 +126,7 @@ tablib
|
||||
# Timezone data (required by django-timezone-field on Python 3.9+)
|
||||
# https://github.com/python/tzdata
|
||||
tzdata
|
||||
|
||||
# HTML sanitizer
|
||||
# https://github.com/mozilla/bleach
|
||||
bleach
|
||||
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=NetBox Request Queue Worker
|
||||
Documentation=https://netbox.readthedocs.io/en/stable/
|
||||
Documentation=https://docs.netbox.dev/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=NetBox WSGI Service
|
||||
Documentation=https://netbox.readthedocs.io/en/stable/
|
||||
Documentation=https://docs.netbox.dev/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
88
docs/administration/authentication/microsoft-azure-ad.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Microsoft Azure AD
|
||||
|
||||
This guide explains how to configure single sign-on (SSO) support for NetBox using [Microsoft Azure Active Directory (AD)](https://azure.microsoft.com/en-us/services/active-directory/) as an authentication backend.
|
||||
|
||||
## Azure AD Configuration
|
||||
|
||||
### 1. Create a test user (optional)
|
||||
|
||||
Create a new user in AD to be used for testing. You can skip this step if you already have a suitable account created.
|
||||
|
||||
### 2. Create an app registration
|
||||
|
||||
Under the Azure Active Directory dashboard, navigate to **Add > App registration**.
|
||||
|
||||

|
||||
|
||||
Enter a name for the registration (e.g. "NetBox") and ensure that the "single tenant" option is selected.
|
||||
|
||||
Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/azuread-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes).
|
||||
|
||||

|
||||
|
||||
Once finished, make note of the application (client) ID; this will be used when configuring NetBox.
|
||||
|
||||

|
||||
|
||||
!!! tip "Multitenant authentication"
|
||||
NetBox also supports multitenant authentication via Azure AD, however it requires a different backend and an additional configuration parameter. Please see the [`python-social-auth` documentation](https://python-social-auth.readthedocs.io/en/latest/backends/azuread.html#tenant-support) for details concerning multitenant authentication.
|
||||
|
||||
### 3. Create a secret
|
||||
|
||||
When viewing the newly-created app registration, click the "Add a certificate or secret" link under "Client credentials". Under the "Client secrets" tab, click the "New client secret" button.
|
||||
|
||||

|
||||
|
||||
You can optionally specify a description and select a lifetime for the secret.
|
||||
|
||||

|
||||
|
||||
Once finished, make note of the secret value (not the secret ID); this will be used when configuring NetBox.
|
||||
|
||||

|
||||
|
||||
## NetBox Configuration
|
||||
|
||||
### 1. Enter configuration parameters
|
||||
|
||||
Enter the following configuration parameters in `configuration.py`, substituting your own values:
|
||||
|
||||
```python
|
||||
REMOTE_AUTH_BACKEND = 'social_core.backends.azuread.AzureADOAuth2'
|
||||
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY = '{APPLICATION_ID}'
|
||||
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET = '{SECRET_VALUE}'
|
||||
```
|
||||
|
||||
### 2. Restart NetBox
|
||||
|
||||
Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Azure AD. Click that link.
|
||||
|
||||

|
||||
|
||||
You should be redirected to Microsoft's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account.
|
||||
|
||||

|
||||
|
||||
If successful, you will be redirected back to the NetBox UI, and will be logged in as the AD user. You can verify this by navigating to your profile (using the button at top right).
|
||||
|
||||
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Redirect URI does not Match
|
||||
|
||||
Azure requires that the authenticating client request a redirect URI that matches what you've configured for the app in step two. This URI **must** begin with `https://` (unless using `localhost` for the domain).
|
||||
|
||||
If Azure complains that the requested URI starts with `http://` (not HTTPS), it's likely that your HTTP server is misconfigured or sitting behind a load balancer, so NetBox is not aware that HTTPS is being use. To force the use of an HTTPS redirect URI, set `SOCIAL_AUTH_REDIRECT_IS_HTTPS = True` in `configuration.py` per the [python-social-auth docs](https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html#processing-redirects-and-urlopen).
|
||||
|
||||
### Not Logged in After Authenticating
|
||||
|
||||
If you are redirected to the NetBox UI after authenticating successfully, but are _not_ logged in, double-check the configured backend and app registration. The instructions in this guide pertain only to the `azuread.AzureADOAuth2` backend using a single-tenant app registration.
|
||||
70
docs/administration/authentication/okta.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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/`
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Local user accounts and groups can be created in NetBox under the "Authentication and Authorization" section of the administrative user interface. This interface is available only to users with the "staff" permission enabled.
|
||||
|
||||
At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](./permissions.md) may also be assigned to users and/or groups within the admin UI.
|
||||
At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](../permissions.md) may also be assigned to users and/or groups within the admin UI.
|
||||
|
||||
## Remote Authentication
|
||||
|
||||
@@ -16,7 +16,7 @@ NetBox may be configured to provide user authenticate via a remote backend in ad
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
|
||||
```
|
||||
|
||||
NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../installation/6-ldap.md) for more detail about this backend.
|
||||
NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../../installation/6-ldap.md) for more detail about this backend.
|
||||
|
||||
### HTTP Header Authentication
|
||||
|
||||
46
docs/administration/error-reporting.md
Normal 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.
|
||||
54
docs/configuration/error-reporting.md
Normal 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).
|
||||
@@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [
|
||||
|
||||
---
|
||||
|
||||
## CSRF_COOKIE_NAME
|
||||
|
||||
Default: `csrftoken`
|
||||
|
||||
The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail.
|
||||
|
||||
---
|
||||
|
||||
## CSRF_TRUSTED_ORIGINS
|
||||
|
||||
Default: `[]`
|
||||
@@ -247,6 +255,23 @@ HTTP_PROXIES = {
|
||||
|
||||
---
|
||||
|
||||
## JINJA2_FILTERS
|
||||
|
||||
Default: `{}`
|
||||
|
||||
A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
|
||||
|
||||
```python
|
||||
def uppercase(x):
|
||||
return str(x).upper()
|
||||
|
||||
JINJA2_FILTERS = {
|
||||
'uppercase': uppercase,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## INTERNAL_IPS
|
||||
|
||||
Default: `('127.0.0.1', '::1')`
|
||||
|
||||
@@ -105,11 +105,11 @@ from my_validators import Validator1, Validator2, Validator3
|
||||
|
||||
CUSTOM_VALIDATORS = {
|
||||
'dcim.site': (
|
||||
Validator1,
|
||||
Validator2,
|
||||
Validator1(),
|
||||
Validator2(),
|
||||
),
|
||||
'dcim.device': (
|
||||
Validator3,
|
||||
Validator3(),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -40,7 +40,7 @@ You should see output similar to the following:
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Docs: https://docs.netbox.dev/
|
||||
Main PID: 1140492 (gunicorn)
|
||||
Tasks: 19 (limit: 4683)
|
||||
Memory: 666.2M
|
||||
|
||||
@@ -39,7 +39,7 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Docs: https://docs.netbox.dev/
|
||||
Main PID: 11993 (gunicorn)
|
||||
Tasks: 6 (limit: 2362)
|
||||
CGroup: /system.slice/netbox.service
|
||||
|
||||
BIN
docs/media/authentication/azure_ad_add_app_registration.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/media/authentication/azure_ad_add_client_secret.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/media/authentication/azure_ad_app_registration.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/media/authentication/azure_ad_app_registration_created.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/media/authentication/azure_ad_client_secret.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
docs/media/authentication/azure_ad_client_secret_created.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/media/authentication/azure_ad_login_portal.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/media/authentication/netbox_azure_ad_login.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/media/authentication/netbox_okta_login.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/media/authentication/okta_create_app_registration.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/media/authentication/okta_integration_parameters.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/media/authentication/okta_login_portal.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/media/authentication/okta_web_app_integration.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
@@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl
|
||||
|
||||
::: netbox.tables.TemplateColumn
|
||||
selection:
|
||||
members: false
|
||||
members:
|
||||
- __init__
|
||||
|
||||
353
docs/reference/markdown.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
hide:
|
||||
- toc
|
||||
---
|
||||
|
||||
# Markdown
|
||||
|
||||
NetBox supports markdown rendering for certain text fields.
|
||||
|
||||
## Syntax
|
||||
|
||||
##### Table of Contents
|
||||
[Headers](#headers)
|
||||
[Emphasis](#emphasis)
|
||||
[Lists](#lists)
|
||||
[Links](#links)
|
||||
[Images](#images)
|
||||
[Code Blocks](#code)
|
||||
[Tables](#tables)
|
||||
[Blockquotes](#blockquotes)
|
||||
[Inline HTML](#html)
|
||||
[Horizontal Rule](#hr)
|
||||
[Line Breaks](#lines)
|
||||
|
||||
<a name="headers"></a>
|
||||
|
||||
## Headers
|
||||
|
||||
```no-highlight
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
|
||||
Alternatively, for H1 and H2, an underline-ish style:
|
||||
|
||||
Alt-H1
|
||||
======
|
||||
|
||||
Alt-H2
|
||||
------
|
||||
```
|
||||
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
|
||||
<a name="emphasis"></a>
|
||||
|
||||
## Emphasis
|
||||
|
||||
```no-highlight
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
```
|
||||
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
|
||||
|
||||
<a name="lists"></a>
|
||||
|
||||
## Lists
|
||||
|
||||
(In this example, leading and trailing spaces are shown with with dots: ⋅)
|
||||
|
||||
```no-highlight
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
⋅⋅* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
⋅⋅1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅
|
||||
⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅
|
||||
⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
```
|
||||
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
To have a line break without a paragraph, you will need to use two trailing spaces.
|
||||
Note that this line is separate, but within the same paragraph.
|
||||
(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
|
||||
<a name="links"></a>
|
||||
|
||||
## Links
|
||||
|
||||
There are two ways to create links.
|
||||
|
||||
```no-highlight
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
```
|
||||
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
|
||||
<a name="images"></a>
|
||||
|
||||
## Images
|
||||
|
||||
```
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
```
|
||||
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
|
||||
<a name="code"></a>
|
||||
|
||||
## Code blocks
|
||||
|
||||
```
|
||||
Inline `code` has `back-ticks around` it.
|
||||
```
|
||||
|
||||
Inline `code` has `back-ticks around` it.
|
||||
|
||||
Blocks of code are fenced by lines with three back-ticks <code>```</code>
|
||||
|
||||
````
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
````
|
||||
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
|
||||
<a name="tables"></a>
|
||||
|
||||
## Tables
|
||||
|
||||
```no-highlight
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell.
|
||||
The outer pipes (|) are optional, and you don't need to make the
|
||||
raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
```
|
||||
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
|
||||
<a name="blockquotes"></a>
|
||||
|
||||
## Blockquotes
|
||||
|
||||
```no-highlight
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
```
|
||||
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
|
||||
<a name="html"></a>
|
||||
|
||||
## Inline HTML
|
||||
|
||||
You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
|
||||
|
||||
```no-highlight
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
|
||||
<a name="hr"></a>
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
```
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
```
|
||||
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
|
||||
<a name="lines"></a>
|
||||
|
||||
## Line Breaks
|
||||
|
||||
|
||||
```
|
||||
Here's a line for us to start with.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
|
||||
This line is also a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
```
|
||||
|
||||
Here's a line for us to start with.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
|
||||
This line is also begins a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
|
||||
Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/)
|
||||
@@ -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))
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre
|
||||
|
||||
#### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
|
||||
|
||||
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
|
||||
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://docs.netbox.dev/en/stable/miscellaneous/reports/) for more info.
|
||||
|
||||
### Enhancements
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ This release upgrades the Django framework to version 2.2.
|
||||
|
||||
#### Python 3 Required
|
||||
|
||||
As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://netbox.readthedocs.io/en/stable/installation/migrating-to-python3/) for assistance with upgrading.
|
||||
As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://docs.netbox.dev/en/stable/installation/migrating-to-python3/) for assistance with upgrading.
|
||||
|
||||
#### Removed Deprecated User Activity Log
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
|
||||
#### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415))
|
||||
|
||||
Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/customization/custom-scripts/) for more detail.
|
||||
Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://docs.netbox.dev/en/stable/customization/custom-scripts/) for more detail.
|
||||
|
||||
Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release.
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
## v2.7.9 (2020-03-06)
|
||||
|
||||
**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://netbox.readthedocs.io/en/stable/installation/upgrading/).
|
||||
**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://docs.netbox.dev/en/stable/installation/upgrading/).
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -418,7 +418,7 @@ to another source before upgrading NetBox to v2.7, as any existing topology maps
|
||||
|
||||
#### Supervisor Replaced with systemd ([#2902](https://github.com/netbox-community/netbox/issues/2902))
|
||||
|
||||
The NetBox [installation documentation](https://netbox.readthedocs.io/en/stable/installation/) has been updated to
|
||||
The NetBox [installation documentation](https://docs.netbox.dev/en/stable/installation/) has been updated to
|
||||
provide instructions for managing the WSGI and RQ services using systemd instead of supervisor. This removes the need to
|
||||
install supervisor and simplifies administration of the processes.
|
||||
|
||||
|
||||
@@ -235,14 +235,14 @@ This release introduces support for custom plugins, which can be used to extend
|
||||
* Introduce new API endpoints
|
||||
* Add custom request/response middleware
|
||||
|
||||
For NetBox plugins to be recognized, they must be installed and added by name to the `PLUGINS` configuration parameter. (Plugin support is disabled by default.) Plugins can be configured under the `PLUGINS_CONFIG` parameter. More information can be found the in the [plugins documentation](https://netbox.readthedocs.io/en/stable/plugins/).
|
||||
For NetBox plugins to be recognized, they must be installed and added by name to the `PLUGINS` configuration parameter. (Plugin support is disabled by default.) Plugins can be configured under the `PLUGINS_CONFIG` parameter. More information can be found the in the [plugins documentation](https://docs.netbox.dev/en/stable/plugins/).
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups
|
||||
* [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups
|
||||
* [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models
|
||||
* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#logging))
|
||||
* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://docs.netbox.dev/en/stable/configuration/optional-settings/#logging))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
||||
@@ -1,5 +1,120 @@
|
||||
# NetBox v3.2
|
||||
|
||||
## v3.2.5 (2022-06-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list
|
||||
* [#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
|
||||
* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module
|
||||
* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters
|
||||
* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view
|
||||
* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables
|
||||
* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation
|
||||
* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms
|
||||
* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons
|
||||
* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links
|
||||
* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data
|
||||
* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure
|
||||
* [#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
|
||||
* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs
|
||||
* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN
|
||||
* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form
|
||||
* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI
|
||||
* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view
|
||||
|
||||
---
|
||||
|
||||
## v3.2.4 (2022-05-31)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated
|
||||
* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view
|
||||
* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports
|
||||
* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment
|
||||
* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter
|
||||
* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search
|
||||
* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device
|
||||
* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters
|
||||
* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields
|
||||
* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view
|
||||
* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed
|
||||
* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis
|
||||
* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list
|
||||
* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance
|
||||
* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
|
||||
* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
|
||||
|
||||
---
|
||||
|
||||
## v3.2.3 (2022-05-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8805](https://github.com/netbox-community/netbox/issues/8805) - Add "mixed" option for device airflow indication
|
||||
* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users
|
||||
* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group
|
||||
* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade
|
||||
* [#9221](https://github.com/netbox-community/netbox/issues/9221) - Add definition list support for Markdown
|
||||
* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views
|
||||
* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list
|
||||
* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module
|
||||
* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments
|
||||
* [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry
|
||||
* [#9343](https://github.com/netbox-community/netbox/issues/9343) - Add Ubiquiti SmartPower power outlet type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device
|
||||
* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices
|
||||
* [#9296](https://github.com/netbox-community/netbox/issues/9296) - Improve Markdown link sanitization
|
||||
* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface
|
||||
* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API
|
||||
* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships
|
||||
* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates
|
||||
|
||||
---
|
||||
|
||||
## v3.2.2 (2022-04-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9060](https://github.com/netbox-community/netbox/issues/9060) - Add device type filters for device bays, module bays, and inventory items
|
||||
* [#9152](https://github.com/netbox-community/netbox/issues/9152) - Annotate related object type under custom field view
|
||||
* [#9192](https://github.com/netbox-community/netbox/issues/9192) - Add Ubiquiti SmartPower connector type
|
||||
* [#9214](https://github.com/netbox-community/netbox/issues/9214) - Linkify cluster counts in cluster type & group tables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4264](https://github.com/netbox-community/netbox/issues/4264) - Treat 0th IP as unusable for IPv6 prefixes (excluding /127s)
|
||||
* [#8941](https://github.com/netbox-community/netbox/issues/8941) - Fix dynamic dropdown behavior when browser is zoomed
|
||||
* [#8959](https://github.com/netbox-community/netbox/issues/8959) - Prevent exception when refreshing scripts list (avoid race condition)
|
||||
* [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link
|
||||
* [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later
|
||||
* [#9138](https://github.com/netbox-community/netbox/issues/9138) - Avoid inadvertent form submission when utilizing quick search field on object lists
|
||||
* [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view
|
||||
* [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures
|
||||
* [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment
|
||||
* [#9194](https://github.com/netbox-community/netbox/issues/9194) - Support position assignment when add module bays to multiple devices
|
||||
* [#9206](https://github.com/netbox-community/netbox/issues/9206) - Show header for comments field under module & module type creation views
|
||||
* [#9222](https://github.com/netbox-community/netbox/issues/9222) - Fix circuit ID display under cable view
|
||||
* [#9227](https://github.com/netbox-community/netbox/issues/9227) - Fix related object assignment when recording change record for interfaces
|
||||
|
||||
---
|
||||
|
||||
## v3.2.1 (2022-04-14)
|
||||
|
||||
### Enhancements
|
||||
|
||||
11
mkdocs.yml
@@ -1,6 +1,6 @@
|
||||
site_name: NetBox Documentation
|
||||
site_dir: netbox/project-static/docs
|
||||
site_url: https://netbox.readthedocs.io/
|
||||
site_url: https://docs.netbox.dev/
|
||||
repo_name: netbox-community/netbox
|
||||
repo_url: https://github.com/netbox-community/netbox
|
||||
theme:
|
||||
@@ -19,6 +19,7 @@ theme:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Light Mode
|
||||
plugins:
|
||||
- search
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
@@ -72,6 +73,7 @@ nav:
|
||||
- Required Settings: 'configuration/required-settings.md'
|
||||
- Optional Settings: 'configuration/optional-settings.md'
|
||||
- Dynamic Settings: 'configuration/dynamic-settings.md'
|
||||
- Error Reporting: 'configuration/error-reporting.md'
|
||||
- Remote Authentication: 'configuration/remote-authentication.md'
|
||||
- Core Functionality:
|
||||
- IP Address Management: 'core-functionality/ipam.md'
|
||||
@@ -117,8 +119,12 @@ nav:
|
||||
- GraphQL API: 'plugins/development/graphql-api.md'
|
||||
- Background Tasks: 'plugins/development/background-tasks.md'
|
||||
- Administration:
|
||||
- Authentication: 'administration/authentication.md'
|
||||
- Authentication:
|
||||
- Overview: 'administration/authentication/overview.md'
|
||||
- Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md'
|
||||
- Okta: 'administration/authentication/okta.md'
|
||||
- Permissions: 'administration/permissions.md'
|
||||
- Error Reporting: 'administration/error-reporting.md'
|
||||
- Housekeeping: 'administration/housekeeping.md'
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
- NetBox Shell: 'administration/netbox-shell.md'
|
||||
@@ -130,6 +136,7 @@ nav:
|
||||
- Overview: 'graphql-api/overview.md'
|
||||
- Reference:
|
||||
- Conditions: 'reference/conditions.md'
|
||||
- Markdown: 'reference/markdown.md'
|
||||
- Development:
|
||||
- Introduction: 'development/index.md'
|
||||
- Getting Started: 'development/getting-started.md'
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('ASN', ('asn',)),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
('Attributes', ('type_id', 'status', 'commit_rate')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
|
||||
@@ -59,7 +59,7 @@ class CircuitTable(NetBoxTable):
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
asns = tables.ManyToManyColumn(
|
||||
asns = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='ASNs'
|
||||
)
|
||||
@@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable):
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -32,7 +32,7 @@ class ProviderView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
|
||||
circuits_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -93,7 +93,7 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user)
|
||||
circuits_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -147,7 +147,7 @@ class CircuitTypeView(generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',))
|
||||
circuits_table.configure(request)
|
||||
|
||||
return {
|
||||
|
||||
@@ -315,7 +315,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
|
||||
|
||||
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -325,13 +334,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -341,13 +360,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -357,14 +386,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
|
||||
'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
|
||||
'allocated_draw', 'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
allow_blank=True,
|
||||
@@ -383,48 +421,75 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created',
|
||||
'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class RearPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
|
||||
'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
|
||||
'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_type = NestedDeviceTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
module_type = NestedModuleTypeSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
rear_port = NestedRearPortTemplateSerializer()
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
|
||||
'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
|
||||
'rear_port_position', 'description', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.config import get_config
|
||||
from utilities.api import get_serializer_for_model
|
||||
@@ -392,6 +393,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
)
|
||||
filterset_class = filtersets.DeviceFilterSet
|
||||
pagination_class = StripCountAnnotationsPaginator
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
|
||||
@@ -159,6 +159,7 @@ class DeviceAirflowChoices(ChoiceSet):
|
||||
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
|
||||
AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
|
||||
AIRFLOW_PASSIVE = 'passive'
|
||||
AIRFLOW_MIXED = 'mixed'
|
||||
|
||||
CHOICES = (
|
||||
(AIRFLOW_FRONT_TO_REAR, 'Front to rear'),
|
||||
@@ -167,6 +168,7 @@ class DeviceAirflowChoices(ChoiceSet):
|
||||
(AIRFLOW_RIGHT_TO_LEFT, 'Right to left'),
|
||||
(AIRFLOW_SIDE_TO_REAR, 'Side to rear'),
|
||||
(AIRFLOW_PASSIVE, 'Passive'),
|
||||
(AIRFLOW_MIXED, 'Mixed'),
|
||||
)
|
||||
|
||||
|
||||
@@ -349,8 +351,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32'
|
||||
TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
|
||||
TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
|
||||
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -464,9 +468,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
|
||||
(TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
|
||||
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
|
||||
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -573,8 +579,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a'
|
||||
TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
|
||||
TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
|
||||
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -681,9 +689,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
|
||||
(TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
|
||||
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
|
||||
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -1041,6 +1051,7 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_URM_P2 = 'urm-p2'
|
||||
TYPE_URM_P4 = 'urm-p4'
|
||||
TYPE_URM_P8 = 'urm-p8'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
@@ -1093,6 +1104,12 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_URM_P4, 'URM-P4'),
|
||||
(TYPE_URM_P8, 'URM-P8'),
|
||||
(TYPE_SPLICE, 'Splice'),
|
||||
),
|
||||
),
|
||||
(
|
||||
'Other',
|
||||
(
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -62,6 +62,8 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
|
||||
# Device components
|
||||
#
|
||||
|
||||
MODULE_TOKEN = '{module}'
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
||||
app_label='dcim',
|
||||
model__in=(
|
||||
|
||||
@@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
qs_filter |= Q(asns__asn=int(value.strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
||||
@@ -346,6 +346,32 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='rack__site__region',
|
||||
lookup_expr='in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='rack__site__region',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='rack__site__group',
|
||||
lookup_expr='in',
|
||||
label='Site group (ID)',
|
||||
)
|
||||
site_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='rack__site__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label='Site group (slug)',
|
||||
)
|
||||
location_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Location.objects.all(),
|
||||
field_name='rack__location',
|
||||
@@ -435,6 +461,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
||||
method='_device_bays',
|
||||
label='Has device bays',
|
||||
)
|
||||
inventory_items = django_filters.BooleanFilter(
|
||||
method='_inventory_items',
|
||||
label='Has inventory items',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
@@ -479,6 +509,9 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
||||
def _device_bays(self, queryset, name, value):
|
||||
return queryset.exclude(devicebaytemplates__isnull=value)
|
||||
|
||||
def _inventory_items(self, queryset, name, value):
|
||||
return queryset.exclude(inventoryitemtemplates__isnull=value)
|
||||
|
||||
|
||||
class ModuleTypeFilterSet(NetBoxModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
|
||||
@@ -3,7 +3,7 @@ from django import forms
|
||||
from dcim.models import *
|
||||
from extras.forms import CustomFieldsMixin
|
||||
from extras.models import Tag
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
|
||||
from .object_create import ComponentCreateForm
|
||||
|
||||
__all__ = (
|
||||
@@ -98,7 +98,13 @@ class RearPortBulkCreateForm(
|
||||
|
||||
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
model = ModuleBay
|
||||
field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
|
||||
field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags')
|
||||
|
||||
position_pattern = ExpandableNameField(
|
||||
label='Position',
|
||||
required=False,
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
|
||||
@@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, VLAN, VRF
|
||||
from ipam.models import ASN, VLAN, VLANGroup, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfaceModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label='VLAN group'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Untagged VLAN'
|
||||
)
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Tagged VLANs'
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm(
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
|
||||
'vrf',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
|
||||
'tagged_vlans', 'vrf',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Region
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag', 'parent_id')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = SiteGroup
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag', 'parent_id')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
@@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=SiteStatusChoices,
|
||||
@@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
||||
(None, ('q', 'tag')),
|
||||
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -210,11 +210,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_id', 'location_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Function', ('status', 'role_id')),
|
||||
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -229,6 +229,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
@@ -282,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('User', ('user_id',)),
|
||||
('Rack', ('region_id', 'site_id', 'location_id')),
|
||||
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -298,6 +303,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.prefetch_related('site'),
|
||||
required=False,
|
||||
@@ -319,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Manufacturer
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -331,7 +341,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
|
||||
('Components', (
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
||||
'pass_through_ports',
|
||||
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
|
||||
)),
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
@@ -392,6 +402,27 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
device_bays = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has device bays',
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
module_bays = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has module bays',
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
inventory_items = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has inventory items',
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -487,7 +518,7 @@ class DeviceFilterForm(
|
||||
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
|
||||
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
('Components', (
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
|
||||
)),
|
||||
@@ -757,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1071,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -385,6 +385,12 @@ class ModuleTypeForm(NetBoxModelForm):
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('Module Type', (
|
||||
'manufacturer', 'model', 'part_number', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleType
|
||||
fields = [
|
||||
@@ -627,11 +633,26 @@ class ModuleForm(NetBoxModelForm):
|
||||
help_text="Automatically populate components associated with this module type"
|
||||
)
|
||||
|
||||
adopt_components = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text="Adopt already existing components"
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Module', (
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'tags',
|
||||
)),
|
||||
('Hardware', (
|
||||
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = [
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
|
||||
'replicate_components', 'comments',
|
||||
'replicate_components', 'adopt_components', 'comments',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -640,6 +661,8 @@ class ModuleForm(NetBoxModelForm):
|
||||
if self.instance.pk:
|
||||
self.fields['replicate_components'].initial = False
|
||||
self.fields['replicate_components'].disabled = True
|
||||
self.fields['adopt_components'].initial = False
|
||||
self.fields['adopt_components'].disabled = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -647,8 +670,62 @@ class ModuleForm(NetBoxModelForm):
|
||||
if self.instance.pk or not self.cleaned_data['replicate_components']:
|
||||
self.instance._disable_replication = True
|
||||
|
||||
if self.cleaned_data['adopt_components']:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get("replicate_components")
|
||||
adopt_components = self.cleaned_data.get("adopt_components")
|
||||
device = self.cleaned_data['device']
|
||||
module_type = self.cleaned_data['module_type']
|
||||
module_bay = self.cleaned_data['module_bay']
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components
|
||||
if self.instance.pk or not replicate_components:
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
|
||||
class CableForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
@@ -1269,6 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
'rf_channel_width': "Populated by selected channel (if set)",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Restrict LAG/bridge interface assignment by device/VC
|
||||
device_id = self.data['device'] if self.is_bound else self.initial.get('device')
|
||||
device = Device.objects.filter(pk=device_id).first()
|
||||
if device and device.virtual_chassis and device.virtual_chassis.master:
|
||||
self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
|
||||
self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
|
||||
|
||||
|
||||
class FrontPortForm(NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
|
||||
@@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
|
||||
raise forms.ValidationError({
|
||||
'initial_position': "A position must be specified for the first VC member."
|
||||
|
||||
@@ -121,12 +121,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
|
||||
def resolve_name(self, module):
|
||||
if module:
|
||||
return self.name.replace('{module}', module.module_bay.position)
|
||||
return self.name.replace(MODULE_TOKEN, module.module_bay.position)
|
||||
return self.name
|
||||
|
||||
def resolve_label(self, module):
|
||||
if module:
|
||||
return self.label.replace('{module}', module.module_bay.position)
|
||||
return self.label.replace(MODULE_TOKEN, module.module_bay.position)
|
||||
return self.label
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class ComponentModel(NetBoxModel):
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
objectchange.related_object = self.device
|
||||
return super().to_objectchange(action)
|
||||
return objectchange
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
@@ -543,7 +543,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
)
|
||||
speed = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
null=True,
|
||||
verbose_name='Speed (Kbps)'
|
||||
)
|
||||
duplex = models.CharField(
|
||||
max_length=50,
|
||||
|
||||
@@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
return f'{self.name} ({self.asset_tag})'
|
||||
elif self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis and self.asset_tag:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
|
||||
elif self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
|
||||
elif self.device_type and self.asset_tag:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
||||
elif self.device_type:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
|
||||
return super().__str__()
|
||||
@@ -1065,30 +1069,52 @@ class Module(NetBoxModel, ConfigContextModel):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If this is a new Module and component replication has not been disabled, instantiate all its
|
||||
# related components per the ModuleType definition
|
||||
if is_new and not getattr(self, '_disable_replication', False):
|
||||
ConsolePort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()]
|
||||
)
|
||||
PowerPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()]
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()]
|
||||
)
|
||||
Interface.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()]
|
||||
)
|
||||
RearPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()]
|
||||
)
|
||||
FrontPort.objects.bulk_create(
|
||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()]
|
||||
)
|
||||
adopt_components = getattr(self, '_adopt_components', False)
|
||||
disable_replication = getattr(self, '_disable_replication', False)
|
||||
|
||||
# We skip adding components if the module is being edited or
|
||||
# both replication and component adoption is disabled
|
||||
if not is_new or (disable_replication and not adopt_components):
|
||||
return
|
||||
|
||||
# Iterate all component types
|
||||
for templates, component_attribute, component_model in [
|
||||
("consoleporttemplates", "consoleports", ConsolePort),
|
||||
("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
|
||||
("interfacetemplates", "interfaces", Interface),
|
||||
("powerporttemplates", "powerports", PowerPort),
|
||||
("poweroutlettemplates", "poweroutlets", PowerOutlet),
|
||||
("rearporttemplates", "rearports", RearPort),
|
||||
("frontporttemplates", "frontports", FrontPort)
|
||||
]:
|
||||
create_instances = []
|
||||
update_instances = []
|
||||
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True)
|
||||
}
|
||||
|
||||
# Get the template for the module type.
|
||||
for template in getattr(self.module_type, templates).all():
|
||||
template_instance = template.instantiate(device=self.device, module=self)
|
||||
|
||||
if adopt_components:
|
||||
existing_item = installed_components.get(template_instance.name)
|
||||
|
||||
# Check if there's a component with the same name already
|
||||
if existing_item:
|
||||
# Assign it to the module
|
||||
existing_item.module = self
|
||||
update_instances.append(existing_item)
|
||||
continue
|
||||
|
||||
# Only create new components if replication is enabled
|
||||
if not disable_replication:
|
||||
create_instances.append(template_instance)
|
||||
|
||||
component_model.objects.bulk_create(create_instances)
|
||||
component_model.objects.bulk_update(update_instances, ['module'])
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -114,7 +114,7 @@ class RackElevationSVG:
|
||||
# Embed front device type image if one exists
|
||||
if self.include_images and device.device_type.front_image:
|
||||
image = drawing.image(
|
||||
href=device.device_type.front_image.url,
|
||||
href='{}{}'.format(self.base_url, device.device_type.front_image.url),
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
@@ -140,7 +140,7 @@ class RackElevationSVG:
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
image = drawing.image(
|
||||
href=device.device_type.rear_image.url,
|
||||
href='{}{}'.format(self.base_url, device.device_type.rear_image.url),
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
@@ -151,9 +151,9 @@ class RackElevationSVG:
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
@staticmethod
|
||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link_url = '{}?{}'.format(
|
||||
def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link_url = '{}{}?{}'.format(
|
||||
self.base_url,
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({
|
||||
'site': rack.site.pk,
|
||||
|
||||
@@ -190,7 +190,7 @@ class DeviceTable(NetBoxTable):
|
||||
verbose_name='VC Priority'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -31,7 +31,9 @@ class ManufacturerTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
devicetype_count = tables.Column(
|
||||
devicetype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:devicetype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name='Device Types'
|
||||
)
|
||||
inventoryitem_count = tables.Column(
|
||||
@@ -41,7 +43,7 @@ class ManufacturerTable(NetBoxTable):
|
||||
verbose_name='Platforms'
|
||||
)
|
||||
slug = tables.Column()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -26,7 +26,7 @@ class PowerPanelTable(NetBoxTable):
|
||||
url_params={'power_panel_id': 'pk'},
|
||||
verbose_name='Feeds'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -69,7 +69,7 @@ class RackTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -26,7 +26,7 @@ class RegionTable(NetBoxTable):
|
||||
url_params={'region_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
@@ -55,7 +55,7 @@ class SiteGroupTable(NetBoxTable):
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
@@ -86,7 +86,7 @@ class SiteTable(NetBoxTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
asns = tables.ManyToManyColumn(
|
||||
asns = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='ASNs'
|
||||
)
|
||||
@@ -98,7 +98,7 @@ class SiteTable(NetBoxTable):
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
@@ -137,7 +137,7 @@ class LocationTable(NetBoxTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """
|
||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -523,6 +523,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
moduletype = ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Module Type 1'
|
||||
)
|
||||
|
||||
console_port_templates = (
|
||||
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
|
||||
@@ -541,9 +544,13 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Console Port Template 5',
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Console Port Template 6',
|
||||
},
|
||||
{
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Console Port Template 7',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -560,6 +567,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
moduletype = ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Module Type 1'
|
||||
)
|
||||
|
||||
console_server_port_templates = (
|
||||
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
|
||||
@@ -578,9 +588,13 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Console Server Port Template 5',
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Console Server Port Template 6',
|
||||
},
|
||||
{
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Console Server Port Template 7',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -597,6 +611,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
moduletype = ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Module Type 1'
|
||||
)
|
||||
|
||||
power_port_templates = (
|
||||
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
|
||||
@@ -615,9 +632,13 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Power Port Template 5',
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Power Port Template 6',
|
||||
},
|
||||
{
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Power Port Template 7',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -634,6 +655,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
moduletype = ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Module Type 1'
|
||||
)
|
||||
|
||||
power_port_templates = (
|
||||
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
|
||||
@@ -664,6 +688,14 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Power Outlet Template 6',
|
||||
'power_port': None,
|
||||
},
|
||||
{
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Power Outlet Template 7',
|
||||
},
|
||||
{
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Power Outlet Template 8',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -680,6 +712,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
moduletype = ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Module Type 1'
|
||||
)
|
||||
|
||||
interface_templates = (
|
||||
InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
|
||||
@@ -700,10 +735,15 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'type': '1000base-t',
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Interface Template 6',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
{
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Interface Template 7',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -720,14 +760,19 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
moduletype = ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Module Type 1'
|
||||
)
|
||||
|
||||
rear_port_templates = (
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C),
|
||||
)
|
||||
RearPortTemplate.objects.bulk_create(rear_port_templates)
|
||||
|
||||
@@ -745,15 +790,28 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
rear_port=rear_port_templates[1]
|
||||
),
|
||||
FrontPortTemplate(
|
||||
device_type=devicetype,
|
||||
name='Front Port Template 3',
|
||||
module_type=moduletype,
|
||||
name='Front Port Template 5',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rear_port_templates[2]
|
||||
rear_port=rear_port_templates[4]
|
||||
),
|
||||
FrontPortTemplate(
|
||||
module_type=moduletype,
|
||||
name='Front Port Template 6',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rear_port_templates[5]
|
||||
),
|
||||
)
|
||||
FrontPortTemplate.objects.bulk_create(front_port_templates)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 3',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[2].pk,
|
||||
'rear_port_position': 1,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 4',
|
||||
@@ -762,17 +820,17 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'rear_port_position': 1,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 5',
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Front Port Template 7',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[4].pk,
|
||||
'rear_port': rear_port_templates[6].pk,
|
||||
'rear_port_position': 1,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 6',
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Front Port Template 8',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[5].pk,
|
||||
'rear_port': rear_port_templates[7].pk,
|
||||
'rear_port_position': 1,
|
||||
},
|
||||
]
|
||||
@@ -791,6 +849,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
moduletype = ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Module Type 1'
|
||||
)
|
||||
|
||||
rear_port_templates = (
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
|
||||
@@ -811,10 +872,15 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Rear Port Template 6',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
},
|
||||
{
|
||||
'module_type': moduletype.pk,
|
||||
'name': 'Rear Port Template 7',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -521,10 +521,26 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
regions = (
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
Region(name='Region 2', slug='region-2'),
|
||||
Region(name='Region 3', slug='region-3'),
|
||||
)
|
||||
for region in regions:
|
||||
region.save()
|
||||
|
||||
groups = (
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
SiteGroup(name='Site Group 2', slug='site-group-2'),
|
||||
SiteGroup(name='Site Group 3', slug='site-group-3'),
|
||||
)
|
||||
for group in groups:
|
||||
group.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
@@ -572,6 +588,20 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
RackReservation.objects.bulk_create(reservations)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site_group(self):
|
||||
site_groups = SiteGroup.objects.all()[:2]
|
||||
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
@@ -698,6 +728,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'),
|
||||
DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'),
|
||||
))
|
||||
# Assigned DeviceType must have parent subdevice_role
|
||||
inventory_item = InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 1')
|
||||
inventory_item.save()
|
||||
|
||||
def test_model(self):
|
||||
params = {'model': ['Model 1', 'Model 2']}
|
||||
@@ -784,6 +817,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'module_bays': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_inventory_items(self):
|
||||
params = {'inventory_items': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'inventory_items': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ModuleType.objects.all()
|
||||
|
||||
@@ -1869,6 +1869,44 @@ class ModuleTestCase(
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_component_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
|
||||
interface_name = "Interface-1"
|
||||
|
||||
# Add an interface to the ModuleType
|
||||
module_type = ModuleType.objects.first()
|
||||
InterfaceTemplate(module_type=module_type, name=interface_name).save()
|
||||
|
||||
form_data = self.form_data.copy()
|
||||
device = Device.objects.get(pk=form_data['device'])
|
||||
|
||||
# Create an interface to be adopted
|
||||
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
|
||||
interface.save()
|
||||
|
||||
# Ensure that interface is created with no module
|
||||
self.assertIsNone(interface.module)
|
||||
|
||||
# Create a module with adopted components
|
||||
form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
|
||||
form_data['module_type'] = module_type
|
||||
form_data['replicate_components'] = False
|
||||
form_data['adopt_components'] = True
|
||||
request = {
|
||||
'path': self._get_url('add'),
|
||||
'data': post_data(form_data),
|
||||
}
|
||||
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
|
||||
# Re-retrieve interface to get new module id
|
||||
interface.refresh_from_db()
|
||||
|
||||
# Check that the Interface now has a module
|
||||
self.assertIsNotNone(interface.module)
|
||||
|
||||
|
||||
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ConsolePort
|
||||
|
||||
@@ -166,7 +166,7 @@ class RegionView(generic.ObjectView):
|
||||
sites = Site.objects.restrict(request.user, 'view').filter(
|
||||
region=instance
|
||||
)
|
||||
sites_table = tables.SiteTable(sites, exclude=('region',))
|
||||
sites_table = tables.SiteTable(sites, user=request.user, exclude=('region',))
|
||||
sites_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -251,7 +251,7 @@ class SiteGroupView(generic.ObjectView):
|
||||
sites = Site.objects.restrict(request.user, 'view').filter(
|
||||
group=instance
|
||||
)
|
||||
sites_table = tables.SiteTable(sites, exclude=('group',))
|
||||
sites_table = tables.SiteTable(sites, user=request.user, exclude=('group',))
|
||||
sites_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -435,7 +435,7 @@ class LocationView(generic.ObjectView):
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).filter(pk__in=location_ids).exclude(pk=instance.pk)
|
||||
child_locations_table = tables.LocationTable(child_locations)
|
||||
child_locations_table = tables.LocationTable(child_locations, user=request.user)
|
||||
child_locations_table.configure(request)
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
@@ -510,11 +510,13 @@ class RackRoleView(generic.ObjectView):
|
||||
queryset = RackRole.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
racks = Rack.objects.restrict(request.user, 'view').filter(
|
||||
role=instance
|
||||
racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
|
||||
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
|
||||
racks_table = tables.RackTable(racks, user=request.user, exclude=(
|
||||
'role', 'get_utilization', 'get_power_utilization',
|
||||
))
|
||||
racks_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -767,7 +769,7 @@ class ManufacturerView(generic.ObjectView):
|
||||
manufacturer=instance
|
||||
)
|
||||
|
||||
devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',))
|
||||
devicetypes_table = tables.DeviceTypeTable(device_types, user=request.user, exclude=('manufacturer',))
|
||||
devicetypes_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -1480,7 +1482,7 @@ class DeviceRoleView(generic.ObjectView):
|
||||
devices = Device.objects.restrict(request.user, 'view').filter(
|
||||
device_role=instance
|
||||
)
|
||||
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
|
||||
devices_table = tables.DeviceTable(devices, user=request.user, exclude=('device_role',))
|
||||
devices_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -1544,7 +1546,7 @@ class PlatformView(generic.ObjectView):
|
||||
devices = Device.objects.restrict(request.user, 'view').filter(
|
||||
platform=instance
|
||||
)
|
||||
devices_table = tables.DeviceTable(devices, exclude=('platform',))
|
||||
devices_table = tables.DeviceTable(devices, user=request.user, exclude=('platform',))
|
||||
devices_table.configure(request)
|
||||
|
||||
return {
|
||||
|
||||
@@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
choices=CustomFieldTypeChoices,
|
||||
help_text='Field data type (e.g. text, integer, etc.)'
|
||||
)
|
||||
object_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False,
|
||||
help_text="Object type (for object or multi-object fields)"
|
||||
)
|
||||
choices = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
@@ -36,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = (
|
||||
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
|
||||
'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'name', 'label', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
|
||||
'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex',
|
||||
)
|
||||
|
||||
|
||||
|
||||
11
netbox/extras/management/commands/clearcache.py
Normal 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")
|
||||
@@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices
|
||||
from extras.context_managers import change_logging
|
||||
from extras.models import JobResult
|
||||
from extras.scripts import get_script
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.utils import NetBoxFakeRequest
|
||||
|
||||
@@ -49,7 +50,7 @@ class Command(BaseCommand):
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -58,7 +59,7 @@ class Command(BaseCommand):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import traceback
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
import yaml
|
||||
@@ -13,11 +14,10 @@ from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from django.utils.functional import classproperty
|
||||
from django_rq import job
|
||||
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
||||
from extras.models import JobResult
|
||||
from extras.signals import clear_webhooks
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortTransaction
|
||||
@@ -42,6 +42,8 @@ __all__ = [
|
||||
'TextVar',
|
||||
]
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
#
|
||||
# Script variables
|
||||
@@ -305,9 +307,16 @@ class BaseScript:
|
||||
@classmethod
|
||||
def _get_vars(cls):
|
||||
vars = {}
|
||||
for name, attr in cls.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
|
||||
# Iterate all base classes looking for ScriptVariables
|
||||
for base_class in inspect.getmro(cls):
|
||||
# When object is reached there's no reason to continue
|
||||
if base_class is object:
|
||||
break
|
||||
|
||||
for name, attr in base_class.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
|
||||
# Order variables according to field_order
|
||||
field_order = getattr(cls.Meta, 'field_order', None)
|
||||
@@ -457,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -466,7 +475,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
@@ -491,11 +500,14 @@ def get_scripts(use_names=False):
|
||||
# Iterate through all modules within the scripts path. These are the user-created files in which reports are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
# Remove cached module to ensure consistency with filesystem
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
# Use a lock as removing and loading modules is not thread safe
|
||||
with lock:
|
||||
# Remove cached module to ensure consistency with filesystem
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
if use_names and hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
module_scripts = OrderedDict()
|
||||
|
||||
@@ -39,10 +39,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
|
||||
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
|
||||
'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
|
||||
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
|
||||
'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
|
||||
'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3}',
|
||||
'field5,Field 5,integer,dcim.site,,100,exact,,1,100,',
|
||||
'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,',
|
||||
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
||||
@@ -91,7 +91,7 @@ class IPAddressRoleChoices(ChoiceSet):
|
||||
(ROLE_VRRP, 'VRRP', 'green'),
|
||||
(ROLE_HSRP, 'HSRP', 'green'),
|
||||
(ROLE_GLBP, 'GLBP', 'green'),
|
||||
(ROLE_CARP, 'CARP'), 'green',
|
||||
(ROLE_CARP, 'CARP', 'green'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
try:
|
||||
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
@@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
try:
|
||||
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
@@ -460,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
field_name='address',
|
||||
lookup_expr='family'
|
||||
)
|
||||
parent = django_filters.CharFilter(
|
||||
parent = MultiValueCharFilter(
|
||||
method='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
@@ -567,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def search_by_parent(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
try:
|
||||
query = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
return queryset.filter(address__net_host_contained=query)
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
q = Q()
|
||||
for prefix in value:
|
||||
try:
|
||||
query = str(netaddr.IPNetwork(prefix.strip()).cidr)
|
||||
q |= Q(address__net_host_contained=query)
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
return queryset.filter(q)
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
try:
|
||||
@@ -681,11 +687,53 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
queryset=FHRPGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device',
|
||||
field_name='pk',
|
||||
label='Device (ID)',
|
||||
)
|
||||
virtual_machine = MultiValueCharFilter(
|
||||
method='filter_virtual_machine',
|
||||
field_name='name',
|
||||
label='Virtual machine (name)',
|
||||
)
|
||||
virtual_machine_id = MultiValueNumberFilter(
|
||||
method='filter_virtual_machine',
|
||||
field_name='pk',
|
||||
label='Virtual machine (ID)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FHRPGroupAssignment
|
||||
fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority']
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
devices = Device.objects.filter(**{f'{name}__in': value})
|
||||
if not devices.exists():
|
||||
return queryset.none()
|
||||
interface_ids = []
|
||||
for device in devices:
|
||||
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||
return queryset.filter(
|
||||
Q(interface_type=ContentType.objects.get_for_model(Interface), interface_id__in=interface_ids)
|
||||
)
|
||||
|
||||
def filter_virtual_machine(self, queryset, name, value):
|
||||
virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value})
|
||||
if not virtual_machines.exists():
|
||||
return queryset.none()
|
||||
interface_ids = []
|
||||
for vm in virtual_machines:
|
||||
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
|
||||
return queryset.filter(
|
||||
Q(interface_type=ContentType.objects.get_for_model(VMInterface), interface_id__in=interface_ids)
|
||||
)
|
||||
|
||||
|
||||
class VLANGroupFilterSet(OrganizationalModelFilterSet):
|
||||
scope_type = ContentTypeFilter()
|
||||
|
||||
@@ -507,16 +507,20 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel):
|
||||
child_ranges.add(iprange.range)
|
||||
available_ips = prefix - child_ips - child_ranges
|
||||
|
||||
# IPv6, pool, or IPv4 /31-/32 sets are fully usable
|
||||
if self.family == 6 or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
|
||||
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
|
||||
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
|
||||
return available_ips
|
||||
|
||||
# For "normal" IPv4 prefixes, omit first and last addresses
|
||||
available_ips -= netaddr.IPSet([
|
||||
netaddr.IPAddress(self.prefix.first),
|
||||
netaddr.IPAddress(self.prefix.last),
|
||||
])
|
||||
|
||||
if self.family == 4:
|
||||
# For "normal" IPv4 prefixes, omit first and last addresses
|
||||
available_ips -= netaddr.IPSet([
|
||||
netaddr.IPAddress(self.prefix.first),
|
||||
netaddr.IPAddress(self.prefix.last),
|
||||
])
|
||||
else:
|
||||
# For IPv6 prefixes, omit the Subnet-Router anycast address
|
||||
# per RFC 4291
|
||||
available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
|
||||
return available_ips
|
||||
|
||||
def get_first_available_ip(self):
|
||||
|
||||
@@ -118,7 +118,7 @@ class ASNTable(NetBoxTable):
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name='Provider Count'
|
||||
)
|
||||
sites = tables.ManyToManyColumn(
|
||||
sites = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='Sites'
|
||||
)
|
||||
@@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
|
||||
|
||||
|
||||
class PrefixTable(NetBoxTable):
|
||||
prefix = tables.TemplateColumn(
|
||||
prefix = columns.TemplateColumn(
|
||||
template_code=PREFIX_LINK,
|
||||
export_raw=True,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
prefix_flat = tables.TemplateColumn(
|
||||
|
||||
@@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
ports = tables.Column(
|
||||
accessor=tables.A('port_list')
|
||||
accessor=tables.A('port_list'),
|
||||
order_by=tables.A('ports'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:servicetemplate_list'
|
||||
@@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable):
|
||||
order_by=('device', 'virtual_machine')
|
||||
)
|
||||
ports = tables.Column(
|
||||
accessor=tables.A('port_list')
|
||||
accessor=tables.A('port_list'),
|
||||
order_by=tables.A('ports'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:service_list'
|
||||
|
||||
@@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_parent(self):
|
||||
params = {'parent': '10.0.0.0/24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'parent': '2001:db8::/64'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
|
||||
def test_filter_address(self):
|
||||
# Check IPv4 and IPv6, with and without a mask
|
||||
@@ -1024,6 +1022,20 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'priority': [10, 20]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device(self):
|
||||
device = Device.objects.first()
|
||||
params = {'device': [device.name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'device_id': [device.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_virtual_machine(self):
|
||||
vm = VirtualMachine.objects.first()
|
||||
params = {'virtual_machine': [vm.name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'virtual_machine_id': [vm.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = VLANGroup.objects.all()
|
||||
|
||||
@@ -185,6 +185,18 @@ class TestPrefix(TestCase):
|
||||
IPAddress.objects.create(address=IPNetwork('10.0.0.4/24'))
|
||||
self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24')
|
||||
|
||||
def test_get_first_available_ip_ipv6(self):
|
||||
parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500::/64'))
|
||||
self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500::1/64')
|
||||
|
||||
def test_get_first_available_ip_ipv6_rfc3627(self):
|
||||
parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:4::/126'))
|
||||
self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:4::1/126')
|
||||
|
||||
def test_get_first_available_ip_ipv6_rfc6164(self):
|
||||
parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:5::/127'))
|
||||
self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:5::/127')
|
||||
|
||||
def test_get_utilization_container(self):
|
||||
prefixes = (
|
||||
Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER),
|
||||
|
||||
@@ -4,15 +4,15 @@ from django.db.models.expressions import RawSQL
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.models import Provider
|
||||
from circuits.models import Provider, Circuit
|
||||
from circuits.tables import ProviderTable
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Interface, Site
|
||||
from dcim.models import Interface, Site, Device
|
||||
from dcim.tables import SiteTable
|
||||
from netbox.views import generic
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VMInterface
|
||||
from virtualization.models import VMInterface, VirtualMachine
|
||||
from . import filtersets, forms, tables
|
||||
from .constants import *
|
||||
from .models import *
|
||||
@@ -158,10 +158,10 @@ class RIRView(generic.ObjectView):
|
||||
queryset = RIR.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
aggregates = Aggregate.objects.restrict(request.user, 'view').filter(
|
||||
rir=instance
|
||||
aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate(
|
||||
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
|
||||
)
|
||||
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
|
||||
aggregates_table = tables.AggregateTable(aggregates, user=request.user, exclude=('rir', 'utilization'))
|
||||
aggregates_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -221,12 +221,14 @@ class ASNView(generic.ObjectView):
|
||||
def get_extra_context(self, request, instance):
|
||||
# Gather assigned Sites
|
||||
sites = instance.sites.restrict(request.user, 'view')
|
||||
sites_table = SiteTable(sites)
|
||||
sites_table = SiteTable(sites, user=request.user)
|
||||
sites_table.configure(request)
|
||||
|
||||
# Gather assigned Providers
|
||||
providers = instance.providers.restrict(request.user, 'view')
|
||||
providers_table = ProviderTable(providers)
|
||||
providers = instance.providers.restrict(request.user, 'view').annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
providers_table = ProviderTable(providers, user=request.user)
|
||||
providers_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -366,7 +368,7 @@ class RoleView(generic.ObjectView):
|
||||
role=instance
|
||||
)
|
||||
|
||||
prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
|
||||
prefixes_table = tables.PrefixTable(prefixes, user=request.user, exclude=('role', 'utilization'))
|
||||
prefixes_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -674,11 +676,26 @@ class IPAddressView(generic.ObjectView):
|
||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||
related_ips_table.configure(request)
|
||||
|
||||
# Find services belonging to the IP
|
||||
service_filter = Q(ipaddresses=instance)
|
||||
|
||||
# Find services listening on all IPs on the assigned device/vm
|
||||
if instance.assigned_object and instance.assigned_object.parent_object:
|
||||
parent_object = instance.assigned_object.parent_object
|
||||
|
||||
if isinstance(parent_object, VirtualMachine):
|
||||
service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None))
|
||||
elif isinstance(parent_object, Device):
|
||||
service_filter |= (Q(device=parent_object) & Q(ipaddresses=None))
|
||||
|
||||
services = Service.objects.restrict(request.user, 'view').filter(service_filter)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'more_duplicate_ips': duplicate_ips.count() > 10,
|
||||
'related_ips_table': related_ips_table,
|
||||
'services': services,
|
||||
}
|
||||
|
||||
|
||||
@@ -805,7 +822,7 @@ class VLANGroupView(generic.ObjectView):
|
||||
vlans_count = vlans.count()
|
||||
vlans = add_available_vlans(vlans, vlan_group=instance)
|
||||
|
||||
vlans_table = tables.VLANTable(vlans, exclude=('group',))
|
||||
vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
|
||||
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
|
||||
vlans_table.columns.show('pk')
|
||||
vlans_table.configure(request)
|
||||
|
||||
@@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
|
||||
if isinstance(queryset, QuerySet):
|
||||
self.count = queryset.count()
|
||||
self.count = self.get_queryset_count(queryset)
|
||||
else:
|
||||
# We're dealing with an iterable, not a QuerySet
|
||||
self.count = len(queryset)
|
||||
@@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
return self.default_limit
|
||||
|
||||
def get_queryset_count(self, queryset):
|
||||
return queryset.count()
|
||||
|
||||
def get_next_link(self):
|
||||
|
||||
# Pagination has been disabled
|
||||
@@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
return None
|
||||
|
||||
return super().get_previous_link()
|
||||
|
||||
|
||||
class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination):
|
||||
"""
|
||||
Strips the annotations on the queryset before getting the count
|
||||
to optimize pagination of complex queries.
|
||||
"""
|
||||
def get_queryset_count(self, queryset):
|
||||
# Clone the queryset to avoid messing up the actual query
|
||||
cloned_queryset = queryset.all()
|
||||
cloned_queryset.query.annotations.clear()
|
||||
|
||||
return cloned_queryset.count()
|
||||
|
||||
@@ -39,6 +39,7 @@ AUTH_BACKEND_ATTRS = {
|
||||
'keycloak': ('Keycloak', None),
|
||||
'microsoft-graph': ('Microsoft Graph', 'microsoft'),
|
||||
'okta': ('Okta', None),
|
||||
'okta-openidconnect': ('Okta (OIDC)', None),
|
||||
'salesforce-oauth2': ('Salesforce', 'salesforce'),
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300
|
||||
# this setting is derived from the installed location.
|
||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||
|
||||
# The name to use for the csrf token cookie.
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
|
||||
# The name to use for the session cookie.
|
||||
SESSION_COOKIE_NAME = 'sessionid'
|
||||
|
||||
|
||||
@@ -36,3 +36,8 @@ REDIS = {
|
||||
}
|
||||
|
||||
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': True
|
||||
}
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Dict
|
||||
|
||||
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
|
||||
import circuits.filtersets
|
||||
import circuits.tables
|
||||
import dcim.filtersets
|
||||
import dcim.tables
|
||||
import ipam.filtersets
|
||||
import ipam.tables
|
||||
import tenancy.filtersets
|
||||
import tenancy.tables
|
||||
import virtualization.filtersets
|
||||
import virtualization.tables
|
||||
from circuits.models import Circuit, ProviderNetwork, Provider
|
||||
from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
|
||||
from dcim.filtersets import (
|
||||
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet,
|
||||
PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
|
||||
)
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
|
||||
)
|
||||
from dcim.tables import (
|
||||
CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable,
|
||||
RackReservationTable, SiteTable, VirtualChassisTable,
|
||||
)
|
||||
from ipam.filtersets import (
|
||||
AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
|
||||
)
|
||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
|
||||
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
|
||||
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
|
||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
|
||||
from tenancy.models import Contact, Tenant, ContactAssignment
|
||||
from tenancy.tables import ContactTable, TenantTable
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineTable
|
||||
|
||||
SEARCH_MAX_RESULTS = 15
|
||||
|
||||
@@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict(
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
'filterset': circuits.filtersets.ProviderFilterSet,
|
||||
'table': circuits.tables.ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
}),
|
||||
('circuit', {
|
||||
'queryset': Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant', 'terminations__site'
|
||||
),
|
||||
'filterset': CircuitFilterSet,
|
||||
'table': CircuitTable,
|
||||
'filterset': circuits.filtersets.CircuitFilterSet,
|
||||
'table': circuits.tables.CircuitTable,
|
||||
'url': 'circuits:circuit_list',
|
||||
}),
|
||||
('providernetwork', {
|
||||
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
|
||||
'filterset': ProviderNetworkFilterSet,
|
||||
'table': ProviderNetworkTable,
|
||||
'filterset': circuits.filtersets.ProviderNetworkFilterSet,
|
||||
'table': circuits.tables.ProviderNetworkTable,
|
||||
'url': 'circuits:providernetwork_list',
|
||||
}),
|
||||
)
|
||||
@@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict(
|
||||
(
|
||||
('site', {
|
||||
'queryset': Site.objects.prefetch_related('region', 'tenant'),
|
||||
'filterset': SiteFilterSet,
|
||||
'table': SiteTable,
|
||||
'filterset': dcim.filtersets.SiteFilterSet,
|
||||
'table': dcim.tables.SiteTable,
|
||||
'url': 'dcim:site_list',
|
||||
}),
|
||||
('rack', {
|
||||
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
),
|
||||
'filterset': RackFilterSet,
|
||||
'table': RackTable,
|
||||
'filterset': dcim.filtersets.RackFilterSet,
|
||||
'table': dcim.tables.RackTable,
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackreservation', {
|
||||
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
|
||||
'filterset': RackReservationFilterSet,
|
||||
'table': RackReservationTable,
|
||||
'filterset': dcim.filtersets.RackReservationFilterSet,
|
||||
'table': dcim.tables.RackReservationTable,
|
||||
'url': 'dcim:rackreservation_list',
|
||||
}),
|
||||
('location', {
|
||||
@@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict(
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': LocationFilterSet,
|
||||
'table': LocationTable,
|
||||
'filterset': dcim.filtersets.LocationFilterSet,
|
||||
'table': dcim.tables.LocationTable,
|
||||
'url': 'dcim:location_list',
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
'filterset': dcim.filtersets.DeviceTypeFilterSet,
|
||||
'table': dcim.tables.DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
}),
|
||||
('device', {
|
||||
'queryset': Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': DeviceFilterSet,
|
||||
'table': DeviceTable,
|
||||
'filterset': dcim.filtersets.DeviceFilterSet,
|
||||
'table': dcim.tables.DeviceTable,
|
||||
'url': 'dcim:device_list',
|
||||
}),
|
||||
('moduletype', {
|
||||
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Module, 'module_type')
|
||||
),
|
||||
'filterset': ModuleTypeFilterSet,
|
||||
'table': ModuleTypeTable,
|
||||
'filterset': dcim.filtersets.ModuleTypeFilterSet,
|
||||
'table': dcim.tables.ModuleTypeTable,
|
||||
'url': 'dcim:moduletype_list',
|
||||
}),
|
||||
('module', {
|
||||
'queryset': Module.objects.prefetch_related(
|
||||
'module_type__manufacturer', 'device', 'module_bay',
|
||||
),
|
||||
'filterset': ModuleFilterSet,
|
||||
'table': ModuleTable,
|
||||
'filterset': dcim.filtersets.ModuleFilterSet,
|
||||
'table': dcim.tables.ModuleTable,
|
||||
'url': 'dcim:module_list',
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
'filterset': dcim.filtersets.VirtualChassisFilterSet,
|
||||
'table': dcim.tables.VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
}),
|
||||
('cable', {
|
||||
'queryset': Cable.objects.all(),
|
||||
'filterset': CableFilterSet,
|
||||
'table': CableTable,
|
||||
'filterset': dcim.filtersets.CableFilterSet,
|
||||
'table': dcim.tables.CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
}),
|
||||
('powerfeed', {
|
||||
'queryset': PowerFeed.objects.all(),
|
||||
'filterset': PowerFeedFilterSet,
|
||||
'table': PowerFeedTable,
|
||||
'filterset': dcim.filtersets.PowerFeedFilterSet,
|
||||
'table': dcim.tables.PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
}),
|
||||
)
|
||||
@@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict(
|
||||
(
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.prefetch_related('tenant'),
|
||||
'filterset': VRFFilterSet,
|
||||
'table': VRFTable,
|
||||
'filterset': ipam.filtersets.VRFFilterSet,
|
||||
'table': ipam.tables.VRFTable,
|
||||
'url': 'ipam:vrf_list',
|
||||
}),
|
||||
('aggregate', {
|
||||
'queryset': Aggregate.objects.prefetch_related('rir'),
|
||||
'filterset': AggregateFilterSet,
|
||||
'table': AggregateTable,
|
||||
'filterset': ipam.filtersets.AggregateFilterSet,
|
||||
'table': ipam.tables.AggregateTable,
|
||||
'url': 'ipam:aggregate_list',
|
||||
}),
|
||||
('prefix', {
|
||||
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
|
||||
'filterset': PrefixFilterSet,
|
||||
'table': PrefixTable,
|
||||
'filterset': ipam.filtersets.PrefixFilterSet,
|
||||
'table': ipam.tables.PrefixTable,
|
||||
'url': 'ipam:prefix_list',
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
|
||||
'filterset': IPAddressFilterSet,
|
||||
'table': IPAddressTable,
|
||||
'filterset': ipam.filtersets.IPAddressFilterSet,
|
||||
'table': ipam.tables.IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
}),
|
||||
('vlan', {
|
||||
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
|
||||
'filterset': VLANFilterSet,
|
||||
'table': VLANTable,
|
||||
'filterset': ipam.filtersets.VLANFilterSet,
|
||||
'table': ipam.tables.VLANTable,
|
||||
'url': 'ipam:vlan_list',
|
||||
}),
|
||||
('asn', {
|
||||
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
|
||||
'filterset': ASNFilterSet,
|
||||
'table': ASNTable,
|
||||
'filterset': ipam.filtersets.ASNFilterSet,
|
||||
'table': ipam.tables.ASNTable,
|
||||
'url': 'ipam:asn_list',
|
||||
}),
|
||||
('service', {
|
||||
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
|
||||
'filterset': ipam.filtersets.ServiceFilterSet,
|
||||
'table': ipam.tables.ServiceTable,
|
||||
'url': 'ipam:service_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict(
|
||||
(
|
||||
('tenant', {
|
||||
'queryset': Tenant.objects.prefetch_related('group'),
|
||||
'filterset': TenantFilterSet,
|
||||
'table': TenantTable,
|
||||
'filterset': tenancy.filtersets.TenantFilterSet,
|
||||
'table': tenancy.tables.TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
}),
|
||||
('contact', {
|
||||
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
|
||||
assignment_count=count_related(ContactAssignment, 'contact')),
|
||||
'filterset': ContactFilterSet,
|
||||
'table': ContactTable,
|
||||
'filterset': tenancy.filtersets.ContactFilterSet,
|
||||
'table': tenancy.tables.ContactTable,
|
||||
'url': 'tenancy:contact_list',
|
||||
}),
|
||||
)
|
||||
@@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict(
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
'filterset': virtualization.filtersets.ClusterFilterSet,
|
||||
'table': virtualization.tables.ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': VirtualMachineFilterSet,
|
||||
'table': VirtualMachineTable,
|
||||
'filterset': virtualization.filtersets.VirtualMachineFilterSet,
|
||||
'table': virtualization.tables.VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -61,6 +61,8 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
|
||||
"""
|
||||
Base form for creating a NetBox objects from CSV data. Used for bulk importing.
|
||||
"""
|
||||
tags = None # Temporary fix in lieu of tag import support (see #9158)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(for_csv_import=True)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
@@ -8,9 +9,11 @@ import sys
|
||||
import warnings
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import sentry_sdk
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from netbox.config import PARAMS
|
||||
|
||||
@@ -26,7 +29,7 @@ django.utils.encoding.force_text = force_str
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.2.1'
|
||||
VERSION = '3.2.5'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -40,6 +43,7 @@ if sys.version_info < (3, 8):
|
||||
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
|
||||
)
|
||||
|
||||
DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485'
|
||||
|
||||
#
|
||||
# Configuration import
|
||||
@@ -68,6 +72,9 @@ DATABASE = getattr(configuration, 'DATABASE')
|
||||
REDIS = getattr(configuration, 'REDIS')
|
||||
SECRET_KEY = getattr(configuration, 'SECRET_KEY')
|
||||
|
||||
# Calculate a unique deployment ID from the secret key
|
||||
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
|
||||
|
||||
# Set static config parameters
|
||||
ADMINS = getattr(configuration, 'ADMINS', [])
|
||||
AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [])
|
||||
@@ -77,6 +84,7 @@ if BASE_PATH:
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
|
||||
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
@@ -88,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
@@ -113,6 +122,11 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
|
||||
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
||||
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
|
||||
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
@@ -428,6 +442,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
|
||||
#
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.columns import library
|
||||
@@ -27,6 +27,7 @@ __all__ = (
|
||||
'CustomLinkColumn',
|
||||
'LinkedCountColumn',
|
||||
'MarkdownColumn',
|
||||
'ManyToManyColumn',
|
||||
'MPTTColumn',
|
||||
'TagColumn',
|
||||
'TemplateColumn',
|
||||
@@ -35,6 +36,10 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Django-tables2 overrides
|
||||
#
|
||||
|
||||
@library.register
|
||||
class DateColumn(tables.DateColumn):
|
||||
"""
|
||||
@@ -42,7 +47,6 @@ class DateColumn(tables.DateColumn):
|
||||
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
||||
default, making this behavior consistent in all fields of type DateField.
|
||||
"""
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
@@ -59,7 +63,6 @@ class DateTimeColumn(tables.DateTimeColumn):
|
||||
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
||||
default, making this behavior consistent in all fields of type DateTimeField.
|
||||
"""
|
||||
|
||||
def value(self, value):
|
||||
if value:
|
||||
return date_format(value, format="SHORT_DATETIME_FORMAT")
|
||||
@@ -71,6 +74,52 @@ class DateTimeColumn(tables.DateTimeColumn):
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
class ManyToManyColumn(tables.ManyToManyColumn):
|
||||
"""
|
||||
Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
|
||||
"""
|
||||
def value(self, value):
|
||||
items = [self.transform(item) for item in self.filter(value)]
|
||||
return self.separator.join(items)
|
||||
|
||||
|
||||
class TemplateColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
|
||||
is an empty string.
|
||||
"""
|
||||
PLACEHOLDER = mark_safe('—')
|
||||
|
||||
def __init__(self, export_raw=False, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
|
||||
False)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.export_raw = export_raw
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
ret = super().render(*args, **kwargs)
|
||||
if not ret.strip():
|
||||
return self.PLACEHOLDER
|
||||
return ret
|
||||
|
||||
def value(self, **kwargs):
|
||||
if self.export_raw:
|
||||
# Skip template rendering and export raw value
|
||||
return kwargs.get('value')
|
||||
|
||||
ret = super().value(**kwargs)
|
||||
if ret == self.PLACEHOLDER:
|
||||
return ''
|
||||
return ret
|
||||
|
||||
|
||||
#
|
||||
# Custom columns
|
||||
#
|
||||
|
||||
class ToggleColumn(tables.CheckBoxColumn):
|
||||
"""
|
||||
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
|
||||
@@ -112,31 +161,12 @@ class BooleanColumn(tables.Column):
|
||||
return str(value)
|
||||
|
||||
|
||||
class TemplateColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
|
||||
is an empty string.
|
||||
"""
|
||||
PLACEHOLDER = mark_safe('—')
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
ret = super().render(*args, **kwargs)
|
||||
if not ret.strip():
|
||||
return self.PLACEHOLDER
|
||||
return ret
|
||||
|
||||
def value(self, **kwargs):
|
||||
ret = super().value(**kwargs)
|
||||
if ret == self.PLACEHOLDER:
|
||||
return ''
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionsItem:
|
||||
title: str
|
||||
icon: str
|
||||
permission: Optional[str] = None
|
||||
css_class: Optional[str] = 'secondary'
|
||||
|
||||
|
||||
class ActionsColumn(tables.Column):
|
||||
@@ -146,19 +176,22 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
:param actions: The ordered list of dropdown menu items to include
|
||||
:param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
|
||||
:param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
|
||||
direct button link and icon (default: True)
|
||||
"""
|
||||
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
|
||||
empty_values = ()
|
||||
actions = {
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change'),
|
||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
|
||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
|
||||
'changelog': ActionsItem('Changelog', 'history'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs):
|
||||
def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.extra_buttons = extra_buttons
|
||||
self.split_actions = split_actions
|
||||
|
||||
# Determine which actions to enable
|
||||
self.actions = {
|
||||
@@ -176,32 +209,62 @@ class ActionsColumn(tables.Column):
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={request.path}' if request else ''
|
||||
html = ''
|
||||
|
||||
links = []
|
||||
# Compile actions menu
|
||||
button = None
|
||||
dropdown_class = 'secondary'
|
||||
dropdown_links = []
|
||||
user = getattr(request, 'user', AnonymousUser())
|
||||
for action, attrs in self.actions.items():
|
||||
for idx, (action, attrs) in enumerate(self.actions.items()):
|
||||
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
|
||||
if attrs.permission is None or user.has_perm(permission):
|
||||
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
|
||||
links.append(f'<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 ''
|
||||
# Render a separate button if a) only one action exists, or b) if split_actions is True
|
||||
if len(self.actions) == 1 or (self.split_actions and idx == 0):
|
||||
dropdown_class = attrs.css_class
|
||||
button = (
|
||||
f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i></a>'
|
||||
)
|
||||
|
||||
menu = f'<span class="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'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
||||
# Add dropdown menu items
|
||||
else:
|
||||
dropdown_links.append(
|
||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
||||
)
|
||||
|
||||
# Create the actions dropdown menu
|
||||
if button and dropdown_links:
|
||||
html += (
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' {button}'
|
||||
f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
elif button:
|
||||
html += button
|
||||
elif dropdown_links:
|
||||
html += (
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
|
||||
# Render any extra buttons from template code
|
||||
if self.extra_buttons:
|
||||
template = Template(self.extra_buttons)
|
||||
context = getattr(table, "context", Context())
|
||||
context.update({'record': record})
|
||||
menu = template.render(context) + menu
|
||||
html = template.render(context) + html
|
||||
|
||||
return mark_safe(menu)
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
class ChoiceFieldColumn(tables.Column):
|
||||
|
||||
@@ -100,4 +100,5 @@ urlpatterns = [
|
||||
path('{}'.format(settings.BASE_PATH), include(_patterns))
|
||||
]
|
||||
|
||||
handler404 = 'netbox.views.handler_404'
|
||||
handler500 = 'netbox.views.server_error'
|
||||
|
||||
@@ -2,7 +2,6 @@ import platform
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.db.models import F
|
||||
from django.http import HttpResponseServerError
|
||||
@@ -11,9 +10,10 @@ from django.template import loader
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME
|
||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||
from django.views.generic import View
|
||||
from packaging import version
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from circuits.models import Circuit, Provider
|
||||
from dcim.models import (
|
||||
@@ -190,13 +190,21 @@ class StaticMediaFailureView(View):
|
||||
"""
|
||||
Display a user-friendly error message with troubleshooting tips when a static media file fails to load.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
return render(request, 'media_failure.html', {
|
||||
'filename': request.GET.get('filename')
|
||||
})
|
||||
|
||||
|
||||
def handler_404(request, exception):
|
||||
"""
|
||||
Wrap Django's default 404 handler to enable Sentry reporting.
|
||||
"""
|
||||
capture_message("Page not found", level="error")
|
||||
|
||||
return page_not_found(request, exception)
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
"""
|
||||
|
||||
14
netbox/project-static/dist/netbox.js
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
@@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle';
|
||||
import { initMoveButtons } from './moveOptions';
|
||||
import { initReslug } from './reslug';
|
||||
import { initSelectAll } from './selectAll';
|
||||
import { initSelectMultiple } from './selectMultiple';
|
||||
|
||||
export function initButtons(): void {
|
||||
for (const func of [
|
||||
@@ -10,6 +11,7 @@ export function initButtons(): void {
|
||||
initConnectionToggle,
|
||||
initReslug,
|
||||
initSelectAll,
|
||||
initSelectMultiple,
|
||||
initMoveButtons,
|
||||
]) {
|
||||
func();
|
||||
|
||||
105
netbox/project-static/src/buttons/selectMultiple.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getElements } from '../util';
|
||||
import { StateManager } from 'src/state';
|
||||
import { previousPkCheckState } from '../stores';
|
||||
|
||||
type PreviousPkCheckState = { element: Nullable<HTMLInputElement> };
|
||||
|
||||
/**
|
||||
* If there is a text selection, removes it.
|
||||
*/
|
||||
function removeTextSelection(): void {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the state object passed in to the eventTargetElement object passed in.
|
||||
*
|
||||
* @param eventTargetElement HTML Input Element, retrieved from getting the target of the
|
||||
* event passed in from handlePkCheck()
|
||||
* @param state PreviousPkCheckState object.
|
||||
*/
|
||||
function updatePreviousPkCheckState(
|
||||
eventTargetElement: HTMLInputElement,
|
||||
state: StateManager<PreviousPkCheckState>,
|
||||
): void {
|
||||
state.set('element', eventTargetElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle
|
||||
* "checked" value to eventTargetElement.checked
|
||||
*
|
||||
* @param eventTargetElement HTML Input Element, retrieved from getting the target of the
|
||||
* event passed in from handlePkCheck()
|
||||
* @param state PreviousPkCheckState object.
|
||||
*/
|
||||
function toggleCheckboxRange(
|
||||
eventTargetElement: HTMLInputElement,
|
||||
previousStateElement: HTMLInputElement,
|
||||
elementList: Generator,
|
||||
): void {
|
||||
let changePkCheckboxState = false;
|
||||
for (const element of elementList) {
|
||||
const typedElement = element as HTMLInputElement;
|
||||
//Change loop's current checkbox state to eventTargetElement checkbox state
|
||||
if (changePkCheckboxState === true) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
//The previously clicked checkbox was above the shift clicked checkbox
|
||||
if (element === previousStateElement) {
|
||||
if (changePkCheckboxState === true) {
|
||||
changePkCheckboxState = false;
|
||||
return;
|
||||
}
|
||||
changePkCheckboxState = true;
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
//The previously clicked checkbox was below the shift clicked checkbox
|
||||
if (element === eventTargetElement) {
|
||||
if (changePkCheckboxState === true) {
|
||||
changePkCheckboxState = false;
|
||||
return;
|
||||
}
|
||||
changePkCheckboxState = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the
|
||||
* event target element and the state element.
|
||||
*
|
||||
* @param event Mouse event.
|
||||
* @param state PreviousPkCheckState object.
|
||||
*/
|
||||
function handlePkCheck(event: MouseEvent, state: StateManager<PreviousPkCheckState>): void {
|
||||
const eventTargetElement = event.target as HTMLInputElement;
|
||||
const previousStateElement = state.get('element');
|
||||
updatePreviousPkCheckState(eventTargetElement, state);
|
||||
//Stop if user is not holding shift key
|
||||
if (!event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
removeTextSelection();
|
||||
//If no previous state, store event target element as previous state and return
|
||||
if (previousStateElement === null) {
|
||||
return updatePreviousPkCheckState(eventTargetElement, state);
|
||||
}
|
||||
const checkboxList = getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]');
|
||||
toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize table select all elements.
|
||||
*/
|
||||
export function initSelectMultiple(): void {
|
||||
const checkboxElements = getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]');
|
||||
for (const element of checkboxElements) {
|
||||
element.addEventListener('click', event => {
|
||||
removeTextSelection();
|
||||
//Stop propogation to avoid event firing multiple times
|
||||
event.stopPropagation();
|
||||
handlePkCheck(event, previousPkCheckState);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,11 @@ export class APISelect {
|
||||
onChange: () => this.handleSlimChange(),
|
||||
});
|
||||
|
||||
// Don't close on select if multiple select
|
||||
if (this.base.multiple) {
|
||||
this.slim.config.closeOnSelect = false;
|
||||
}
|
||||
|
||||
// Initialize API query properties.
|
||||
this.getStaticParams();
|
||||
this.getDynamicParams();
|
||||
@@ -570,8 +575,9 @@ export class APISelect {
|
||||
* additional paginated options.
|
||||
*/
|
||||
private handleScroll(): void {
|
||||
// Floor scrollTop as chrome can return fractions on some zoom levels.
|
||||
const atBottom =
|
||||
this.slim.slim.list.scrollTop + this.slim.slim.list.offsetHeight ===
|
||||
Math.floor(this.slim.slim.list.scrollTop) + this.slim.slim.list.offsetHeight ===
|
||||
this.slim.slim.list.scrollHeight;
|
||||
|
||||
if (this.atBottom && !atBottom) {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './objectDepth';
|
||||
export * from './rackImages';
|
||||
export * from './previousPkCheck';
|
||||
|
||||
6
netbox/project-static/src/stores/previousPkCheck.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createState } from '../state';
|
||||
|
||||
export const previousPkCheckState = createState<{ element: Nullable<HTMLInputElement> }>(
|
||||
{ element: null },
|
||||
{ persist: false },
|
||||
);
|
||||