mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-30 09:07:46 -06:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c91331e16 | ||
|
|
eef38257b9 | ||
|
|
bb9a125934 | ||
|
|
8de252e34e | ||
|
|
9e305c6181 | ||
|
|
97ed6439ce | ||
|
|
6820796c10 | ||
|
|
4a331b560f | ||
|
|
4c9cf9032c | ||
|
|
ada01b39cc | ||
|
|
b41f8755df | ||
|
|
2c07762b7a | ||
|
|
278f2b173a | ||
|
|
768d6f624e | ||
|
|
1146aaff89 | ||
|
|
5a4feb7099 | ||
|
|
a6fd0ab09a | ||
|
|
9f71cf79e6 | ||
|
|
c26fe266cc | ||
|
|
085cfc58f4 | ||
|
|
63a0ec7a79 | ||
|
|
ccfdc216a5 | ||
|
|
2bf9acfb19 | ||
|
|
74d8baea30 | ||
|
|
f8d40ae824 | ||
|
|
41c92483a0 | ||
|
|
94c2a2e56c | ||
|
|
0a2ae90411 | ||
|
|
b032742418 | ||
|
|
8a684adf66 | ||
|
|
bca00cd97a | ||
|
|
2883fa14de | ||
|
|
3264636b7a | ||
|
|
fbc23424a6 | ||
|
|
6f08c4a4be | ||
|
|
0ac8419005 | ||
|
|
e467589730 | ||
|
|
0330c652bd | ||
|
|
5a6005cdfa | ||
|
|
4eaba7993f | ||
|
|
2840f9d71d | ||
|
|
9946ae2981 | ||
|
|
420ec6791f | ||
|
|
47234f1607 | ||
|
|
b058bd9cea | ||
|
|
5b03636c88 | ||
|
|
be55bb43ad | ||
|
|
293afab730 | ||
|
|
6b622fd9bf | ||
|
|
7280dfacab | ||
|
|
4428a446d0 | ||
|
|
2eedcac383 | ||
|
|
35af1d7b61 | ||
|
|
1b92958870 | ||
|
|
795669113f | ||
|
|
de57446f36 | ||
|
|
3b13cef0c8 | ||
|
|
497f3145fa | ||
|
|
f597b76ddc | ||
|
|
ebaac82560 | ||
|
|
371764fecd | ||
|
|
f67deb0dea | ||
|
|
d3c5f1e744 | ||
|
|
b12551c64b | ||
|
|
9995fad513 | ||
|
|
1d2335d578 | ||
|
|
ad03061edf | ||
|
|
87eabdbffb | ||
|
|
19787dd21d | ||
|
|
7d64e5bc62 | ||
|
|
a2c7452f90 | ||
|
|
654e32cbbe | ||
|
|
879aabe2f9 | ||
|
|
835af32213 | ||
|
|
571d33e660 | ||
|
|
96eb89a469 | ||
|
|
8d25d7812c | ||
|
|
a864e8127b | ||
|
|
fc482ed096 | ||
|
|
85f40bcbe0 | ||
|
|
e7ed280790 | ||
|
|
3d14a79428 | ||
|
|
d93a24d0bb | ||
|
|
4286d74d44 | ||
|
|
016eff52c0 | ||
|
|
6b6ea36b4c | ||
|
|
520493c714 | ||
|
|
e459c46dad | ||
|
|
a71a59c088 | ||
|
|
267a14264b | ||
|
|
065738473e | ||
|
|
f698c42c41 | ||
|
|
ab303db3dd | ||
|
|
07b0b93256 | ||
|
|
d880875e67 | ||
|
|
fa60f9d2a8 | ||
|
|
33286aad39 | ||
|
|
d48a8770de | ||
|
|
ee5b707e68 | ||
|
|
d29a4a60f9 | ||
|
|
07b39fe44a | ||
|
|
e270cb20ba | ||
|
|
6640fc9eb7 | ||
|
|
189668fbfb | ||
|
|
c9e5a4c996 | ||
|
|
ed5fd140eb | ||
|
|
4f12eccde6 | ||
|
|
1f0db6d2fa | ||
|
|
eed6990b39 | ||
|
|
a554164d1d | ||
|
|
6ea30798bf | ||
|
|
3418b7adf6 | ||
|
|
88d5119c59 | ||
|
|
6e7d2f53aa | ||
|
|
559a318584 | ||
|
|
67499cbf06 | ||
|
|
0744ff2fa0 | ||
|
|
cfa6b28ceb | ||
|
|
ed77c03830 | ||
|
|
561f1eadfc | ||
|
|
6638fd88b4 | ||
|
|
c280ca35d6 | ||
|
|
3586cf79d4 | ||
|
|
972ba7bfdc | ||
|
|
5a4d8a7107 | ||
|
|
3e946c78d0 | ||
|
|
0855ff8b42 | ||
|
|
cd09501d4d | ||
|
|
e635e3e959 | ||
|
|
9efc4689cc | ||
|
|
25278becef | ||
|
|
fc7cb106c1 | ||
|
|
18ea7d1e13 | ||
|
|
eed1b8f412 | ||
|
|
a61e7e7c04 | ||
|
|
ce166b12ce | ||
|
|
315371bf7c | ||
|
|
afc752b4ce | ||
|
|
126f9ba05f | ||
|
|
c031951f4b | ||
|
|
c36e7a1d0b | ||
|
|
3a4fee4e6e | ||
|
|
2db181ea49 | ||
|
|
eee1a0e10a | ||
|
|
9594049804 | ||
|
|
c78022a74c | ||
|
|
3150c1f8b3 | ||
|
|
9f91b89467 | ||
|
|
d748851027 | ||
|
|
df499ea8ac | ||
|
|
b5da383a17 | ||
|
|
f9237285fd | ||
|
|
3c970c331c | ||
|
|
91705aa9fd | ||
|
|
56c7a238a4 | ||
|
|
3f28d6aef3 | ||
|
|
edbd597bf2 | ||
|
|
5e1bb20f32 | ||
|
|
7ebfa4c1d1 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
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.4.4
|
||||
placeholder: v3.4.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
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.4.4
|
||||
placeholder: v3.4.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
necessary.
|
||||
close-pr-message: >
|
||||
This PR has been automatically closed due to lack of activity.
|
||||
days-before-stale: 60
|
||||
days-before-stale: 90
|
||||
days-before-close: 30
|
||||
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
|
||||
operations-per-run: 100
|
||||
|
||||
@@ -13,9 +13,9 @@ NetBox provides the ideal "source of truth" to power network automation.
|
||||
Available as open source software under the Apache 2.0 license, NetBox serves
|
||||
as the cornerstone for network automation in thousands of organizations.
|
||||
|
||||
* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
|
||||
* **Physical infrastructure:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
|
||||
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
|
||||
* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||
* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
|
||||
* **Organization:** Manage tenant and contact assignments natively.
|
||||
* **Powerful search:** Easily find anything you need using a single global search function.
|
||||
@@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations.
|
||||
[](https://netboxlabs.com)
|
||||
|
||||
[](https://try.digitalocean.com/developer-cloud)
|
||||
|
||||
[](https://ns1.com)
|
||||
<br />
|
||||
[](https://sentry.io)
|
||||
|
||||
|
||||
@@ -121,7 +121,8 @@ social-auth-core
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django
|
||||
social-auth-app-django
|
||||
# See https://github.com/python-social-auth/social-app-django/issues/429
|
||||
social-auth-app-django==5.0.0
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
<VirtualHost *:80>
|
||||
# CHANGE THIS TO YOUR SERVER'S NAME
|
||||
ServerName netbox.example.com
|
||||
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ProxyPreserveHost On
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
||||
* Clearing expired authentication sessions from the database
|
||||
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
|
||||
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#jobresult_retention)
|
||||
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
|
||||
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
|
||||
@@ -18,4 +18,4 @@ interface.
|
||||
|
||||
Default: False
|
||||
|
||||
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base.
|
||||
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.
|
||||
|
||||
@@ -69,6 +69,14 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
|
||||
|
||||
Default: `2621440` (2.5 MB).
|
||||
|
||||
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
|
||||
---
|
||||
|
||||
## GRAPHQL_ENABLED
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
@@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated
|
||||
|
||||
Default: `'netbox.authentication.RemoteUserBackend'`
|
||||
|
||||
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins.
|
||||
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. Provide a string for a single backend, or an iterable for multiple backends, which will be attempted in the order given.
|
||||
|
||||
* `netbox.authentication.RemoteUserBackend`
|
||||
* `netbox.authentication.LDAPBackend`
|
||||
|
||||
@@ -38,7 +38,7 @@ In order to send email, NetBox needs an email server configured. The following i
|
||||
* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
|
||||
* `PORT` - TCP port to use for the connection (default: `25`)
|
||||
* `USERNAME` - Username with which to authenticate
|
||||
* `PASSSWORD` - Password with which to authenticate
|
||||
* `PASSWORD` - Password with which to authenticate
|
||||
* `USE_SSL` - Use SSL when connecting to the server (default: `False`)
|
||||
* `USE_TLS` - Use TLS when connecting to the server (default: `False`)
|
||||
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)
|
||||
|
||||
@@ -79,7 +79,22 @@ A human-friendly description of what your script does.
|
||||
|
||||
### `field_order`
|
||||
|
||||
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered. Any fields not included in this iterable be listed last.
|
||||
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered within a default "Script Data" group. Any fields not included in this iterable be listed last. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the form by default for the user.
|
||||
|
||||
### `fieldsets`
|
||||
|
||||
`fieldsets` may be defined as an iterable of field groups and their field names to determine the order in which variables are group and rendered. Any fields not included in this iterable will not be displayed in the form. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the fieldsets by default for the user.
|
||||
|
||||
An example fieldset definition is provided below:
|
||||
|
||||
```python
|
||||
class MyScript(Script):
|
||||
class Meta:
|
||||
fieldsets = (
|
||||
('First group', ('field1', 'field2', 'field3')),
|
||||
('Second group', ('field4', 'field5')),
|
||||
)
|
||||
```
|
||||
|
||||
### `commit_default`
|
||||
|
||||
@@ -302,7 +317,7 @@ Optionally `schedule_at` can be passed in the form data with a datetime string t
|
||||
Scripts can be run on the CLI by invoking the management command:
|
||||
|
||||
```
|
||||
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
|
||||
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
|
||||
```
|
||||
|
||||
The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
|
||||
|
||||
@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
|
||||
@@ -54,15 +54,19 @@ Each model should have a corresponding FilterSet class defined. This is used to
|
||||
|
||||
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
|
||||
|
||||
## 9. Create the object template
|
||||
## 9. Create a SearchIndex subclass
|
||||
|
||||
If this model will be included in global search results, create a subclass of `netbox.search.SearchIndex` for it and specify the fields to be indexed.
|
||||
|
||||
## 10. Create the object template
|
||||
|
||||
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
|
||||
|
||||
## 10. Add the model to the navigation menu
|
||||
## 11. Add the model to the navigation menu
|
||||
|
||||
Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.
|
||||
|
||||
## 11. REST API components
|
||||
## 12. REST API components
|
||||
|
||||
Create the following for each model:
|
||||
|
||||
@@ -71,13 +75,13 @@ Create the following for each model:
|
||||
* API view in `api/views.py`
|
||||
* Endpoint route in `api/urls.py`
|
||||
|
||||
## 12. GraphQL API components
|
||||
## 13. GraphQL API components
|
||||
|
||||
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
|
||||
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
|
||||
|
||||
## 13. Add tests
|
||||
## 14. Add tests
|
||||
|
||||
Add tests for the following:
|
||||
|
||||
@@ -85,7 +89,7 @@ Add tests for the following:
|
||||
* API views
|
||||
* Filter sets
|
||||
|
||||
## 14. Documentation
|
||||
## 15. Documentation
|
||||
|
||||
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user (
|
||||
```postgresql
|
||||
CREATE DATABASE netbox;
|
||||
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
|
||||
ALTER DATABASE netbox OWNER TO netbox;
|
||||
```
|
||||
|
||||
!!! danger "Use a strong password"
|
||||
|
||||
@@ -65,7 +65,7 @@ sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
|
||||
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
|
||||
|
||||
```no-highlight
|
||||
sudo a2enmod ssl proxy proxy_http headers
|
||||
sudo a2enmod ssl proxy proxy_http headers rewrite
|
||||
sudo a2ensite netbox
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
@@ -584,11 +584,16 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
|
||||
|
||||
#### Client IP Restriction
|
||||
|
||||
!!! note
|
||||
This feature was introduced in NetBox v3.3.
|
||||
|
||||
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
|
||||
|
||||
#### Creating Tokens for Other Users
|
||||
|
||||
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
|
||||
|
||||

|
||||
|
||||
!!! warning "Exercise Caution"
|
||||
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
|
||||
|
||||
### Authenticating to the API
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
NetBox was originally developed by its lead maintainer, [Jeremy Stretch](https://github.com/jeremystretch), while he was working as a network engineer at [DigitalOcean](https://www.digitalocean.com/) in 2015 as part of an effort to automate their network provisioning. Recognizing the new tool's potential, DigitalOcean agreed to release it as an open source project in June 2016.
|
||||
|
||||
Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation.
|
||||
Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation. Today, the open source project is stewarded by [NetBox Labs](https://netboxlabs.com/) and a team of volunteer maintainers. Beyond the core product, myriad [plugins](https://netbox.dev/plugins/) have been developed by the NetBox community to enhance and expand its feature set.
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -17,6 +17,7 @@ NetBox was built specifically to serve the needs of network engineers and operat
|
||||
* AS number (ASN) management
|
||||
* Rack elevations with SVG rendering
|
||||
* Device modeling using pre-defined types
|
||||
* Virtual chassis and device contexts
|
||||
* Network, power, and console cabling with SVG traces
|
||||
* Power distribution modeling
|
||||
* Data circuit and provider tracking
|
||||
@@ -29,12 +30,13 @@ NetBox was built specifically to serve the needs of network engineers and operat
|
||||
* Tenant ownership assignment
|
||||
* Device & VM configuration contexts for advanced configuration rendering
|
||||
* Custom fields for data model extension
|
||||
* Support for custom validation rules
|
||||
* Custom validation rules
|
||||
* Custom reports & scripts executable directly within the UI
|
||||
* Extensive plugin framework for adding custom functionality
|
||||
* Single sign-on (SSO) authentication
|
||||
* Robust object-based permissions
|
||||
* Detailed, automatic change logging
|
||||
* Global search engine
|
||||
* NAPALM integration
|
||||
|
||||
## What NetBox Is Not
|
||||
|
||||
BIN
docs/media/admin_ui_grant_permission.png
Normal file
BIN
docs/media/admin_ui_grant_permission.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -97,7 +97,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
|
||||
### Examples
|
||||
|
||||
`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied.
|
||||
`status` is "active" and `primary_ip4` is defined _or_ the "exempt" tag is applied.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -109,8 +109,8 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"attr": "primary_ip",
|
||||
"value": "",
|
||||
"attr": "primary_ip4",
|
||||
"value": null,
|
||||
"negate": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,115 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.8 (2023-04-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10414](https://github.com/netbox-community/netbox/issues/10414) - Enable general purpose image attachments for device types
|
||||
* [#10600](https://github.com/netbox-community/netbox/issues/10600) - Allow custom object fields to reference a user or group
|
||||
* [#11015](https://github.com/netbox-community/netbox/issues/11015) - Remove unit from commit rate column header in circuits table
|
||||
* [#11431](https://github.com/netbox-community/netbox/issues/11431) - Disallow changing custom field type after creation
|
||||
* [#11453](https://github.com/netbox-community/netbox/issues/11453) - Display a warning banner when `DEBUG` is enabled
|
||||
* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
|
||||
* [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
|
||||
* [#12207](https://github.com/netbox-community/netbox/issues/12207) - Introduce the `grant_token` permission for controlling the creation of API tokens on behalf of other users
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10221](https://github.com/netbox-community/netbox/issues/10221) - Validate generic foreign key relations assigned via REST API requests
|
||||
* [#11432](https://github.com/netbox-community/netbox/issues/11432) - Prevent existing components & component templates from being reassigned to different devices/device types via the REST API
|
||||
* [#11454](https://github.com/netbox-community/netbox/issues/11454) - Raise validation error if generic foreign key assignment does not specify both object type and ID
|
||||
* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
|
||||
* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
|
||||
* [#12040](https://github.com/netbox-community/netbox/issues/12040) - Display relevant UI tab upon bulk import validation failure
|
||||
* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
|
||||
* [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
|
||||
* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
|
||||
* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes
|
||||
* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
|
||||
* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
|
||||
* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
|
||||
* [#12227](https://github.com/netbox-community/netbox/issues/12227) - Fix tenant assignment on bulk import of L2VPNs
|
||||
|
||||
---
|
||||
|
||||
## v3.4.7 (2023-03-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11645](https://github.com/netbox-community/netbox/issues/11645) - Automatically set the scheduled time when executing reports/scripts at a recurring interval
|
||||
* [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms
|
||||
* [#11973](https://github.com/netbox-community/netbox/issues/11833) - Use SSID for representing wireless links, if set
|
||||
* [#11977](https://github.com/netbox-community/netbox/issues/11977) - Support designating multiple backends via `REMOTE_AUTH_BACKEND` config parameter
|
||||
* [#11990](https://github.com/netbox-community/netbox/issues/11990) - Improve error reporting for duplicate CSV column headings
|
||||
* [#11991](https://github.com/netbox-community/netbox/issues/11991) - Enable VDC assignment during bulk import/edit of interfaces
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11914](https://github.com/netbox-community/netbox/issues/11914) - Include parameters when exporting saved filters
|
||||
* [#11933](https://github.com/netbox-community/netbox/issues/11933) - Fix cloning of saved filters
|
||||
* [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type
|
||||
* [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list
|
||||
* [#12008](https://github.com/netbox-community/netbox/issues/12008) - Enable cloning of export templates
|
||||
* [#12029](https://github.com/netbox-community/netbox/issues/12029) - Restore missing description field on virtual chassis form
|
||||
* [#12038](https://github.com/netbox-community/netbox/issues/12038) - Correct display of zero values for virtual chassis member priority
|
||||
* [#12048](https://github.com/netbox-community/netbox/issues/12048) - Enable cloning of tags
|
||||
* [#12058](https://github.com/netbox-community/netbox/issues/12058) - Enable cloning of config contexts
|
||||
|
||||
---
|
||||
|
||||
## v3.4.6 (2023-03-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10058](https://github.com/netbox-community/netbox/issues/10058) - Enable searching for devices/VMs by primary IP address
|
||||
* [#11011](https://github.com/netbox-community/netbox/issues/11011) - Add ability to toggle visibility of virtual interfaces under device view
|
||||
* [#11294](https://github.com/netbox-community/netbox/issues/11294) - Enable live preview of Markdown content
|
||||
* [#11807](https://github.com/netbox-community/netbox/issues/11807) - Restore default page size when navigating between views
|
||||
* [#11817](https://github.com/netbox-community/netbox/issues/11817) - Add `connected_endpoints` field to GraphQL API for cabled objects
|
||||
* [#11851](https://github.com/netbox-community/netbox/issues/11851) - Include IP version in GraphQL API representations of aggregates, prefixes, and IP addresses
|
||||
* [#11862](https://github.com/netbox-community/netbox/issues/11862) - Add Cisco StackWise 1T interface type
|
||||
* [#11871](https://github.com/netbox-community/netbox/issues/11871) - Add IEEE 802.3az PoE type for interfaces
|
||||
* [#11929](https://github.com/netbox-community/netbox/issues/11929) - Strip whitespace from CSV headers prior to validation
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11470](https://github.com/netbox-community/netbox/issues/11470) - Avoid raising exception when filtering IPs by an invalid address
|
||||
* [#11565](https://github.com/netbox-community/netbox/issues/11565) - Apply custom field defaults to IP address created during FHRP group creation
|
||||
* [#11631](https://github.com/netbox-community/netbox/issues/11631) - Fix filtering changelog & journal entries by multiple content type IDs
|
||||
* [#11758](https://github.com/netbox-community/netbox/issues/11758) - Support non-URL-safe characters in plugin menu titles
|
||||
* [#11796](https://github.com/netbox-community/netbox/issues/11796) - When importing devices, restrict rack by location only if the location field is specified
|
||||
* [#11819](https://github.com/netbox-community/netbox/issues/11819) - Fix filtering of cable terminations by object type
|
||||
* [#11850](https://github.com/netbox-community/netbox/issues/11850) - Fix loading of CSV files containing a byte order mark
|
||||
* [#11903](https://github.com/netbox-community/netbox/issues/11903) - Fix escaping of return URL values for action buttons in tables
|
||||
* [#11927](https://github.com/netbox-community/netbox/issues/11927) - Correct loading of plugin resources with custom paths
|
||||
|
||||
---
|
||||
|
||||
## v3.4.5 (2023-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11110](https://github.com/netbox-community/netbox/issues/11110) - Add `start_address` and `end_address` filters for IP ranges
|
||||
* [#11592](https://github.com/netbox-community/netbox/issues/11592) - Introduce `FILE_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
|
||||
* [#11685](https://github.com/netbox-community/netbox/issues/11685) - Match on containing prefixes and aggregates when querying for IP addresses using global search
|
||||
* [#11787](https://github.com/netbox-community/netbox/issues/11787) - Upgrade script will automatically rebuild missing search cache
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11032](https://github.com/netbox-community/netbox/issues/11032) - Fix false custom validation errors during component creation
|
||||
* [#11226](https://github.com/netbox-community/netbox/issues/11226) - Ensure scripts and reports within submodules are automatically reloaded
|
||||
* [#11459](https://github.com/netbox-community/netbox/issues/11459) - Enable evaluating null values in custom validation rules
|
||||
* [#11473](https://github.com/netbox-community/netbox/issues/11473) - GraphQL requests specifying an invalid filter should return an empty queryset
|
||||
* [#11582](https://github.com/netbox-community/netbox/issues/11582) - Ensure form validation errors are displayed when adding virtual chassis members
|
||||
* [#11601](https://github.com/netbox-community/netbox/issues/11601) - Fix partial matching of start/end addresses for IP range search
|
||||
* [#11683](https://github.com/netbox-community/netbox/issues/11683) - Fix CSV header attribute detection when auto-detecting import format
|
||||
* [#11711](https://github.com/netbox-community/netbox/issues/11711) - Fix CSV import for multiple-object custom fields
|
||||
* [#11723](https://github.com/netbox-community/netbox/issues/11723) - Circuit terminations should link to their associated circuits (rather than site or provider network)
|
||||
* [#11775](https://github.com/netbox-community/netbox/issues/11775) - Skip checking for old search cache records when creating a new object
|
||||
* [#11786](https://github.com/netbox-community/netbox/issues/11786) - List only applicable object types in form widget when filtering custom fields
|
||||
|
||||
---
|
||||
|
||||
## v3.4.4 (2023-02-02)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -8,6 +8,9 @@ theme:
|
||||
custom_dir: docs/_theme/
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
features:
|
||||
- content.code.copy
|
||||
- navigation.footer
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
@@ -20,7 +23,8 @@ theme:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Light Mode
|
||||
plugins:
|
||||
- search
|
||||
- search:
|
||||
lang: en
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
|
||||
@@ -7,7 +7,7 @@ from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
StaticSelect,
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
|
||||
@@ -196,12 +196,10 @@ class CircuitTermination(
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'Termination {self.term_side}: {self.site or self.provider_network}'
|
||||
return f'{self.circuit}: Termination {self.term_side}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
if self.site:
|
||||
return self.site.get_absolute_url()
|
||||
return self.provider_network.get_absolute_url()
|
||||
return self.circuit.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -57,7 +57,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side Z'
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
commit_rate = CommitRateColumn(
|
||||
verbose_name='Commit Rate'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
|
||||
@@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
filterset = filtersets.CircuitTypeFilterSet
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
|
||||
@@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
||||
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
||||
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
||||
TYPE_STACKWISE1T = 'cisco-stackwise-1t'
|
||||
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
||||
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
||||
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
||||
@@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
||||
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
||||
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
||||
(TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
|
||||
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
||||
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
||||
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
||||
|
||||
@@ -24,6 +24,7 @@ __all__ = (
|
||||
'CableFilterSet',
|
||||
'CabledObjectFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'CommonInterfaceFilterSet',
|
||||
'ConsoleConnectionFilterSet',
|
||||
'ConsolePortFilterSet',
|
||||
'ConsolePortTemplateFilterSet',
|
||||
@@ -981,7 +982,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
Q(comments__icontains=value) |
|
||||
Q(primary_ip4__address__startswith=value) |
|
||||
Q(primary_ip6__address__startswith=value)
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
@@ -1319,11 +1322,63 @@ class PowerOutletFilterSet(
|
||||
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
|
||||
|
||||
|
||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
)
|
||||
vlan = django_filters.CharFilter(
|
||||
method='filter_vlan',
|
||||
label=_('Assigned VID')
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
label=_('VRF'),
|
||||
)
|
||||
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf__rd',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn',
|
||||
queryset=L2VPN.objects.all(),
|
||||
label=_('L2VPN (ID)'),
|
||||
)
|
||||
l2vpn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn__identifier',
|
||||
queryset=L2VPN.objects.all(),
|
||||
to_field_name='identifier',
|
||||
label=_('L2VPN'),
|
||||
)
|
||||
|
||||
def filter_vlan_id(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id=value) |
|
||||
Q(tagged_vlans=value)
|
||||
)
|
||||
|
||||
def filter_vlan(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id__vid=value) |
|
||||
Q(tagged_vlans__vid=value)
|
||||
)
|
||||
|
||||
|
||||
class InterfaceFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
PathEndpointFilterSet,
|
||||
CommonInterfaceFilterSet
|
||||
):
|
||||
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
|
||||
# members
|
||||
@@ -1368,14 +1423,6 @@ class InterfaceFilterSet(
|
||||
poe_type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoETypeChoices
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
)
|
||||
vlan = django_filters.CharFilter(
|
||||
method='filter_vlan',
|
||||
label=_('Assigned VID')
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
@@ -1386,17 +1433,6 @@ class InterfaceFilterSet(
|
||||
rf_channel = django_filters.MultipleChoiceFilter(
|
||||
choices=WirelessChannelChoices
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
label=_('VRF'),
|
||||
)
|
||||
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf__rd',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
vdc_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vdcs',
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
@@ -1414,17 +1450,6 @@ class InterfaceFilterSet(
|
||||
to_field_name='name',
|
||||
label='Virtual Device Context',
|
||||
)
|
||||
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn',
|
||||
queryset=L2VPN.objects.all(),
|
||||
label=_('L2VPN (ID)'),
|
||||
)
|
||||
l2vpn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn__identifier',
|
||||
queryset=L2VPN.objects.all(),
|
||||
to_field_name='identifier',
|
||||
label=_('L2VPN'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
@@ -1454,24 +1479,6 @@ class InterfaceFilterSet(
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
def filter_vlan_id(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id=value) |
|
||||
Q(tagged_vlans=value)
|
||||
)
|
||||
|
||||
def filter_vlan(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id__vid=value) |
|
||||
Q(tagged_vlans__vid=value)
|
||||
)
|
||||
|
||||
def filter_kind(self, queryset, name, value):
|
||||
value = value.strip().lower()
|
||||
return {
|
||||
@@ -1660,12 +1667,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_a_id = MultiValueNumberFilter(
|
||||
method='filter_by_cable_end_a',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
termination_b_type = ContentTypeFilter(
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_b_id = MultiValueNumberFilter(
|
||||
method='filter_by_cable_end_b',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1723,8 +1732,21 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
# Supported objects: device, rack, location, site
|
||||
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
|
||||
|
||||
def filter_by_cable_end(self, queryset, name, value, side):
|
||||
# Filter by termination id and cable_end type
|
||||
return queryset.filter(**{f'{name}__in': value, 'terminations__cable_end': side}).distinct()
|
||||
|
||||
def filter_by_cable_end_a(self, queryset, name, value):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_A)
|
||||
|
||||
def filter_by_cable_end_b(self, queryset, name, value):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = CableTermination
|
||||
|
||||
@@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
|
||||
|
||||
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
model = ModuleBay
|
||||
field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'position', 'description', 'tags')
|
||||
replication_fields = ('name', 'label', 'position')
|
||||
position_pattern = ExpandableNameField(
|
||||
position = ExpandableNameField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')
|
||||
|
||||
@@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
|
||||
DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@@ -1186,6 +1175,14 @@ class InterfaceBulkEditForm(
|
||||
},
|
||||
label=_('LAG')
|
||||
)
|
||||
vdcs = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label='Virtual Device Contexts',
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
speed = forms.IntegerField(
|
||||
required=False,
|
||||
widget=SelectSpeedWidget(),
|
||||
@@ -1251,14 +1248,14 @@ class InterfaceBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||
)
|
||||
|
||||
@@ -11,7 +11,9 @@ from dcim.models import *
|
||||
from ipam.models import VRF
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
@@ -447,11 +449,14 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
# Limit rack queryset by assigned site and group
|
||||
# Limit rack queryset by assigned site and location
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
}
|
||||
if 'location' in data:
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
# Limit device bay queryset by parent device
|
||||
@@ -664,6 +669,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Parent LAG interface')
|
||||
)
|
||||
vdcs = CSVModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
help_text=_('Physical medium')
|
||||
@@ -703,7 +714,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
model = Interface
|
||||
fields = (
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
|
||||
)
|
||||
|
||||
@@ -719,6 +730,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
|
||||
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
||||
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
@@ -727,6 +739,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
def clean_vdcs(self):
|
||||
for vdc in self.cleaned_data['vdcs']:
|
||||
if vdc.device != self.cleaned_data['device']:
|
||||
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
|
||||
return self.cleaned_data['vdcs']
|
||||
|
||||
|
||||
class FrontPortImportForm(NetBoxModelImportForm):
|
||||
device = CSVModelChoiceField(
|
||||
|
||||
@@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = [
|
||||
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
'name', 'domain', 'description', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -10,3 +10,11 @@ class CabledObjectMixin:
|
||||
|
||||
def resolve_link_peers(self, info):
|
||||
return self.link_peers
|
||||
|
||||
|
||||
class PathEndpointMixin:
|
||||
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
|
||||
|
||||
def resolve_connected_endpoints(self, info):
|
||||
# Handle empty values
|
||||
return self.connected_endpoints or None
|
||||
|
||||
@@ -7,7 +7,7 @@ from extras.graphql.mixins import (
|
||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
from .mixins import CabledObjectMixin
|
||||
from .mixins import CabledObjectMixin, PathEndpointMixin
|
||||
|
||||
__all__ = (
|
||||
'CableType',
|
||||
@@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
|
||||
filterset_class = filtersets.CableTerminationFilterSet
|
||||
|
||||
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
@@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
@@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
@@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
@@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
|
||||
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
@@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
|
||||
@@ -152,8 +152,6 @@ class Cable(PrimaryModel):
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
@@ -187,6 +185,10 @@ class Cable(PrimaryModel):
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
# Clear length_unit if no length is defined
|
||||
if self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
|
||||
@@ -120,6 +120,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
if self.device_type is not None:
|
||||
@@ -131,6 +137,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device_type != self.device_type_id:
|
||||
raise ValidationError({
|
||||
"device_type": "Component templates cannot be moved to a different device type."
|
||||
})
|
||||
|
||||
# A component template must belong to a DeviceType *or* to a ModuleType
|
||||
if self.device_type and self.module_type:
|
||||
raise ValidationError(
|
||||
|
||||
@@ -78,6 +78,12 @@ class ComponentModel(NetBoxModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
return f"{self.name} ({self.label})"
|
||||
@@ -88,6 +94,14 @@ class ComponentModel(NetBoxModel):
|
||||
objectchange.related_object = self.device
|
||||
return objectchange
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device != self.device_id:
|
||||
raise ValidationError({
|
||||
"device": "Components cannot be moved to a different device."
|
||||
})
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
return self.device
|
||||
@@ -794,8 +808,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
raise ValidationError({
|
||||
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
|
||||
})
|
||||
elif self.rf_channel:
|
||||
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
|
||||
|
||||
# Validate channel width against interface type and selected channel (if any)
|
||||
if self.rf_channel_width:
|
||||
@@ -803,8 +815,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
|
||||
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
|
||||
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
|
||||
elif self.rf_channel:
|
||||
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
|
||||
|
||||
# VLAN validation
|
||||
|
||||
@@ -815,6 +825,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
f"interface's parent device, or it must be global."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set absolute channel attributes from selected options
|
||||
if self.rf_channel and not self.rf_channel_frequency:
|
||||
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
|
||||
if self.rf_channel and not self.rf_channel_width:
|
||||
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
return super()._occupied or bool(self.wireless_link_id)
|
||||
|
||||
@@ -120,6 +120,10 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
blank=True
|
||||
)
|
||||
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||
)
|
||||
@@ -659,8 +663,6 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
raise ValidationError({
|
||||
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
|
||||
})
|
||||
elif self.rack:
|
||||
self.location = self.rack.location
|
||||
|
||||
if self.rack is None:
|
||||
if self.face:
|
||||
@@ -776,8 +778,10 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
||||
(default). Otherwise, save() will be called on each instance individually.
|
||||
"""
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if components and bulk_create:
|
||||
if bulk_create:
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
model = components[0]._meta.model
|
||||
model.objects.bulk_create(components)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
@@ -790,8 +794,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
using='default',
|
||||
update_fields=None
|
||||
)
|
||||
elif components:
|
||||
for component in components:
|
||||
else:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
component.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -801,6 +806,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
if is_new and not self.airflow:
|
||||
self.airflow = self.device_type.airflow
|
||||
|
||||
if self.rack and self.rack.location:
|
||||
self.location = self.rack.location
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If this is a new Device, instantiate all the related components per the DeviceType definition
|
||||
|
||||
@@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
|
||||
raise ValidationError("Must specify a unit when setting an outer width/depth")
|
||||
elif self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
# Validate max_weight and weight_unit
|
||||
if self.max_weight and not self.weight_unit:
|
||||
@@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
else:
|
||||
self._abs_max_weight = None
|
||||
|
||||
# Clear unit if outer width & depth are not set
|
||||
if self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
||||
@@ -588,6 +588,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
filterset = filtersets.RackRoleFilterSet
|
||||
table = tables.RackRoleTable
|
||||
|
||||
|
||||
@@ -909,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer')
|
||||
)
|
||||
filterset = filtersets.ManufacturerFilterSet
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@@ -1808,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
filterset = filtersets.DeviceRoleFilterSet
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
|
||||
@@ -1868,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView):
|
||||
|
||||
class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Platform.objects.all()
|
||||
filterset = filtersets.PlatformFilterSet
|
||||
table = tables.PlatformTable
|
||||
|
||||
|
||||
@@ -2981,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
|
||||
|
||||
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
@@ -3038,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItemRole.objects.annotate(
|
||||
inventoryitem_count=count_related(InventoryItem, 'role'),
|
||||
)
|
||||
filterset = filtersets.InventoryItemRoleFilterSet
|
||||
table = tables.InventoryItemRoleTable
|
||||
|
||||
|
||||
|
||||
@@ -97,6 +97,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
|
||||
return value
|
||||
|
||||
def get_data_type(self, obj):
|
||||
types = CustomFieldTypeChoices
|
||||
if obj.type == types.TYPE_INTEGER:
|
||||
|
||||
@@ -44,7 +44,8 @@ class Condition:
|
||||
bool: (EQ, CONTAINS),
|
||||
int: (EQ, GT, GTE, LT, LTE, CONTAINS),
|
||||
float: (EQ, GT, GTE, LT, LTE, CONTAINS),
|
||||
list: (EQ, IN, CONTAINS)
|
||||
list: (EQ, IN, CONTAINS),
|
||||
type(None): (EQ,)
|
||||
}
|
||||
|
||||
def __init__(self, attr, value, op=EQ, negate=False):
|
||||
|
||||
8
netbox/extras/fields.py
Normal file
8
netbox/extras/fields.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.db.models import TextField
|
||||
|
||||
|
||||
class CachedValueField(TextField):
|
||||
"""
|
||||
Currently a dummy field to prevent custom lookups being applied globally to TextField.
|
||||
"""
|
||||
pass
|
||||
@@ -210,6 +210,9 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
created = django_filters.DateTimeFromToRangeFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
@@ -458,6 +461,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
time = django_filters.DateTimeFromToRangeFilter()
|
||||
changed_object_type = ContentTypeFilter()
|
||||
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
|
||||
@@ -2,6 +2,7 @@ from .model_forms import *
|
||||
from .filtersets import *
|
||||
from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .misc import *
|
||||
from .mixins import *
|
||||
from .config import *
|
||||
from .scripts import *
|
||||
|
||||
@@ -38,8 +38,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
required=False,
|
||||
label=_('Object type')
|
||||
)
|
||||
@@ -79,8 +78,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
obj_type = ContentTypeChoiceField(
|
||||
label=_('Object Type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
|
||||
queryset=ContentType.objects.filter(FeatureQuery('job_results').get_query()),
|
||||
required=False,
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
@@ -135,8 +133,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_links'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
@@ -162,8 +159,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('export_templates'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
required=False
|
||||
)
|
||||
mime_type = forms.CharField(
|
||||
@@ -187,8 +183,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('content_types', 'enabled', 'shared', 'weight')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('export_templates'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
@@ -215,8 +210,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('webhooks'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
||||
required=False,
|
||||
label=_('Object type')
|
||||
)
|
||||
|
||||
14
netbox/extras/forms/misc.py
Normal file
14
netbox/extras/forms/misc.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
__all__ = (
|
||||
'RenderMarkdownForm',
|
||||
)
|
||||
|
||||
|
||||
class RenderMarkdownForm(forms.Form):
|
||||
"""
|
||||
Provides basic validation for markup to be rendered.
|
||||
"""
|
||||
text = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import QueryDict
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
@@ -36,7 +38,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
object_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
# TODO: Come up with a canonical way to register suitable models
|
||||
limit_choices_to=FeatureQuery('webhooks'),
|
||||
limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
|
||||
required=False,
|
||||
help_text=_("Type of the related object (for object/multi-object fields only)")
|
||||
)
|
||||
@@ -63,6 +65,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
'ui_visibility': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
|
||||
if self.instance.pk:
|
||||
self.fields['type'].disabled = True
|
||||
|
||||
|
||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
@@ -128,11 +137,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
# Convert any parameters delivered via initial data to a dictionary
|
||||
# Convert any parameters delivered via initial data to JSON data
|
||||
if initial and 'parameters' in initial:
|
||||
if type(initial['parameters']) is str:
|
||||
# TODO: Make a utility function for this
|
||||
initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
|
||||
initial['parameters'] = json.loads(initial['parameters'])
|
||||
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
@@ -254,6 +262,15 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
'tenants', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
# Convert data delivered via initial data to JSON data
|
||||
if initial and 'data' in initial:
|
||||
if type(initial['data']) is str:
|
||||
initial['data'] = json.loads(initial['data'])
|
||||
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
|
||||
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
|
||||
@@ -25,12 +25,16 @@ class ReportForm(BootstrapMixin, forms.Form):
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
def clean_schedule_at(self):
|
||||
def clean(self):
|
||||
scheduled_time = self.cleaned_data['schedule_at']
|
||||
if scheduled_time and scheduled_time < timezone.now():
|
||||
if scheduled_time and scheduled_time < local_now():
|
||||
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||
|
||||
return scheduled_time
|
||||
# When interval is used without schedule at, raise an exception
|
||||
if self.cleaned_data['interval'] and not scheduled_time:
|
||||
self.cleaned_data['schedule_at'] = local_now()
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -52,7 +52,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
|
||||
# When interval is used without schedule at, raise an exception
|
||||
if self.cleaned_data['_interval'] and not scheduled_time:
|
||||
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
|
||||
self.cleaned_data['_schedule_at'] = local_now()
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db.models import CharField, Lookup
|
||||
from django.db.models import CharField, TextField, Lookup
|
||||
from .fields import CachedValueField
|
||||
|
||||
|
||||
class Empty(Lookup):
|
||||
@@ -14,4 +15,18 @@ class Empty(Lookup):
|
||||
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
class NetContainsOrEquals(Lookup):
|
||||
"""
|
||||
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
|
||||
"""
|
||||
lookup_name = 'net_contains_or_equals'
|
||||
|
||||
def as_sql(self, qn, connection):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
CharField.register_lookup(Empty)
|
||||
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||
|
||||
@@ -37,7 +37,7 @@ class Command(BaseCommand):
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
|
||||
# Delete expired ObjectRecords
|
||||
# Delete expired ObjectChanges
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Checking for expired changelog records")
|
||||
if config.CHANGELOG_RETENTION:
|
||||
|
||||
@@ -15,6 +15,11 @@ class Command(BaseCommand):
|
||||
nargs='*',
|
||||
help='One or more apps or models to reindex',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--lazy',
|
||||
action='store_true',
|
||||
help="For each model, reindex objects only if no cache entries already exist"
|
||||
)
|
||||
|
||||
def _get_indexers(self, *model_names):
|
||||
indexers = {}
|
||||
@@ -60,14 +65,15 @@ class Command(BaseCommand):
|
||||
raise CommandError("No indexers found!")
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
if not kwargs['lazy']:
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
|
||||
# Index models
|
||||
self.stdout.write('Indexing models')
|
||||
@@ -76,11 +82,18 @@ class Command(BaseCommand):
|
||||
model_name = model._meta.model_name
|
||||
self.stdout.write(f' {app_label}.{model_name}... ', ending='')
|
||||
self.stdout.flush()
|
||||
|
||||
if kwargs['lazy']:
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
if cached_count := search_backend.count(object_types=[content_type]):
|
||||
self.stdout.write(f'Skipping (found {cached_count} existing).')
|
||||
continue
|
||||
|
||||
i = search_backend.cache(model.objects.iterator(), remove_existing=False)
|
||||
if i:
|
||||
self.stdout.write(f'{i} entries cached.')
|
||||
else:
|
||||
self.stdout.write(f'None found.')
|
||||
self.stdout.write(f'No objects found.')
|
||||
|
||||
msg = f'Completed.'
|
||||
if total_count := search_backend.size:
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.lookups
|
||||
from django.core import management
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def reindex(apps, schema_editor):
|
||||
# Build the search index (except during tests)
|
||||
if 'test' not in sys.argv:
|
||||
management.call_command(
|
||||
'reindex',
|
||||
'circuits',
|
||||
'dcim',
|
||||
'extras',
|
||||
'ipam',
|
||||
'tenancy',
|
||||
'virtualization',
|
||||
'wireless',
|
||||
)
|
||||
import extras.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -49,7 +33,7 @@ class Migration(migrations.Migration):
|
||||
('object_id', models.PositiveBigIntegerField()),
|
||||
('field', models.CharField(max_length=200)),
|
||||
('type', models.CharField(max_length=30)),
|
||||
('value', models.TextField()),
|
||||
('value', extras.fields.CachedValueField()),
|
||||
('weight', models.PositiveSmallIntegerField(default=1000)),
|
||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
||||
],
|
||||
@@ -57,8 +41,4 @@ class Migration(migrations.Migration):
|
||||
'ordering': ('weight', 'object_type', 'object_id'),
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=reindex,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
from netbox.models.features import CloningMixin, WebhooksMixin
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
class ConfigContext(CloningMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
@@ -108,6 +108,12 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
|
||||
objects = ConfigContextQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@ from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||
from netbox.search import FieldTypes
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
from utilities.forms.fields import (
|
||||
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, LaxURLField,
|
||||
)
|
||||
from utilities.forms.widgets import DatePicker, StaticSelectMultiple, StaticSelect
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
@@ -213,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
|
||||
instances = model.objects.filter(custom_field_data__has_key=self.name)
|
||||
for instance in instances:
|
||||
del instance.custom_field_data[self.name]
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
@@ -413,7 +415,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = self.object_type.model_class()
|
||||
field = DynamicModelChoiceField(
|
||||
field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
|
||||
field = field_class(
|
||||
queryset=model.objects.all(),
|
||||
required=required,
|
||||
initial=initial
|
||||
@@ -422,10 +425,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
# Multiple objects
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
model = self.object_type.model_class()
|
||||
field = DynamicModelMultipleChoiceField(
|
||||
field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
|
||||
field = field_class(
|
||||
queryset=model.objects.all(),
|
||||
required=required,
|
||||
initial=initial
|
||||
initial=initial,
|
||||
)
|
||||
|
||||
# Text
|
||||
|
||||
@@ -245,7 +245,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -280,7 +280,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
||||
}
|
||||
|
||||
|
||||
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
class ExportTemplate(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='export_templates',
|
||||
@@ -301,7 +301,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='MIME type',
|
||||
help_text=_('Defaults to <code>text/plain</code>')
|
||||
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
|
||||
)
|
||||
file_extension = models.CharField(
|
||||
max_length=15,
|
||||
@@ -313,6 +313,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
help_text=_("Download file as attachment")
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
|
||||
@@ -353,7 +357,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
Render the template to an HTTP response, delivered as a named file attachment
|
||||
"""
|
||||
output = self.render(queryset)
|
||||
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
||||
mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
|
||||
|
||||
# Build the response
|
||||
response = HttpResponse(output, content_type=mime_type)
|
||||
@@ -406,7 +410,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
parameters = models.JSONField()
|
||||
|
||||
clone_fields = (
|
||||
'enabled', 'weight',
|
||||
'content_types', 'weight', 'enabled', 'parameters',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
|
||||
from utilities.fields import RestrictedGenericForeignKey
|
||||
from ..fields import CachedValueField
|
||||
|
||||
__all__ = (
|
||||
'CachedValue',
|
||||
@@ -36,7 +37,7 @@ class CachedValue(models.Model):
|
||||
type = models.CharField(
|
||||
max_length=30
|
||||
)
|
||||
value = models.TextField()
|
||||
value = CachedValueField()
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=1000
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.utils.text import slugify
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
|
||||
@@ -14,7 +14,7 @@ from utilities.fields import ColorField
|
||||
# Tags
|
||||
#
|
||||
|
||||
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
class Tag(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
id = models.BigAutoField(
|
||||
primary_key=True
|
||||
)
|
||||
@@ -26,6 +26,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'color', 'description',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ class PluginConfig(AppConfig):
|
||||
|
||||
def _load_resource(self, name):
|
||||
# Import from the configured path, if defined.
|
||||
if getattr(self, name):
|
||||
return import_string(f"{self.__module__}.{self.name}")
|
||||
if path := getattr(self, name, None):
|
||||
return import_string(f"{self.__module__}.{path}")
|
||||
|
||||
# Fall back to the resource's default path. Return None if the module has not been provided.
|
||||
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
@@ -21,7 +22,7 @@ class PluginMenu:
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.label.replace(' ', '_')
|
||||
return slugify(self.label)
|
||||
|
||||
|
||||
class PluginMenuItem:
|
||||
|
||||
@@ -352,6 +352,18 @@ class BaseScript:
|
||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
|
||||
|
||||
# Append the default fieldset if defined in the Meta class
|
||||
default_fieldset = (
|
||||
('Script Execution Parameters', ('_schedule_at', '_interval', '_commit')),
|
||||
)
|
||||
if not hasattr(self.Meta, 'fieldsets'):
|
||||
fields = (
|
||||
name for name, _ in self._get_vars().items()
|
||||
)
|
||||
self.Meta.fieldsets = (('Script Data', fields),)
|
||||
|
||||
self.Meta.fieldsets += default_fieldset
|
||||
|
||||
return form
|
||||
|
||||
# Logging
|
||||
@@ -524,27 +536,39 @@ def get_scripts(use_names=False):
|
||||
defined name in place of the actual module name.
|
||||
"""
|
||||
scripts = {}
|
||||
# Iterate through all modules within the scripts path. These are the user-created files in which reports are
|
||||
|
||||
# Get all modules within the scripts path. These are the user-created files in which scripts are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
# Use a lock as removing and loading modules is not thread safe
|
||||
with lock:
|
||||
# Remove cached module to ensure consistency with filesystem
|
||||
if module_name in sys.modules:
|
||||
modules = list(pkgutil.iter_modules([settings.SCRIPTS_ROOT]))
|
||||
modules_bases = set([name.split(".")[0] for _, name, _ in modules])
|
||||
|
||||
# Deleting from sys.modules needs to done behind a lock to prevent race conditions where a module is
|
||||
# removed from sys.modules while another thread is importing
|
||||
with lock:
|
||||
for module_name in list(sys.modules.keys()):
|
||||
# Everything sharing a base module path with a module in the script folder is removed.
|
||||
# We also remove all modules with a base module called "scripts". This allows modifying imported
|
||||
# non-script modules without having to reload the RQ worker.
|
||||
module_base = module_name.split(".")[0]
|
||||
if module_base == "scripts" or module_base in modules_bases:
|
||||
del sys.modules[module_name]
|
||||
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
for importer, module_name, _ in modules:
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
|
||||
if use_names and hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
|
||||
module_scripts = {}
|
||||
script_order = getattr(module, "script_order", ())
|
||||
ordered_scripts = [cls for cls in script_order if is_script(cls)]
|
||||
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
|
||||
|
||||
for cls in [*ordered_scripts, *unordered_scripts]:
|
||||
# For scripts in submodules use the full import path w/o the root module as the name
|
||||
script_name = cls.full_name.split(".", maxsplit=1)[1]
|
||||
module_scripts[script_name] = cls
|
||||
|
||||
if module_scripts:
|
||||
scripts[module_name] = module_scripts
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -110,11 +112,14 @@ class SavedFilterTable(NetBoxTable):
|
||||
enabled = columns.BooleanColumn()
|
||||
shared = columns.BooleanColumn()
|
||||
|
||||
def value_parameters(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = SavedFilter
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
|
||||
'created', 'last_updated',
|
||||
'created', 'last_updated', 'parameters'
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import datetime
|
||||
from unittest import skipIf
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rq import Worker
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
||||
@@ -16,8 +13,6 @@ from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
rq_worker_running = Worker.count(get_connection('default'))
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
|
||||
@@ -107,6 +102,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
update_data = {
|
||||
'content_types': ['dcim.device'],
|
||||
'name': 'New_Name',
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -539,16 +539,6 @@ class ReportTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['name'], self.TestReport.__name__)
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_report(self):
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
url = reverse('extras-api:report-run', kwargs={'pk': None})
|
||||
response = self.client.post(url, {}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class ScriptTest(APITestCase):
|
||||
|
||||
@@ -589,27 +579,6 @@ class ScriptTest(APITestCase):
|
||||
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
|
||||
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_script(self):
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
script_data = {
|
||||
'var1': 'FooBar',
|
||||
'var2': 123,
|
||||
'var3': False,
|
||||
}
|
||||
|
||||
data = {
|
||||
'data': script_data,
|
||||
'commit': True,
|
||||
}
|
||||
|
||||
url = reverse('extras-api:script-detail', kwargs={'pk': None})
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
|
||||
@@ -126,6 +126,16 @@ class ConditionSetTest(TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
ConditionSet({'foo': []})
|
||||
|
||||
def test_null_value(self):
|
||||
cs = ConditionSet({
|
||||
'and': [
|
||||
{'attr': 'a', 'value': None, 'op': 'eq', 'negate': True},
|
||||
]
|
||||
})
|
||||
self.assertFalse(cs.eval({'a': None}))
|
||||
self.assertTrue(cs.eval({'a': "string"}))
|
||||
self.assertTrue(cs.eval({'a': {"key": "value"}}))
|
||||
|
||||
def test_and_single_depth(self):
|
||||
cs = ConditionSet({
|
||||
'and': [
|
||||
|
||||
@@ -502,7 +502,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_assigned_object_type(self):
|
||||
params = {'assigned_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_assigned_object(self):
|
||||
@@ -876,7 +876,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||
def test_changed_object_type(self):
|
||||
params = {'changed_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_changed_object_type_id(self):
|
||||
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.test import TestCase
|
||||
from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.forms import SavedFilterForm
|
||||
from extras.models import CustomField
|
||||
|
||||
|
||||
@@ -77,3 +78,24 @@ class CustomFieldModelFormTest(TestCase):
|
||||
for field_type, _ in CustomFieldTypeChoices.CHOICES:
|
||||
self.assertIn(field_type, instance.custom_field_data)
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
|
||||
|
||||
class SavedFilterFormTest(TestCase):
|
||||
|
||||
def test_basic_submit(self):
|
||||
"""
|
||||
Test form submission and validation
|
||||
"""
|
||||
form = SavedFilterForm({
|
||||
'name': 'test-sf',
|
||||
'slug': 'test-sf',
|
||||
'content_types': [ContentType.objects.get_for_model(Site).pk],
|
||||
'weight': 100,
|
||||
'parameters': {
|
||||
"status": [
|
||||
"active"
|
||||
]
|
||||
}
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
form.save()
|
||||
|
||||
@@ -92,4 +92,6 @@ urlpatterns = [
|
||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
|
||||
|
||||
# Markdown
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
@@ -10,6 +10,7 @@ from rq import Worker
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
@@ -413,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = filtersets.ConfigContextFilterSet
|
||||
table = tables.ConfigContextTable
|
||||
|
||||
|
||||
@@ -885,3 +887,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = JobResult.objects.all()
|
||||
filterset = filtersets.JobResultFilterSet
|
||||
table = tables.JobResultTable
|
||||
|
||||
|
||||
#
|
||||
# Markdown
|
||||
#
|
||||
|
||||
class RenderMarkdownView(View):
|
||||
|
||||
def post(self, request):
|
||||
form = forms.RenderMarkdownForm(request.POST)
|
||||
if not form.is_valid():
|
||||
HttpResponseBadRequest()
|
||||
rendered = render_markdown(form.cleaned_data['text'])
|
||||
|
||||
return HttpResponse(rendered)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import secrets
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
__all__ = (
|
||||
'AggregateFilterSet',
|
||||
@@ -405,6 +406,14 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
field_name='start_address',
|
||||
lookup_expr='family'
|
||||
)
|
||||
start_address = MultiValueCharFilter(
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
)
|
||||
end_address = MultiValueCharFilter(
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
)
|
||||
contains = django_filters.CharFilter(
|
||||
method='search_contains',
|
||||
label=_('Ranges which contain this prefix or IP'),
|
||||
@@ -441,9 +450,9 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
qs_filter = Q(description__icontains=value) | Q(start_address__contains=value) | Q(end_address__contains=value)
|
||||
try:
|
||||
ipaddress = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
ipaddress = str(netaddr.IPNetwork(value.strip()))
|
||||
qs_filter |= Q(start_address=ipaddress)
|
||||
qs_filter |= Q(end_address=ipaddress)
|
||||
except (AddrFormatError, ValueError):
|
||||
@@ -461,6 +470,12 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
try:
|
||||
return queryset.filter(**{f'{name}__net_in': value})
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
@@ -585,7 +600,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset.none()
|
||||
return queryset.filter(q)
|
||||
|
||||
def parse_inet_addresses(self, value):
|
||||
'''
|
||||
Parse networks or IP addresses and cast to a format
|
||||
acceptable by the Postgres inet type.
|
||||
|
||||
Skips invalid values.
|
||||
'''
|
||||
parsed = []
|
||||
for addr in value:
|
||||
if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
|
||||
parsed.append(addr)
|
||||
continue
|
||||
try:
|
||||
network = netaddr.IPNetwork(addr)
|
||||
parsed.append(str(network))
|
||||
except (AddrFormatError, ValueError):
|
||||
continue
|
||||
return parsed
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
# Let's first parse the addresses passed
|
||||
# as argument. If they are all invalid,
|
||||
# we return an empty queryset
|
||||
value = self.parse_inet_addresses(value)
|
||||
if (len(value) == 0):
|
||||
return queryset.none()
|
||||
|
||||
try:
|
||||
return queryset.filter(address__net_in=value)
|
||||
except ValidationError:
|
||||
|
||||
@@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
|
||||
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
|
||||
StaticSelect, DynamicModelMultipleChoiceField
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
|
||||
@@ -443,7 +443,8 @@ class L2VPNImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
|
||||
fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
|
||||
'comments', 'tags')
|
||||
|
||||
|
||||
class L2VPNTerminationImportForm(NetBoxModelImportForm):
|
||||
|
||||
@@ -578,6 +578,7 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.populate_custom_field_defaults()
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
|
||||
@@ -27,6 +27,28 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class IPAddressFamilyType(graphene.ObjectType):
|
||||
|
||||
value = graphene.Int()
|
||||
label = graphene.String()
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.label = f'IPv{value}'
|
||||
|
||||
|
||||
class BaseIPAddressFamilyType:
|
||||
'''
|
||||
Base type for models that need to expose their IPAddress family type.
|
||||
'''
|
||||
family = graphene.Field(IPAddressFamilyType)
|
||||
|
||||
def resolve_family(self, _):
|
||||
# Note that self, is an instance of models.IPAddress
|
||||
# thus resolves to the address family value.
|
||||
return IPAddressFamilyType(self.family)
|
||||
|
||||
|
||||
class ASNType(NetBoxObjectType):
|
||||
asn = graphene.Field(BigInt)
|
||||
|
||||
@@ -36,7 +58,7 @@ class ASNType(NetBoxObjectType):
|
||||
filterset_class = filtersets.ASNFilterSet
|
||||
|
||||
|
||||
class AggregateType(NetBoxObjectType):
|
||||
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
|
||||
class Meta:
|
||||
model = models.Aggregate
|
||||
@@ -64,7 +86,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
|
||||
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
|
||||
|
||||
|
||||
class IPAddressType(NetBoxObjectType):
|
||||
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
|
||||
|
||||
class Meta:
|
||||
@@ -87,7 +109,7 @@ class IPRangeType(NetBoxObjectType):
|
||||
return self.role or None
|
||||
|
||||
|
||||
class PrefixType(NetBoxObjectType):
|
||||
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
|
||||
class Meta:
|
||||
model = models.Prefix
|
||||
|
||||
31
netbox/ipam/migrations/0064_clear_search_cache.py
Normal file
31
netbox/ipam/migrations/0064_clear_search_cache.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def clear_cache(apps, schema_editor):
|
||||
"""
|
||||
Clear existing CachedValues referencing IPAddressFields or IPNetworkFields. (#11658
|
||||
introduced new cache record types for these.)
|
||||
"""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
CachedValue = apps.get_model('extras', 'CachedValue')
|
||||
|
||||
for model_name in ('Aggregate', 'IPAddress', 'IPRange', 'Prefix'):
|
||||
try:
|
||||
content_type = ContentType.objects.get(app_label='ipam', model=model_name.lower())
|
||||
CachedValue.objects.filter(object_type=content_type).delete()
|
||||
except ContentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0063_standardize_description_comments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=clear_cache,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -178,9 +178,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
|
||||
if self.prefix:
|
||||
|
||||
# Clear host bits from prefix
|
||||
self.prefix = self.prefix.cidr
|
||||
|
||||
# /0 masks are not acceptable
|
||||
if self.prefix.prefixlen == 0:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -6,7 +6,7 @@ from netbox.search import SearchIndex, register_search
|
||||
class AggregateIndex(SearchIndex):
|
||||
model = models.Aggregate
|
||||
fields = (
|
||||
('prefix', 100),
|
||||
('prefix', 120),
|
||||
('description', 500),
|
||||
('date_added', 2000),
|
||||
('comments', 5000),
|
||||
@@ -70,7 +70,7 @@ class L2VPNIndex(SearchIndex):
|
||||
class PrefixIndex(SearchIndex):
|
||||
model = models.Prefix
|
||||
fields = (
|
||||
('prefix', 100),
|
||||
('prefix', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ class RouteTargetTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
url_name='ipam:routetarget_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
|
||||
@@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
self.add_permissions('ipam.delete_vlan')
|
||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
|
||||
with disable_warnings('django.request'):
|
||||
with disable_warnings('netbox.api.views.ModelViewSet'):
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
|
||||
@@ -10,6 +10,7 @@ from ipam.models import *
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@@ -680,6 +681,14 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'family': '6'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_start_address(self):
|
||||
params = {'start_address': ['10.0.1.100', '10.0.2.100']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_end_address(self):
|
||||
params = {'end_address': ['10.0.1.199', '10.0.2.199']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_contains(self):
|
||||
params = {'contains': '10.0.1.150/24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
@@ -843,6 +852,26 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# Check for valid edge cases. Note that Postgres inet type
|
||||
# only accepts netmasks in the int form, so the filterset
|
||||
# casts netmasks in the xxx.xxx.xxx.xxx format.
|
||||
params = {'address': ['24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'address': ['10.0.0.1/255.255.255.0']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'address': ['10.0.0.1/255.255.255.0', '10.0.0.1/25']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# Check for invalid input.
|
||||
params = {'address': ['/24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'address': ['10.0.0.1/255.255.999.0']} # Invalid netmask
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
# Check for partially invalid input.
|
||||
params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
@@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView):
|
||||
|
||||
class RoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Role.objects.all()
|
||||
filterset = filtersets.RoleFilterSet
|
||||
table = tables.RoleTable
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@ CORS_ORIGIN_REGEX_WHITELIST = [
|
||||
# r'^(https?://)?(\w+\.)?example\.com$',
|
||||
]
|
||||
|
||||
# The name to use for the CSRF token cookie.
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
|
||||
# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal
|
||||
# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
|
||||
# on a production system.
|
||||
@@ -127,6 +130,9 @@ EMAIL = {
|
||||
'FROM_EMAIL': '',
|
||||
}
|
||||
|
||||
# Localization
|
||||
ENABLE_LOCALIZATION = False
|
||||
|
||||
# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
|
||||
# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
|
||||
EXEMPT_VIEW_PERMISSIONS = [
|
||||
@@ -168,16 +174,6 @@ LOGOUT_REDIRECT_URL = 'home'
|
||||
# the default value of this setting is derived from the installed location.
|
||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||
|
||||
# By default uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
# STORAGE_CONFIG = {
|
||||
# 'AWS_ACCESS_KEY_ID': 'Key ID',
|
||||
# 'AWS_SECRET_ACCESS_KEY': 'Secret',
|
||||
# 'AWS_STORAGE_BUCKET_NAME': 'netbox',
|
||||
# 'AWS_S3_REGION_NAME': 'eu-west-1',
|
||||
# }
|
||||
|
||||
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
|
||||
METRICS_ENABLED = False
|
||||
|
||||
@@ -217,9 +213,6 @@ 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'
|
||||
|
||||
@@ -228,8 +221,15 @@ SESSION_COOKIE_NAME = 'sessionid'
|
||||
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
|
||||
SESSION_FILE_PATH = None
|
||||
|
||||
# Localization
|
||||
ENABLE_LOCALIZATION = False
|
||||
# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
# STORAGE_CONFIG = {
|
||||
# 'AWS_ACCESS_KEY_ID': 'Key ID',
|
||||
# 'AWS_SECRET_ACCESS_KEY': 'Secret',
|
||||
# 'AWS_STORAGE_BUCKET_NAME': 'netbox',
|
||||
# 'AWS_S3_REGION_NAME': 'eu-west-1',
|
||||
# }
|
||||
|
||||
# Time zone (default: UTC)
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
@@ -60,6 +60,8 @@ class ObjectListField(DjangoListField):
|
||||
filterset_class = django_object_type._meta.filterset_class
|
||||
if filterset_class:
|
||||
filterset = filterset_class(data=args, queryset=queryset, request=info.context)
|
||||
if not filterset.is_valid():
|
||||
return queryset.none()
|
||||
return filterset.qs
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
@@ -58,6 +59,33 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist.
|
||||
"""
|
||||
super().clean()
|
||||
|
||||
for field in self._meta.get_fields():
|
||||
if isinstance(field, GenericForeignKey):
|
||||
ct_value = getattr(self, field.ct_field)
|
||||
fk_value = getattr(self, field.fk_field)
|
||||
|
||||
if ct_value is None and fk_value is not None:
|
||||
raise ValidationError({
|
||||
field.ct_field: "This field cannot be null.",
|
||||
})
|
||||
if fk_value is None and ct_value is not None:
|
||||
raise ValidationError({
|
||||
field.fk_field: "This field cannot be null.",
|
||||
})
|
||||
|
||||
if ct_value and fk_value:
|
||||
klass = getattr(self, field.ct_field).model_class()
|
||||
if not klass.objects.filter(pk=fk_value).exists():
|
||||
raise ValidationError({
|
||||
field.fk_field: f"Related object not found using the provided value: {fk_value}."
|
||||
})
|
||||
|
||||
|
||||
class PrimaryModel(NetBoxModel):
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from functools import cached_property
|
||||
|
||||
@@ -111,7 +112,11 @@ class CloningMixin(models.Model):
|
||||
for field_name in getattr(self, 'clone_fields', []):
|
||||
field = self._meta.get_field(field_name)
|
||||
field_value = field.value_from_object(self)
|
||||
if field_value not in (None, ''):
|
||||
if field_value and isinstance(field, models.ManyToManyField):
|
||||
attrs[field_name] = [v.pk for v in field_value]
|
||||
elif field_value and isinstance(field, models.JSONField):
|
||||
attrs[field_name] = json.dumps(field_value)
|
||||
elif field_value not in (None, ''):
|
||||
attrs[field_name] = field_value
|
||||
|
||||
# Include tags (if applicable)
|
||||
@@ -216,6 +221,13 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
return dict(groups)
|
||||
|
||||
def populate_custom_field_defaults(self):
|
||||
"""
|
||||
Apply the default value for each custom field
|
||||
"""
|
||||
for cf in self.custom_fields:
|
||||
self.custom_field_data[cf.name] = cf.default
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
from extras.models import CustomField
|
||||
@@ -257,6 +269,10 @@ class CustomValidationMixin(models.Model):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# If the instance is a base for replications, skip custom validation
|
||||
if getattr(self, '_replicated_base', False):
|
||||
return
|
||||
|
||||
# Send the post_clean signal
|
||||
post_clean.send(sender=self.__class__, instance=self)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ PREFERENCES = {
|
||||
'pagination.per_page': UserPreference(
|
||||
label=_('Page length'),
|
||||
choices=get_page_lengths(),
|
||||
description=_('The number of objects to display per page'),
|
||||
description=_('The default number of objects to display per page'),
|
||||
coerce=lambda x: int(x)
|
||||
),
|
||||
'pagination.placement': UserPreference(
|
||||
|
||||
@@ -2,6 +2,7 @@ from collections import namedtuple
|
||||
|
||||
from django.db import models
|
||||
|
||||
from ipam.fields import IPAddressField, IPNetworkField
|
||||
from netbox.registry import registry
|
||||
|
||||
ObjectFieldValue = namedtuple('ObjectFieldValue', ('name', 'type', 'weight', 'value'))
|
||||
@@ -11,6 +12,8 @@ class FieldTypes:
|
||||
FLOAT = 'float'
|
||||
INTEGER = 'int'
|
||||
STRING = 'str'
|
||||
INET = 'inet'
|
||||
CIDR = 'cidr'
|
||||
|
||||
|
||||
class LookupTypes:
|
||||
@@ -43,6 +46,10 @@ class SearchIndex:
|
||||
field_cls = instance._meta.get_field(field_name).__class__
|
||||
if issubclass(field_cls, (models.FloatField, models.DecimalField)):
|
||||
return FieldTypes.FLOAT
|
||||
if issubclass(field_cls, IPAddressField):
|
||||
return FieldTypes.INET
|
||||
if issubclass(field_cls, IPNetworkField):
|
||||
return FieldTypes.CIDR
|
||||
if issubclass(field_cls, models.IntegerField):
|
||||
return FieldTypes.INTEGER
|
||||
return FieldTypes.STRING
|
||||
|
||||
@@ -3,10 +3,12 @@ from collections import defaultdict
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import F, Window
|
||||
from django.db.models import F, Window, Q
|
||||
from django.db.models.functions import window
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.utils.module_loading import import_string
|
||||
import netaddr
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from extras.models import CachedValue, CustomField
|
||||
from netbox.registry import registry
|
||||
@@ -52,11 +54,11 @@ class SearchBackend:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def caching_handler(self, sender, instance, **kwargs):
|
||||
def caching_handler(self, sender, instance, created, **kwargs):
|
||||
"""
|
||||
Receiver for the post_save signal, responsible for caching object creation/changes.
|
||||
"""
|
||||
self.cache(instance)
|
||||
self.cache(instance, remove_existing=not created)
|
||||
|
||||
def removal_handler(self, sender, instance, **kwargs):
|
||||
"""
|
||||
@@ -78,7 +80,13 @@ class SearchBackend:
|
||||
|
||||
def clear(self, object_types=None):
|
||||
"""
|
||||
Delete *all* cached data.
|
||||
Delete *all* cached data (optionally filtered by object type).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def count(self, object_types=None):
|
||||
"""
|
||||
Return a count of all cache entries (optionally filtered by object type).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -95,18 +103,24 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
|
||||
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||
|
||||
# Define the search parameters
|
||||
params = {
|
||||
f'value__{lookup}': value
|
||||
}
|
||||
query_filter = Q(**{f'value__{lookup}': value})
|
||||
|
||||
if object_types:
|
||||
query_filter &= Q(object_type__in=object_types)
|
||||
|
||||
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
||||
# Partial string matches are valid only on string values
|
||||
params['type'] = FieldTypes.STRING
|
||||
if object_types:
|
||||
params['object_type__in'] = object_types
|
||||
query_filter &= Q(type=FieldTypes.STRING)
|
||||
|
||||
if lookup == LookupTypes.PARTIAL:
|
||||
try:
|
||||
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
|
||||
# Construct the base queryset to retrieve matching results
|
||||
queryset = CachedValue.objects.filter(**params).annotate(
|
||||
queryset = CachedValue.objects.filter(query_filter).annotate(
|
||||
# Annotate the rank of each result for its object according to its weight
|
||||
row_number=Window(
|
||||
expression=window.RowNumber(),
|
||||
@@ -210,6 +224,12 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
# Call _raw_delete() on the queryset to avoid first loading instances into memory
|
||||
return qs._raw_delete(using=qs.db)
|
||||
|
||||
def count(self, object_types=None):
|
||||
qs = CachedValue.objects.all()
|
||||
if object_types:
|
||||
qs = qs.filter(object_type__in=object_types)
|
||||
return qs.count()
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return CachedValue.objects.count()
|
||||
|
||||
@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.4.4'
|
||||
VERSION = '3.4.8'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -91,6 +91,7 @@ DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BAS
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||
@@ -395,8 +396,10 @@ TEMPLATES = [
|
||||
]
|
||||
|
||||
# Set up authentication backends
|
||||
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
|
||||
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
REMOTE_AUTH_BACKEND,
|
||||
*REMOTE_AUTH_BACKEND,
|
||||
'netbox.authentication.ObjectPermissionBackend',
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
@@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.html import escape
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -235,7 +235,7 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
|
||||
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
|
||||
@@ -16,7 +16,6 @@ from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||
from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
|
||||
@@ -384,8 +383,8 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
'data': record,
|
||||
'instance': instance,
|
||||
}
|
||||
if form.cleaned_data['format'] == ImportFormatChoices.CSV:
|
||||
model_form_kwargs['headers'] = form._csv_headers
|
||||
if hasattr(form, '_csv_headers'):
|
||||
model_form_kwargs['headers'] = form._csv_headers # Add CSV headers
|
||||
model_form = self.model_form(**model_form_kwargs)
|
||||
|
||||
# When updating, omit all form fields other than those specified in the record. (No
|
||||
@@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
]
|
||||
nullified_fields = request.POST.getlist('_nullify')
|
||||
updated_objects = []
|
||||
model_fields = {}
|
||||
m2m_fields = {}
|
||||
|
||||
# Build list of model fields and m2m fields for later iteration
|
||||
for name in standard_fields:
|
||||
try:
|
||||
model_field = self.queryset.model._meta.get_field(name)
|
||||
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
|
||||
m2m_fields[name] = model_field
|
||||
else:
|
||||
model_fields[name] = model_field
|
||||
|
||||
except FieldDoesNotExist:
|
||||
# This form field is used to modify a field rather than set its value directly
|
||||
model_fields[name] = None
|
||||
|
||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||
|
||||
@@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
obj.snapshot()
|
||||
|
||||
# Update standard fields. If a field is listed in _nullify, delete its value.
|
||||
for name in standard_fields:
|
||||
|
||||
try:
|
||||
model_field = self.queryset.model._meta.get_field(name)
|
||||
except FieldDoesNotExist:
|
||||
# This form field is used to modify a field rather than set its value directly
|
||||
model_field = None
|
||||
|
||||
for name, model_field in model_fields.items():
|
||||
# Handle nullification
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
if isinstance(model_field, ManyToManyField):
|
||||
getattr(obj, name).set([])
|
||||
else:
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
|
||||
# ManyToManyFields
|
||||
elif isinstance(model_field, (ManyToManyField, ManyToManyRel)):
|
||||
if form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
# Normal fields
|
||||
elif name in form.changed_data:
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
@@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
obj.save()
|
||||
updated_objects.append(obj)
|
||||
|
||||
# Handle M2M fields after save
|
||||
for name, m2m_field in m2m_fields.items():
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
getattr(obj, name).clear()
|
||||
else:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
|
||||
# Add/remove tags
|
||||
if form.cleaned_data.get('add_tags', None):
|
||||
obj.tags.add(*form.cleaned_data['add_tags'])
|
||||
|
||||
@@ -436,6 +436,10 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
form = self.initialize_form(request)
|
||||
instance = self.alter_object(self.queryset.model(), request)
|
||||
|
||||
# Note that the form instance is a replicated field base
|
||||
# This is needed to avoid running custom validators multiple times
|
||||
form.instance._replicated_base = hasattr(self.form, "replication_fields")
|
||||
|
||||
if form.is_valid():
|
||||
new_components = []
|
||||
data = deepcopy(request.POST)
|
||||
|
||||
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -56,4 +56,4 @@
|
||||
"resolutions": {
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user