mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 17:26:10 -06:00
Merge branch 'develop' into 11432-device-field
This commit is contained in:
commit
bea3426ad6
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.4.3
|
placeholder: v3.4.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.4.3
|
placeholder: v3.4.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
|||||||
necessary.
|
necessary.
|
||||||
close-pr-message: >
|
close-pr-message: >
|
||||||
This PR has been automatically closed due to lack of activity.
|
This PR has been automatically closed due to lack of activity.
|
||||||
days-before-stale: 60
|
days-before-stale: 90
|
||||||
days-before-close: 30
|
days-before-close: 30
|
||||||
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
|
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
|
||||||
operations-per-run: 100
|
operations-per-run: 100
|
||||||
|
127
README.md
127
README.md
@ -1,107 +1,73 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||||
|
|
||||||
|
The premiere source of truth powering network automation
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
NetBox is the leading solution for modeling and documenting modern networks. By
|
NetBox is the leading solution for modeling and documenting modern networks. By
|
||||||
combining the traditional disciplines of IP address management (IPAM) and
|
combining the traditional disciplines of IP address management (IPAM) and
|
||||||
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
|
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
|
||||||
NetBox provides the ideal "source of truth" to power network automation.
|
NetBox provides the ideal "source of truth" to power network automation.
|
||||||
Available as open source software under the Apache 2.0 license, NetBox is
|
Available as open source software under the Apache 2.0 license, NetBox serves
|
||||||
employed by thousands of organizations around the world.
|
as the cornerstone for network automation in thousands of organizations.
|
||||||
|
|
||||||

|
* **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.
|
||||||
[](https://github.com/netbox-community/netbox/commits)
|
* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||||
[](https://github.com/netbox-community/netbox/issues)
|
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
|
||||||
[](https://github.com/netbox-community/netbox/pulls)
|
* **Organization:** Manage tenant and contact assignments natively.
|
||||||
[](https://github.com/netbox-community/netbox/graphs/contributors)
|
* **Powerful search:** Easily find anything you need using a single global search function.
|
||||||
<br />Stats via [Repography](https://repography.com)
|
* **Comprehensive logging:** Leverage both automatic change logging and user-submitted journal entries to track your network's growth over time.
|
||||||
|
* **Endless customization:** Custom fields, custom links, tags, export templates, custom validation, reports, scripts, and more!
|
||||||
## About NetBox
|
* **Flexible permissions:** An advanced permissions systems enables very flexible delegation of permissions.
|
||||||
|
* **Integrations:** Easily connect NetBox to your other tooling via its REST & GraphQL APIs.
|
||||||
|
* **Plugins:** Not finding what you need in the core application? Try one of many community plugins - or build your own!
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Myriad infrastructure components can be modeled in NetBox, including:
|
## Getting Started
|
||||||
|
|
||||||
* Hierarchical regions, site groups, sites, and locations
|
* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
|
||||||
* Racks, devices, and device components
|
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
|
||||||
* Cables and wireless connections
|
* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/).
|
||||||
* Power distribution
|
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
|
||||||
* Data circuits and providers
|
|
||||||
* Virtual machines and clusters
|
|
||||||
* IP prefixes, ranges, and addresses
|
|
||||||
* VRFs and route targets
|
|
||||||
* L2VPN and overlays
|
|
||||||
* FHRP groups (VRRP, HSRP, etc.)
|
|
||||||
* AS numbers
|
|
||||||
* VLANs and scoped VLAN groups
|
|
||||||
* Organizational tenants and contacts
|
|
||||||
|
|
||||||
In addition to its extensive built-in models and functionality, NetBox can be
|
## Get Involved
|
||||||
customized and extended through the use of:
|
|
||||||
|
|
||||||
* Custom fields
|
* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
|
||||||
* Custom links
|
* Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)!
|
||||||
* Configuration contexts
|
* Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub.
|
||||||
* Custom model validation rules
|
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
|
||||||
* Reports
|
|
||||||
* Custom scripts
|
|
||||||
* Export templates
|
|
||||||
* Conditional webhooks
|
|
||||||
* Plugins
|
|
||||||
* Single sign-on (SSO) authentication
|
|
||||||
* NAPALM integration
|
|
||||||
* Detailed change logging
|
|
||||||
|
|
||||||
NetBox also features a complete REST API as well as a GraphQL API for easily
|
## Project Stats
|
||||||
integrating with other tools and systems.
|
|
||||||
|
|
||||||
The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/).
|
|
||||||
A public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev).
|
|
||||||
|
|
||||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
|
||||||
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
|
|
||||||
complete list of requirements, see `requirements.txt`. The code is available
|
|
||||||
[on GitHub](https://github.com/netbox-community/netbox).
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<h3>Thank you to our sponsors!</h3>
|
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
|
||||||
|
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
|
||||||
|
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
|
||||||
|
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
|
||||||
|
<br />Stats via <a href="https://repography.com">Repography</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://netboxlabs.com)
|
||||||
|
|
||||||
[](https://try.digitalocean.com/developer-cloud)
|
[](https://try.digitalocean.com/developer-cloud)
|
||||||
|
|
||||||
[](https://metal.equinix.com/)
|
[](https://ns1.com)
|
||||||
|
|
||||||
[](https://ns1.com/)
|
|
||||||
<br />
|
<br />
|
||||||
[](https://sentry.io/)
|
[](https://sentry.io)
|
||||||
|
|
||||||
[](https://stellar.tech/)
|
[](https://metal.equinix.com)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Discussion
|
## Screenshots
|
||||||
|
|
||||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
|
||||||
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Please see [the documentation](https://docs.netbox.dev/) for
|
|
||||||
instructions on installing NetBox. To upgrade NetBox, please download the
|
|
||||||
[latest release](https://github.com/netbox-community/netbox/releases) and
|
|
||||||
run `upgrade.sh`.
|
|
||||||
|
|
||||||
### Providing Feedback
|
|
||||||
|
|
||||||
The best platform for general feedback, assistance, and other discussion is our
|
|
||||||
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
|
|
||||||
To report a bug or request a specific feature, please open a GitHub issue using
|
|
||||||
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
|
|
||||||
|
|
||||||
If you are interested in contributing to the development of NetBox, please read
|
|
||||||
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
|
||||||
|
|
||||||
### Screenshots
|
|
||||||
|
|
||||||
")
|
")
|
||||||
|
|
||||||
@ -110,8 +76,3 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
|||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Related projects
|
|
||||||
|
|
||||||
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
|
|
||||||
for a list of relevant community projects.
|
|
||||||
|
@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
|
|||||||
|
|
||||||
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
|
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
|
||||||
|
|
||||||
If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
||||||
|
|
||||||
### Bug Bounties
|
### Bug Bounties
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# HTML sanitizer
|
# HTML sanitizer
|
||||||
# https://github.com/mozilla/bleach
|
# https://github.com/mozilla/bleach
|
||||||
bleach
|
bleach<6.0
|
||||||
|
|
||||||
# The Python web framework on which NetBox is built
|
# The Python web framework on which NetBox is built
|
||||||
# https://github.com/django/django
|
# https://github.com/django/django
|
||||||
@ -121,7 +121,8 @@ social-auth-core
|
|||||||
|
|
||||||
# Django app for social-auth-core
|
# Django app for social-auth-core
|
||||||
# https://github.com/python-social-auth/social-app-django
|
# 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)
|
# SVG image rendering (used for rack elevations)
|
||||||
# https://github.com/mozman/svgwrite
|
# 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>
|
<VirtualHost *:443>
|
||||||
ProxyPreserveHost On
|
ProxyPreserveHost On
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
|||||||
* Clearing expired authentication sessions from the database
|
* Clearing expired authentication sessions from the database
|
||||||
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
|
* 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)
|
* 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.
|
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.
|
||||||
|
|
||||||
|
@ -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
|
## GRAPHQL_ENABLED
|
||||||
|
|
||||||
!!! tip "Dynamic Configuration Parameter"
|
!!! tip "Dynamic Configuration Parameter"
|
||||||
|
@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated
|
|||||||
|
|
||||||
Default: `'netbox.authentication.RemoteUserBackend'`
|
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.RemoteUserBackend`
|
||||||
* `netbox.authentication.LDAPBackend`
|
* `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)
|
* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
|
||||||
* `PORT` - TCP port to use for the connection (default: `25`)
|
* `PORT` - TCP port to use for the connection (default: `25`)
|
||||||
* `USERNAME` - Username with which to authenticate
|
* `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_SSL` - Use SSL when connecting to the server (default: `False`)
|
||||||
* `USE_TLS` - Use TLS 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)
|
* `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`
|
### `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`
|
### `commit_default`
|
||||||
|
|
||||||
@ -142,6 +157,19 @@ obj.full_clean()
|
|||||||
obj.save()
|
obj.save()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Sometimes things go wrong and a script will run into an `Exception`. If that happens and an uncaught exception is raised by the custom script, the execution is aborted and a full stack trace is reported.
|
||||||
|
|
||||||
|
Although this is helpful for debugging, in some situations it might be required to cleanly abort the execution of a custom script (e.g. because of invalid input data) and thereby make sure no changes are performed on the database. In this case the script can throw an `AbortScript` exception, which will prevent the stack trace from being reported, but still terminating the script's execution and reporting a given error message.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from utilities.exceptions import AbortScript
|
||||||
|
|
||||||
|
if some_error:
|
||||||
|
raise AbortScript("Some meaningful error message")
|
||||||
|
```
|
||||||
|
|
||||||
## Variable Reference
|
## Variable Reference
|
||||||
|
|
||||||
### Default Options
|
### Default Options
|
||||||
@ -289,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:
|
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.
|
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.
|
||||||
|
@ -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.
|
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`.
|
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`.
|
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:
|
Create the following for each model:
|
||||||
|
|
||||||
@ -71,13 +75,13 @@ Create the following for each model:
|
|||||||
* API view in `api/views.py`
|
* API view in `api/views.py`
|
||||||
* Endpoint route in `api/urls.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`.
|
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.
|
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:
|
Add tests for the following:
|
||||||
|
|
||||||
@ -85,7 +89,7 @@ Add tests for the following:
|
|||||||
* API views
|
* API views
|
||||||
* Filter sets
|
* 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.
|
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.
|
||||||
|
|
||||||
|
@ -52,4 +52,4 @@ NetBox is built on the enormously popular [Django](http://www.djangoproject.com/
|
|||||||
* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in
|
* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in
|
||||||
* The [installation guide](./installation/index.md) will help you get your own deployment up and running
|
* The [installation guide](./installation/index.md) will help you get your own deployment up and running
|
||||||
* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach
|
* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach
|
||||||
* [NetBox Cloud](https://www.getnetbox.io/) is a hosted solution offered by NS1
|
* [NetBox Cloud](https://netboxlabs.com/netbox-cloud) is a managed solution offered by [NetBox Labs](https://netboxlabs.com/)
|
||||||
|
@ -272,7 +272,10 @@ See the [housekeeping documentation](../administration/housekeeping.md) for furt
|
|||||||
|
|
||||||
## Test the Application
|
## Test the Application
|
||||||
|
|
||||||
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:
|
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
Check that the Python virtual environment is still active before attempting to run the server.
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
python3 manage.py runserver 0.0.0.0:8000 --insecure
|
python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||||
|
@ -14,7 +14,10 @@ While the provided configuration should suffice for most initial installations,
|
|||||||
|
|
||||||
## systemd Setup
|
## systemd Setup
|
||||||
|
|
||||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
|
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon.
|
||||||
|
|
||||||
|
!!! warning "Check user & group assignment"
|
||||||
|
The stock service configuration files packaged with NetBox assume that the service will run with the `netbox` user and group names. If these differ on your installation, be sure to update the service files accordingly.
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
|
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
|
||||||
|
@ -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:
|
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
sudo a2enmod ssl proxy proxy_http headers
|
sudo a2enmod ssl proxy proxy_http headers rewrite
|
||||||
sudo a2ensite netbox
|
sudo a2ensite netbox
|
||||||
sudo systemctl restart apache2
|
sudo systemctl restart apache2
|
||||||
```
|
```
|
||||||
|
@ -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.
|
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
|
## Key Features
|
||||||
|
|
||||||
@ -17,6 +17,7 @@ NetBox was built specifically to serve the needs of network engineers and operat
|
|||||||
* AS number (ASN) management
|
* AS number (ASN) management
|
||||||
* Rack elevations with SVG rendering
|
* Rack elevations with SVG rendering
|
||||||
* Device modeling using pre-defined types
|
* Device modeling using pre-defined types
|
||||||
|
* Virtual chassis and device contexts
|
||||||
* Network, power, and console cabling with SVG traces
|
* Network, power, and console cabling with SVG traces
|
||||||
* Power distribution modeling
|
* Power distribution modeling
|
||||||
* Data circuit and provider tracking
|
* 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
|
* Tenant ownership assignment
|
||||||
* Device & VM configuration contexts for advanced configuration rendering
|
* Device & VM configuration contexts for advanced configuration rendering
|
||||||
* Custom fields for data model extension
|
* Custom fields for data model extension
|
||||||
* Support for custom validation rules
|
* Custom validation rules
|
||||||
* Custom reports & scripts executable directly within the UI
|
* Custom reports & scripts executable directly within the UI
|
||||||
* Extensive plugin framework for adding custom functionality
|
* Extensive plugin framework for adding custom functionality
|
||||||
* Single sign-on (SSO) authentication
|
* Single sign-on (SSO) authentication
|
||||||
* Robust object-based permissions
|
* Robust object-based permissions
|
||||||
* Detailed, automatic change logging
|
* Detailed, automatic change logging
|
||||||
|
* Global search engine
|
||||||
* NAPALM integration
|
* NAPALM integration
|
||||||
|
|
||||||
## What NetBox Is Not
|
## What NetBox Is Not
|
||||||
|
@ -51,7 +51,7 @@ menu_items = (item1, item2, item3)
|
|||||||
|
|
||||||
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
|
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
|
||||||
|
|
||||||
```python filename="navigation.py"
|
```python title="navigation.py"
|
||||||
from extras.plugins import PluginMenuButton, PluginMenuItem
|
from extras.plugins import PluginMenuButton, PluginMenuItem
|
||||||
from utilities.choices import ButtonColorChoices
|
from utilities.choices import ButtonColorChoices
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
|||||||
|
|
||||||
### Examples
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
@ -109,8 +109,8 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
|||||||
"value": "active"
|
"value": "active"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"attr": "primary_ip",
|
"attr": "primary_ip4",
|
||||||
"value": "",
|
"value": null,
|
||||||
"negate": true
|
"negate": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -1,10 +1,109 @@
|
|||||||
# NetBox v3.4
|
# NetBox v3.4
|
||||||
|
|
||||||
## v3.4.4 (FUTURE)
|
## v3.4.8 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
### 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
|
||||||
|
|
||||||
|
* [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice
|
||||||
|
* [#11152](https://github.com/netbox-community/netbox/issues/11152) - Introduce AbortScript exception to elegantly abort scripts
|
||||||
|
* [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list
|
||||||
|
* [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services
|
||||||
|
* [#11598](https://github.com/netbox-community/netbox/issues/11598) - Add buttons to easily switch between rack list and elevations views
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#11267](https://github.com/netbox-community/netbox/issues/11267) - Avoid catching ImportErrors when loading plugin resources
|
||||||
|
* [#11487](https://github.com/netbox-community/netbox/issues/11487) - Remove "set null" option from non-writable custom fields during bulk edit
|
||||||
|
* [#11491](https://github.com/netbox-community/netbox/issues/11491) - Show edit/delete buttons in user tokens table
|
||||||
|
* [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file
|
||||||
* [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516))
|
* [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516))
|
||||||
|
* [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names
|
||||||
|
* [#11574](https://github.com/netbox-community/netbox/issues/11574) - Fix exception when attempting to schedule reports/scripts
|
||||||
|
* [#11620](https://github.com/netbox-community/netbox/issues/11620) - Correct available filter choices for interface PoE type
|
||||||
|
* [#11635](https://github.com/netbox-community/netbox/issues/11635) - Pre-populate assigned VRF when following "first available IP" link from prefix view
|
||||||
|
* [#11650](https://github.com/netbox-community/netbox/issues/11650) - Display error message when attempting to create device component with duplicate name
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ from ipam.models import ASN
|
|||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||||
StaticSelect,
|
StaticSelect,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -196,12 +196,10 @@ class CircuitTermination(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
def get_absolute_url(self):
|
||||||
if self.site:
|
return self.circuit.get_absolute_url()
|
||||||
return self.site.get_absolute_url()
|
|
||||||
return self.provider_network.get_absolute_url()
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
||||||
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
||||||
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
||||||
|
TYPE_STACKWISE1T = 'cisco-stackwise-1t'
|
||||||
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
||||||
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
||||||
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
||||||
@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
||||||
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
||||||
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
||||||
|
(TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
|
||||||
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
||||||
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
||||||
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
||||||
|
@ -981,7 +981,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
|||||||
Q(serial__icontains=value.strip()) |
|
Q(serial__icontains=value.strip()) |
|
||||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||||
Q(asset_tag__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()
|
).distinct()
|
||||||
|
|
||||||
def _has_primary_ip(self, queryset, name, value):
|
def _has_primary_ip(self, queryset, name, value):
|
||||||
@ -1725,6 +1727,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class CableTerminationFilterSet(BaseFilterSet):
|
class CableTerminationFilterSet(BaseFilterSet):
|
||||||
|
termination_type = ContentTypeFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CableTermination
|
model = CableTermination
|
||||||
|
@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
|||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
|
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
|
||||||
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
|
DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1186,6 +1175,14 @@ class InterfaceBulkEditForm(
|
|||||||
},
|
},
|
||||||
label=_('LAG')
|
label=_('LAG')
|
||||||
)
|
)
|
||||||
|
vdcs = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VirtualDeviceContext.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label='Virtual Device Contexts',
|
||||||
|
query_params={
|
||||||
|
'device_id': '$device',
|
||||||
|
}
|
||||||
|
)
|
||||||
speed = forms.IntegerField(
|
speed = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=SelectSpeedWidget(),
|
widget=SelectSpeedWidget(),
|
||||||
@ -1251,14 +1248,14 @@ class InterfaceBulkEditForm(
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
||||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
('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')),
|
('PoE', ('poe_mode', 'poe_type')),
|
||||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
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',
|
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||||
)
|
)
|
||||||
|
@ -11,14 +11,15 @@ from dcim.models import *
|
|||||||
from ipam.models import VRF
|
from ipam.models import VRF
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
from tenancy.models import Tenant
|
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 virtualization.models import Cluster
|
||||||
from wireless.choices import WirelessRoleChoices
|
from wireless.choices import WirelessRoleChoices
|
||||||
from .common import ModuleCommonForm
|
from .common import ModuleCommonForm
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CableImportForm',
|
'CableImportForm',
|
||||||
'ChildDeviceImportForm',
|
|
||||||
'ConsolePortImportForm',
|
'ConsolePortImportForm',
|
||||||
'ConsoleServerPortImportForm',
|
'ConsoleServerPortImportForm',
|
||||||
'DeviceBayImportForm',
|
'DeviceBayImportForm',
|
||||||
@ -413,6 +414,18 @@ class DeviceImportForm(BaseDeviceImportForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_('Mounted rack face')
|
help_text=_('Mounted rack face')
|
||||||
)
|
)
|
||||||
|
parent = CSVModelChoiceField(
|
||||||
|
queryset=Device.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
required=False,
|
||||||
|
help_text=_('Parent device (for child devices)')
|
||||||
|
)
|
||||||
|
device_bay = CSVModelChoiceField(
|
||||||
|
queryset=DeviceBay.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
required=False,
|
||||||
|
help_text=_('Device bay in which this device is installed (for child devices)')
|
||||||
|
)
|
||||||
airflow = CSVChoiceField(
|
airflow = CSVChoiceField(
|
||||||
choices=DeviceAirflowChoices,
|
choices=DeviceAirflowChoices,
|
||||||
required=False,
|
required=False,
|
||||||
@ -422,8 +435,8 @@ class DeviceImportForm(BaseDeviceImportForm):
|
|||||||
class Meta(BaseDeviceImportForm.Meta):
|
class Meta(BaseDeviceImportForm.Meta):
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||||
'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority',
|
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
|
||||||
'cluster', 'description', 'comments', 'tags',
|
'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@ -434,14 +447,35 @@ class DeviceImportForm(BaseDeviceImportForm):
|
|||||||
# Limit location queryset by assigned site
|
# Limit location queryset by assigned site
|
||||||
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
||||||
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
|
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 = {
|
params = {
|
||||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
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)
|
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||||
|
|
||||||
|
# Limit device bay queryset by parent device
|
||||||
|
if parent := data.get('parent'):
|
||||||
|
params = {f"device__{self.fields['parent'].to_field_name}": parent}
|
||||||
|
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Inherit site and rack from parent device
|
||||||
|
if parent := self.cleaned_data.get('parent'):
|
||||||
|
self.instance.site = parent.site
|
||||||
|
self.instance.rack = parent.rack
|
||||||
|
|
||||||
|
# Set parent_bay reverse relationship
|
||||||
|
if device_bay := self.cleaned_data.get('device_bay'):
|
||||||
|
self.instance.parent_bay = device_bay
|
||||||
|
|
||||||
|
|
||||||
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
||||||
device = CSVModelChoiceField(
|
device = CSVModelChoiceField(
|
||||||
@ -495,48 +529,6 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
|||||||
return self.cleaned_data['replicate_components']
|
return self.cleaned_data['replicate_components']
|
||||||
|
|
||||||
|
|
||||||
class ChildDeviceImportForm(BaseDeviceImportForm):
|
|
||||||
parent = CSVModelChoiceField(
|
|
||||||
queryset=Device.objects.all(),
|
|
||||||
to_field_name='name',
|
|
||||||
help_text=_('Parent device')
|
|
||||||
)
|
|
||||||
device_bay = CSVModelChoiceField(
|
|
||||||
queryset=DeviceBay.objects.all(),
|
|
||||||
to_field_name='name',
|
|
||||||
help_text=_('Device bay in which this device is installed')
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta(BaseDeviceImportForm.Meta):
|
|
||||||
fields = [
|
|
||||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
|
||||||
'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags'
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
|
||||||
super().__init__(data, *args, **kwargs)
|
|
||||||
|
|
||||||
if data:
|
|
||||||
|
|
||||||
# Limit device bay queryset by parent device
|
|
||||||
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
|
|
||||||
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
# Set parent_bay reverse relationship
|
|
||||||
device_bay = self.cleaned_data.get('device_bay')
|
|
||||||
if device_bay:
|
|
||||||
self.instance.parent_bay = device_bay
|
|
||||||
|
|
||||||
# Inherit site and rack from parent device
|
|
||||||
parent = self.cleaned_data.get('parent')
|
|
||||||
if parent:
|
|
||||||
self.instance.site = parent.site
|
|
||||||
self.instance.rack = parent.rack
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Device components
|
# Device components
|
||||||
#
|
#
|
||||||
@ -677,6 +669,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=_('Parent LAG interface')
|
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(
|
type = CSVChoiceField(
|
||||||
choices=InterfaceTypeChoices,
|
choices=InterfaceTypeChoices,
|
||||||
help_text=_('Physical medium')
|
help_text=_('Physical medium')
|
||||||
@ -716,7 +714,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
model = Interface
|
model = Interface
|
||||||
fields = (
|
fields = (
|
||||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
'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'
|
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -732,6 +730,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||||
self.fields['bridge'].queryset = self.fields['bridge'].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['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
||||||
|
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
|
||||||
|
|
||||||
def clean_enabled(self):
|
def clean_enabled(self):
|
||||||
# Make sure enabled is True when it's not included in the uploaded data
|
# Make sure enabled is True when it's not included in the uploaded data
|
||||||
@ -740,6 +739,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
else:
|
else:
|
||||||
return self.cleaned_data['enabled']
|
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):
|
class FrontPortImportForm(NetBoxModelImportForm):
|
||||||
device = CSVModelChoiceField(
|
device = CSVModelChoiceField(
|
||||||
|
@ -1170,7 +1170,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
label='PoE mode'
|
label='PoE mode'
|
||||||
)
|
)
|
||||||
poe_type = MultipleChoiceField(
|
poe_type = MultipleChoiceField(
|
||||||
choices=InterfacePoEModeChoices,
|
choices=InterfacePoETypeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label='PoE type'
|
label='PoE type'
|
||||||
)
|
)
|
||||||
|
@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = VirtualChassis
|
model = VirtualChassis
|
||||||
fields = [
|
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):
|
def clean(self):
|
||||||
|
@ -10,3 +10,11 @@ class CabledObjectMixin:
|
|||||||
|
|
||||||
def resolve_link_peers(self, info):
|
def resolve_link_peers(self, info):
|
||||||
return self.link_peers
|
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 ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||||
from netbox.graphql.scalars import BigInt
|
from netbox.graphql.scalars import BigInt
|
||||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||||
from .mixins import CabledObjectMixin
|
from .mixins import CabledObjectMixin, PathEndpointMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CableType',
|
'CableType',
|
||||||
@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
|
|||||||
filterset_class = filtersets.CableTerminationFilterSet
|
filterset_class = filtersets.CableTerminationFilterSet
|
||||||
|
|
||||||
|
|
||||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
|
class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ConsolePort
|
model = models.ConsolePort
|
||||||
@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
|
|||||||
return self.type or None
|
return self.type or None
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
|
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ConsoleServerPort
|
model = models.ConsoleServerPort
|
||||||
@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
|
|||||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||||
|
|
||||||
|
|
||||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
|
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Interface
|
model = models.Interface
|
||||||
@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
|
|||||||
filterset_class = filtersets.PlatformFilterSet
|
filterset_class = filtersets.PlatformFilterSet
|
||||||
|
|
||||||
|
|
||||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PowerFeed
|
model = models.PowerFeed
|
||||||
@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
|||||||
filterset_class = filtersets.PowerFeedFilterSet
|
filterset_class = filtersets.PowerFeedFilterSet
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
|
class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PowerOutlet
|
model = models.PowerOutlet
|
||||||
@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
|
|||||||
filterset_class = filtersets.PowerPanelFilterSet
|
filterset_class = filtersets.PowerPanelFilterSet
|
||||||
|
|
||||||
|
|
||||||
class PowerPortType(ComponentObjectType, CabledObjectMixin):
|
class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PowerPort
|
model = models.PowerPort
|
||||||
|
@ -580,7 +580,6 @@ class DeviceInterfaceTable(InterfaceTable):
|
|||||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
|
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
|
||||||
'untagged_vlan', 'tagged_vlans', 'actions',
|
'untagged_vlan', 'tagged_vlans', 'actions',
|
||||||
)
|
)
|
||||||
order_by = ('name',)
|
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
|
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
|
||||||
'cable', 'connection',
|
'cable', 'connection',
|
||||||
@ -589,6 +588,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
|||||||
'class': get_interface_row_class,
|
'class': get_interface_row_class,
|
||||||
'data-name': lambda record: record.name,
|
'data-name': lambda record: record.name,
|
||||||
'data-enabled': get_interface_state_attribute,
|
'data-enabled': get_interface_state_attribute,
|
||||||
|
'data-type': lambda record: record.type,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,10 +34,19 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
url_params={'manufacturer_id': 'pk'},
|
url_params={'manufacturer_id': 'pk'},
|
||||||
verbose_name='Device Types'
|
verbose_name='Device Types'
|
||||||
)
|
)
|
||||||
inventoryitem_count = tables.Column(
|
moduletype_count = columns.LinkedCountColumn(
|
||||||
|
viewname='dcim:moduletype_list',
|
||||||
|
url_params={'manufacturer_id': 'pk'},
|
||||||
|
verbose_name='Module Types'
|
||||||
|
)
|
||||||
|
inventoryitem_count = columns.LinkedCountColumn(
|
||||||
|
viewname='dcim:inventoryitem_list',
|
||||||
|
url_params={'manufacturer_id': 'pk'},
|
||||||
verbose_name='Inventory Items'
|
verbose_name='Inventory Items'
|
||||||
)
|
)
|
||||||
platform_count = tables.Column(
|
platform_count = columns.LinkedCountColumn(
|
||||||
|
viewname='dcim:platform_list',
|
||||||
|
url_params={'manufacturer_id': 'pk'},
|
||||||
verbose_name='Platforms'
|
verbose_name='Platforms'
|
||||||
)
|
)
|
||||||
slug = tables.Column()
|
slug = tables.Column()
|
||||||
@ -48,11 +57,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.Manufacturer
|
model = models.Manufacturer
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||||
'tags', 'contacts', 'actions', 'created', 'last_updated',
|
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||||
|
'description', 'slug',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -177,7 +177,6 @@ urlpatterns = [
|
|||||||
path('devices/', views.DeviceListView.as_view(), name='device_list'),
|
path('devices/', views.DeviceListView.as_view(), name='device_list'),
|
||||||
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
|
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
|
||||||
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
|
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
|
||||||
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
|
|
||||||
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
|
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
|
||||||
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
|
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
|
||||||
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
|
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
|
||||||
|
@ -642,6 +642,7 @@ class RackListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.RackFilterSet
|
filterset = filtersets.RackFilterSet
|
||||||
filterset_form = forms.RackFilterForm
|
filterset_form = forms.RackFilterForm
|
||||||
table = tables.RackTable
|
table = tables.RackTable
|
||||||
|
template_name = 'dcim/rack_list.html'
|
||||||
|
|
||||||
|
|
||||||
class RackElevationListView(generic.ObjectListView):
|
class RackElevationListView(generic.ObjectListView):
|
||||||
@ -842,6 +843,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
|||||||
class ManufacturerListView(generic.ObjectListView):
|
class ManufacturerListView(generic.ObjectListView):
|
||||||
queryset = Manufacturer.objects.annotate(
|
queryset = Manufacturer.objects.annotate(
|
||||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||||
|
moduletype_count=count_related(ModuleType, 'manufacturer'),
|
||||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||||
platform_count=count_related(Platform, 'manufacturer')
|
platform_count=count_related(Platform, 'manufacturer')
|
||||||
)
|
)
|
||||||
@ -2090,22 +2092,15 @@ class DeviceBulkImportView(generic.BulkImportView):
|
|||||||
queryset = Device.objects.all()
|
queryset = Device.objects.all()
|
||||||
model_form = forms.DeviceImportForm
|
model_form = forms.DeviceImportForm
|
||||||
table = tables.DeviceImportTable
|
table = tables.DeviceImportTable
|
||||||
template_name = 'dcim/device_import.html'
|
|
||||||
|
|
||||||
|
|
||||||
class ChildDeviceBulkImportView(generic.BulkImportView):
|
|
||||||
queryset = Device.objects.all()
|
|
||||||
model_form = forms.ChildDeviceImportForm
|
|
||||||
table = tables.DeviceImportTable
|
|
||||||
template_name = 'dcim/device_import_child.html'
|
|
||||||
|
|
||||||
def save_object(self, object_form, request):
|
def save_object(self, object_form, request):
|
||||||
obj = object_form.save()
|
obj = object_form.save()
|
||||||
|
|
||||||
# Save the reverse relation to the parent device bay
|
# For child devices, save the reverse relation to the parent device bay
|
||||||
device_bay = obj.parent_bay
|
if getattr(obj, 'parent_bay', None):
|
||||||
device_bay.installed_device = obj
|
device_bay = obj.parent_bay
|
||||||
device_bay.save()
|
device_bay.installed_device = obj
|
||||||
|
device_bay.save()
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
@ -44,7 +44,8 @@ class Condition:
|
|||||||
bool: (EQ, CONTAINS),
|
bool: (EQ, CONTAINS),
|
||||||
int: (EQ, GT, GTE, LT, LTE, CONTAINS),
|
int: (EQ, GT, GTE, LT, LTE, CONTAINS),
|
||||||
float: (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):
|
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):
|
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||||
created = django_filters.DateTimeFromToRangeFilter()
|
created = django_filters.DateTimeFromToRangeFilter()
|
||||||
assigned_object_type = ContentTypeFilter()
|
assigned_object_type = ContentTypeFilter()
|
||||||
|
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=ContentType.objects.all()
|
||||||
|
)
|
||||||
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
label=_('User (ID)'),
|
label=_('User (ID)'),
|
||||||
@ -458,6 +461,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
|||||||
)
|
)
|
||||||
time = django_filters.DateTimeFromToRangeFilter()
|
time = django_filters.DateTimeFromToRangeFilter()
|
||||||
changed_object_type = ContentTypeFilter()
|
changed_object_type = ContentTypeFilter()
|
||||||
|
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=ContentType.objects.all()
|
||||||
|
)
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
label=_('User (ID)'),
|
label=_('User (ID)'),
|
||||||
|
@ -2,6 +2,7 @@ from .model_forms import *
|
|||||||
from .filtersets import *
|
from .filtersets import *
|
||||||
from .bulk_edit import *
|
from .bulk_edit import *
|
||||||
from .bulk_import import *
|
from .bulk_import import *
|
||||||
|
from .misc import *
|
||||||
from .mixins import *
|
from .mixins import *
|
||||||
from .config import *
|
from .config import *
|
||||||
from .scripts import *
|
from .scripts import *
|
||||||
|
@ -38,8 +38,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
|
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
|
||||||
)
|
)
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||||
limit_choices_to=FeatureQuery('custom_fields'),
|
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object type')
|
label=_('Object type')
|
||||||
)
|
)
|
||||||
@ -79,8 +78,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
obj_type = ContentTypeChoiceField(
|
obj_type = ContentTypeChoiceField(
|
||||||
label=_('Object Type'),
|
label=_('Object Type'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.filter(FeatureQuery('job_results').get_query()),
|
||||||
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
|
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = MultipleChoiceField(
|
||||||
@ -135,8 +133,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
|
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
|
||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
||||||
limit_choices_to=FeatureQuery('custom_links'),
|
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
@ -162,8 +159,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||||
limit_choices_to=FeatureQuery('export_templates'),
|
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
mime_type = forms.CharField(
|
mime_type = forms.CharField(
|
||||||
@ -187,8 +183,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
('Attributes', ('content_types', 'enabled', 'shared', 'weight')),
|
('Attributes', ('content_types', 'enabled', 'shared', 'weight')),
|
||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||||
limit_choices_to=FeatureQuery('export_templates'),
|
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
@ -215,8 +210,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||||
)
|
)
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
||||||
limit_choices_to=FeatureQuery('webhooks'),
|
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object type')
|
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,7 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.http import QueryDict
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
@ -128,11 +129,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, initial=None, **kwargs):
|
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 initial and 'parameters' in initial:
|
||||||
if type(initial['parameters']) is str:
|
if type(initial['parameters']) is str:
|
||||||
# TODO: Make a utility function for this
|
initial['parameters'] = json.loads(initial['parameters'])
|
||||||
initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
|
|
||||||
|
|
||||||
super().__init__(*args, initial=initial, **kwargs)
|
super().__init__(*args, initial=initial, **kwargs)
|
||||||
|
|
||||||
@ -254,6 +254,15 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
|||||||
'tenants', 'tags',
|
'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):
|
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)")
|
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']
|
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.'))
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -45,12 +45,16 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
|||||||
self.fields['_interval'] = interval
|
self.fields['_interval'] = interval
|
||||||
self.fields['_commit'] = commit
|
self.fields['_commit'] = commit
|
||||||
|
|
||||||
def clean__schedule_at(self):
|
def clean(self):
|
||||||
scheduled_time = self.cleaned_data['_schedule_at']
|
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.'))
|
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
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_input(self):
|
def requires_input(self):
|
||||||
|
@ -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):
|
class Empty(Lookup):
|
||||||
@ -14,4 +15,18 @@ class Empty(Lookup):
|
|||||||
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
|
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)
|
CharField.register_lookup(Empty)
|
||||||
|
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||||
|
@ -37,7 +37,7 @@ class Command(BaseCommand):
|
|||||||
f"clearing sessions; skipping."
|
f"clearing sessions; skipping."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete expired ObjectRecords
|
# Delete expired ObjectChanges
|
||||||
if options['verbosity']:
|
if options['verbosity']:
|
||||||
self.stdout.write("[*] Checking for expired changelog records")
|
self.stdout.write("[*] Checking for expired changelog records")
|
||||||
if config.CHANGELOG_RETENTION:
|
if config.CHANGELOG_RETENTION:
|
||||||
|
@ -15,6 +15,11 @@ class Command(BaseCommand):
|
|||||||
nargs='*',
|
nargs='*',
|
||||||
help='One or more apps or models to reindex',
|
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):
|
def _get_indexers(self, *model_names):
|
||||||
indexers = {}
|
indexers = {}
|
||||||
@ -60,14 +65,15 @@ class Command(BaseCommand):
|
|||||||
raise CommandError("No indexers found!")
|
raise CommandError("No indexers found!")
|
||||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||||
|
|
||||||
# Clear all cached values for the specified models
|
# Clear all cached values for the specified models (if not being lazy)
|
||||||
self.stdout.write('Clearing cached values... ', ending='')
|
if not kwargs['lazy']:
|
||||||
self.stdout.flush()
|
self.stdout.write('Clearing cached values... ', ending='')
|
||||||
content_types = [
|
self.stdout.flush()
|
||||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
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.')
|
deleted_count = search_backend.clear(content_types)
|
||||||
|
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||||
|
|
||||||
# Index models
|
# Index models
|
||||||
self.stdout.write('Indexing models')
|
self.stdout.write('Indexing models')
|
||||||
@ -76,11 +82,18 @@ class Command(BaseCommand):
|
|||||||
model_name = model._meta.model_name
|
model_name = model._meta.model_name
|
||||||
self.stdout.write(f' {app_label}.{model_name}... ', ending='')
|
self.stdout.write(f' {app_label}.{model_name}... ', ending='')
|
||||||
self.stdout.flush()
|
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)
|
i = search_backend.cache(model.objects.iterator(), remove_existing=False)
|
||||||
if i:
|
if i:
|
||||||
self.stdout.write(f'{i} entries cached.')
|
self.stdout.write(f'{i} entries cached.')
|
||||||
else:
|
else:
|
||||||
self.stdout.write(f'None found.')
|
self.stdout.write(f'No objects found.')
|
||||||
|
|
||||||
msg = f'Completed.'
|
msg = f'Completed.'
|
||||||
if total_count := search_backend.size:
|
if total_count := search_backend.size:
|
||||||
|
@ -1,25 +1,9 @@
|
|||||||
import sys
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.db.models.lookups
|
import django.db.models.lookups
|
||||||
from django.core import management
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
import extras.fields
|
||||||
|
|
||||||
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',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -49,7 +33,7 @@ class Migration(migrations.Migration):
|
|||||||
('object_id', models.PositiveBigIntegerField()),
|
('object_id', models.PositiveBigIntegerField()),
|
||||||
('field', models.CharField(max_length=200)),
|
('field', models.CharField(max_length=200)),
|
||||||
('type', models.CharField(max_length=30)),
|
('type', models.CharField(max_length=30)),
|
||||||
('value', models.TextField()),
|
('value', extras.fields.CachedValueField()),
|
||||||
('weight', models.PositiveSmallIntegerField(default=1000)),
|
('weight', models.PositiveSmallIntegerField(default=1000)),
|
||||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
('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'),
|
'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 extras.querysets import ConfigContextQuerySet
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import WebhooksMixin
|
from netbox.models.features import CloningMixin, WebhooksMixin
|
||||||
from utilities.utils import deepmerge
|
from utilities.utils import deepmerge
|
||||||
|
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ __all__ = (
|
|||||||
# Config contexts
|
# 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
|
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
|
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()
|
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:
|
class Meta:
|
||||||
ordering = ['weight', 'name']
|
ordering = ['weight', 'name']
|
||||||
|
|
||||||
|
@ -20,10 +20,12 @@ from netbox.models import ChangeLoggedModel
|
|||||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||||
from netbox.search import FieldTypes
|
from netbox.search import FieldTypes
|
||||||
from utilities import filters
|
from utilities import filters
|
||||||
from utilities.forms import (
|
from utilities.forms.fields import (
|
||||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
|
||||||
JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
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.querysets import RestrictedQuerySet
|
||||||
from utilities.validators import validate_regex
|
from utilities.validators import validate_regex
|
||||||
|
|
||||||
@ -273,10 +275,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
'choices': "Choices may be set only for custom selection fields."
|
'choices': "Choices may be set only for custom selection fields."
|
||||||
})
|
})
|
||||||
|
|
||||||
# A selection field must have at least two choices defined
|
# Selection fields must have at least one choice defined
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
|
if self.type in (
|
||||||
|
CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
|
CustomFieldTypeChoices.TYPE_MULTISELECT
|
||||||
|
) and not self.choices:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'choices': "Selection fields must specify at least two choices."
|
'choices': "Selection fields must specify at least one choice."
|
||||||
})
|
})
|
||||||
|
|
||||||
# A selection field's default (if any) must be present in its available choices
|
# A selection field's default (if any) must be present in its available choices
|
||||||
@ -410,7 +415,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
# Object
|
# Object
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||||
model = self.object_type.model_class()
|
model = self.object_type.model_class()
|
||||||
field = DynamicModelChoiceField(
|
field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
|
||||||
|
field = field_class(
|
||||||
queryset=model.objects.all(),
|
queryset=model.objects.all(),
|
||||||
required=required,
|
required=required,
|
||||||
initial=initial
|
initial=initial
|
||||||
@ -419,10 +425,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
# Multiple objects
|
# Multiple objects
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||||
model = self.object_type.model_class()
|
model = self.object_type.model_class()
|
||||||
field = DynamicModelMultipleChoiceField(
|
field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
|
||||||
|
field = field_class(
|
||||||
queryset=model.objects.all(),
|
queryset=model.objects.all(),
|
||||||
required=required,
|
required=required,
|
||||||
initial=initial
|
initial=initial,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Text
|
# Text
|
||||||
|
@ -245,7 +245,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
|||||||
)
|
)
|
||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
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(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
related_name='export_templates',
|
related_name='export_templates',
|
||||||
@ -313,6 +313,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
|||||||
help_text=_("Download file as attachment")
|
help_text=_("Download file as attachment")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
clone_fields = (
|
||||||
|
'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
|
|
||||||
@ -406,7 +410,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
parameters = models.JSONField()
|
parameters = models.JSONField()
|
||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'enabled', 'weight',
|
'content_types', 'weight', 'enabled', 'parameters',
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from utilities.fields import RestrictedGenericForeignKey
|
from utilities.fields import RestrictedGenericForeignKey
|
||||||
|
from ..fields import CachedValueField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CachedValue',
|
'CachedValue',
|
||||||
@ -36,7 +37,7 @@ class CachedValue(models.Model):
|
|||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
max_length=30
|
max_length=30
|
||||||
)
|
)
|
||||||
value = models.TextField()
|
value = CachedValueField()
|
||||||
weight = models.PositiveSmallIntegerField(
|
weight = models.PositiveSmallIntegerField(
|
||||||
default=1000
|
default=1000
|
||||||
)
|
)
|
||||||
|
@ -5,7 +5,7 @@ from django.utils.text import slugify
|
|||||||
from taggit.models import TagBase, GenericTaggedItemBase
|
from taggit.models import TagBase, GenericTaggedItemBase
|
||||||
|
|
||||||
from netbox.models import ChangeLoggedModel
|
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.choices import ColorChoices
|
||||||
from utilities.fields import ColorField
|
from utilities.fields import ColorField
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ from utilities.fields import ColorField
|
|||||||
# Tags
|
# Tags
|
||||||
#
|
#
|
||||||
|
|
||||||
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
class Tag(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||||
id = models.BigAutoField(
|
id = models.BigAutoField(
|
||||||
primary_key=True
|
primary_key=True
|
||||||
)
|
)
|
||||||
@ -26,6 +26,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
clone_fields = (
|
||||||
|
'color', 'description',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import collections
|
import collections
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -21,6 +22,15 @@ registry['plugins'] = {
|
|||||||
'template_extensions': collections.defaultdict(list),
|
'template_extensions': collections.defaultdict(list),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEFAULT_RESOURCE_PATHS = {
|
||||||
|
'search_indexes': 'search.indexes',
|
||||||
|
'graphql_schema': 'graphql.schema',
|
||||||
|
'menu': 'navigation.menu',
|
||||||
|
'menu_items': 'navigation.menu_items',
|
||||||
|
'template_extensions': 'template_content.template_extensions',
|
||||||
|
'user_preferences': 'preferences.preferences',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Plugin AppConfig class
|
# Plugin AppConfig class
|
||||||
@ -58,58 +68,53 @@ class PluginConfig(AppConfig):
|
|||||||
# Django apps to append to INSTALLED_APPS when plugin requires them.
|
# Django apps to append to INSTALLED_APPS when plugin requires them.
|
||||||
django_apps = []
|
django_apps = []
|
||||||
|
|
||||||
# Default integration paths. Plugin authors can override these to customize the paths to
|
# Optional plugin resources
|
||||||
# integrated components.
|
search_indexes = None
|
||||||
search_indexes = 'search.indexes'
|
graphql_schema = None
|
||||||
graphql_schema = 'graphql.schema'
|
menu = None
|
||||||
menu = 'navigation.menu'
|
menu_items = None
|
||||||
menu_items = 'navigation.menu_items'
|
template_extensions = None
|
||||||
template_extensions = 'template_content.template_extensions'
|
user_preferences = None
|
||||||
user_preferences = 'preferences.preferences'
|
|
||||||
|
def _load_resource(self, name):
|
||||||
|
# Import from the configured path, if defined.
|
||||||
|
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]}'
|
||||||
|
default_module, resource_name = default_path.rsplit('.', 1)
|
||||||
|
try:
|
||||||
|
module = import_module(default_module)
|
||||||
|
return getattr(module, resource_name, None)
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
plugin_name = self.name.rsplit('.', 1)[-1]
|
plugin_name = self.name.rsplit('.', 1)[-1]
|
||||||
|
|
||||||
# Register search extensions (if defined)
|
# Register search extensions (if defined)
|
||||||
try:
|
search_indexes = self._load_resource('search_indexes') or []
|
||||||
search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
|
for idx in search_indexes:
|
||||||
for idx in search_indexes:
|
register_search(idx)
|
||||||
register_search(idx)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Register template content (if defined)
|
# Register template content (if defined)
|
||||||
try:
|
if template_extensions := self._load_resource('template_extensions'):
|
||||||
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
|
|
||||||
register_template_extensions(template_extensions)
|
register_template_extensions(template_extensions)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Register navigation menu and/or menu items (if defined)
|
# Register navigation menu and/or menu items (if defined)
|
||||||
try:
|
if menu := self._load_resource('menu'):
|
||||||
menu = import_string(f"{self.__module__}.{self.menu}")
|
|
||||||
register_menu(menu)
|
register_menu(menu)
|
||||||
except ImportError:
|
if menu_items := self._load_resource('menu_items'):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
|
|
||||||
register_menu_items(self.verbose_name, menu_items)
|
register_menu_items(self.verbose_name, menu_items)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Register GraphQL schema (if defined)
|
# Register GraphQL schema (if defined)
|
||||||
try:
|
if graphql_schema := self._load_resource('graphql_schema'):
|
||||||
graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
|
|
||||||
register_graphql_schema(graphql_schema)
|
register_graphql_schema(graphql_schema)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Register user preferences (if defined)
|
# Register user preferences (if defined)
|
||||||
try:
|
if user_preferences := self._load_resource('user_preferences'):
|
||||||
user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
|
|
||||||
register_user_preferences(plugin_name, user_preferences)
|
register_user_preferences(plugin_name, user_preferences)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, user_config, netbox_version):
|
def validate(cls, user_config, netbox_version):
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from netbox.navigation import MenuGroup
|
from netbox.navigation import MenuGroup
|
||||||
from utilities.choices import ButtonColorChoices
|
from utilities.choices import ButtonColorChoices
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'PluginMenu',
|
'PluginMenu',
|
||||||
@ -21,7 +22,7 @@ class PluginMenu:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self.label.replace(' ', '_')
|
return slugify(self.label)
|
||||||
|
|
||||||
|
|
||||||
class PluginMenuItem:
|
class PluginMenuItem:
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import include
|
from django.conf.urls import include
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string, module_has_submodule
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
@ -19,24 +21,21 @@ plugin_admin_patterns = [
|
|||||||
|
|
||||||
# Register base/API URL patterns for each plugin
|
# Register base/API URL patterns for each plugin
|
||||||
for plugin_path in settings.PLUGINS:
|
for plugin_path in settings.PLUGINS:
|
||||||
|
plugin = import_module(plugin_path)
|
||||||
plugin_name = plugin_path.split('.')[-1]
|
plugin_name = plugin_path.split('.')[-1]
|
||||||
app = apps.get_app_config(plugin_name)
|
app = apps.get_app_config(plugin_name)
|
||||||
base_url = getattr(app, 'base_url') or app.label
|
base_url = getattr(app, 'base_url') or app.label
|
||||||
|
|
||||||
# Check if the plugin specifies any base URLs
|
# Check if the plugin specifies any base URLs
|
||||||
try:
|
if module_has_submodule(plugin, 'urls'):
|
||||||
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
||||||
plugin_patterns.append(
|
plugin_patterns.append(
|
||||||
path(f"{base_url}/", include((urlpatterns, app.label)))
|
path(f"{base_url}/", include((urlpatterns, app.label)))
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if the plugin specifies any API URLs
|
# Check if the plugin specifies any API URLs
|
||||||
try:
|
if module_has_submodule(plugin, 'api.urls'):
|
||||||
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
||||||
plugin_api_patterns.append(
|
plugin_api_patterns.append(
|
||||||
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
@ -21,7 +21,7 @@ from extras.models import JobResult
|
|||||||
from extras.signals import clear_webhooks
|
from extras.signals import clear_webhooks
|
||||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortScript, AbortTransaction
|
||||||
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
from .context_managers import change_logging
|
from .context_managers import change_logging
|
||||||
from .forms import ScriptForm
|
from .forms import ScriptForm
|
||||||
@ -352,6 +352,18 @@ class BaseScript:
|
|||||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||||
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
|
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
|
return form
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
@ -470,6 +482,14 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
|||||||
except AbortTransaction:
|
except AbortTransaction:
|
||||||
script.log_info("Database changes have been reverted automatically.")
|
script.log_info("Database changes have been reverted automatically.")
|
||||||
clear_webhooks.send(request)
|
clear_webhooks.send(request)
|
||||||
|
except AbortScript as e:
|
||||||
|
script.log_failure(
|
||||||
|
f"Script aborted with error: {e}"
|
||||||
|
)
|
||||||
|
script.log_info("Database changes have been reverted due to error.")
|
||||||
|
logger.error(f"Script aborted with error: {e}")
|
||||||
|
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||||
|
clear_webhooks.send(request)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stacktrace = traceback.format_exc()
|
stacktrace = traceback.format_exc()
|
||||||
script.log_failure(
|
script.log_failure(
|
||||||
@ -516,27 +536,39 @@ def get_scripts(use_names=False):
|
|||||||
defined name in place of the actual module name.
|
defined name in place of the actual module name.
|
||||||
"""
|
"""
|
||||||
scripts = {}
|
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.
|
# defined.
|
||||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
modules = list(pkgutil.iter_modules([settings.SCRIPTS_ROOT]))
|
||||||
# Use a lock as removing and loading modules is not thread safe
|
modules_bases = set([name.split(".")[0] for _, name, _ in modules])
|
||||||
with lock:
|
|
||||||
# Remove cached module to ensure consistency with filesystem
|
# Deleting from sys.modules needs to done behind a lock to prevent race conditions where a module is
|
||||||
if module_name in sys.modules:
|
# 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]
|
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'):
|
if use_names and hasattr(module, 'name'):
|
||||||
module_name = module.name
|
module_name = module.name
|
||||||
|
|
||||||
module_scripts = {}
|
module_scripts = {}
|
||||||
script_order = getattr(module, "script_order", ())
|
script_order = getattr(module, "script_order", ())
|
||||||
ordered_scripts = [cls for cls in script_order if is_script(cls)]
|
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]
|
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 cls in [*ordered_scripts, *unordered_scripts]:
|
||||||
# For scripts in submodules use the full import path w/o the root module as the name
|
# 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]
|
script_name = cls.full_name.split(".", maxsplit=1)[1]
|
||||||
module_scripts[script_name] = cls
|
module_scripts[script_name] = cls
|
||||||
|
|
||||||
if module_scripts:
|
if module_scripts:
|
||||||
scripts[module_name] = module_scripts
|
scripts[module_name] = module_scripts
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -110,11 +112,14 @@ class SavedFilterTable(NetBoxTable):
|
|||||||
enabled = columns.BooleanColumn()
|
enabled = columns.BooleanColumn()
|
||||||
shared = columns.BooleanColumn()
|
shared = columns.BooleanColumn()
|
||||||
|
|
||||||
|
def value_parameters(self, value):
|
||||||
|
return json.dumps(value)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = SavedFilter
|
model = SavedFilter
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
|
'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
|
||||||
'created', 'last_updated',
|
'created', 'last_updated', 'parameters'
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
|
'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
|
||||||
|
@ -101,6 +101,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
|||||||
'content_types': ['dcim.site'],
|
'content_types': ['dcim.site'],
|
||||||
'name': 'cf6',
|
'name': 'cf6',
|
||||||
'type': 'select',
|
'type': 'select',
|
||||||
|
'choices': ['A', 'B', 'C']
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
|
@ -126,6 +126,16 @@ class ConditionSetTest(TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
ConditionSet({'foo': []})
|
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):
|
def test_and_single_depth(self):
|
||||||
cs = ConditionSet({
|
cs = ConditionSet({
|
||||||
'and': [
|
'and': [
|
||||||
|
@ -502,7 +502,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
def test_assigned_object_type(self):
|
def test_assigned_object_type(self):
|
||||||
params = {'assigned_object_type': 'dcim.site'}
|
params = {'assigned_object_type': 'dcim.site'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
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)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
def test_assigned_object(self):
|
def test_assigned_object(self):
|
||||||
@ -876,7 +876,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
|||||||
def test_changed_object_type(self):
|
def test_changed_object_type(self):
|
||||||
params = {'changed_object_type': 'dcim.site'}
|
params = {'changed_object_type': 'dcim.site'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||||
def test_changed_object_type_id(self):
|
|
||||||
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
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.forms import SiteForm
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import CustomFieldTypeChoices
|
from extras.choices import CustomFieldTypeChoices
|
||||||
|
from extras.forms import SavedFilterForm
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
|
|
||||||
|
|
||||||
@ -77,3 +78,24 @@ class CustomFieldModelFormTest(TestCase):
|
|||||||
for field_type, _ in CustomFieldTypeChoices.CHOICES:
|
for field_type, _ in CustomFieldTypeChoices.CHOICES:
|
||||||
self.assertIn(field_type, instance.custom_field_data)
|
self.assertIn(field_type, instance.custom_field_data)
|
||||||
self.assertIsNone(instance.custom_field_data[field_type])
|
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'),
|
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'),
|
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 import messages
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Count, Q
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
@ -10,6 +10,7 @@ from rq import Worker
|
|||||||
|
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.htmx import is_htmx
|
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.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
@ -885,3 +886,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
|
|||||||
queryset = JobResult.objects.all()
|
queryset = JobResult.objects.all()
|
||||||
filterset = filtersets.JobResultFilterSet
|
filterset = filtersets.JobResultFilterSet
|
||||||
table = tables.JobResultTable
|
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.
|
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
|
|||||||
from .choices import *
|
from .choices import *
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'AggregateFilterSet',
|
'AggregateFilterSet',
|
||||||
@ -405,6 +406,14 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
field_name='start_address',
|
field_name='start_address',
|
||||||
lookup_expr='family'
|
lookup_expr='family'
|
||||||
)
|
)
|
||||||
|
start_address = MultiValueCharFilter(
|
||||||
|
method='filter_address',
|
||||||
|
label=_('Address'),
|
||||||
|
)
|
||||||
|
end_address = MultiValueCharFilter(
|
||||||
|
method='filter_address',
|
||||||
|
label=_('Address'),
|
||||||
|
)
|
||||||
contains = django_filters.CharFilter(
|
contains = django_filters.CharFilter(
|
||||||
method='search_contains',
|
method='search_contains',
|
||||||
label=_('Ranges which contain this prefix or IP'),
|
label=_('Ranges which contain this prefix or IP'),
|
||||||
@ -441,9 +450,9 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
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:
|
try:
|
||||||
ipaddress = str(netaddr.IPNetwork(value.strip()).cidr)
|
ipaddress = str(netaddr.IPNetwork(value.strip()))
|
||||||
qs_filter |= Q(start_address=ipaddress)
|
qs_filter |= Q(start_address=ipaddress)
|
||||||
qs_filter |= Q(end_address=ipaddress)
|
qs_filter |= Q(end_address=ipaddress)
|
||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
@ -461,6 +470,12 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
return queryset.none()
|
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):
|
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||||
family = django_filters.NumberFilter(
|
family = django_filters.NumberFilter(
|
||||||
@ -585,7 +600,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
return queryset.none()
|
return queryset.none()
|
||||||
return queryset.filter(q)
|
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):
|
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:
|
try:
|
||||||
return queryset.filter(address__net_in=value)
|
return queryset.filter(address__net_in=value)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
@ -923,6 +964,18 @@ class ServiceFilterSet(NetBoxModelFilterSet):
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
label=_('Virtual machine (name)'),
|
label=_('Virtual machine (name)'),
|
||||||
)
|
)
|
||||||
|
ipaddress_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='ipaddresses',
|
||||||
|
queryset=IPAddress.objects.all(),
|
||||||
|
label=_('IP address (ID)'),
|
||||||
|
)
|
||||||
|
ipaddress = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='ipaddresses__address',
|
||||||
|
queryset=IPAddress.objects.all(),
|
||||||
|
to_field_name='address',
|
||||||
|
label=_('IP address'),
|
||||||
|
)
|
||||||
|
|
||||||
port = NumericArrayFilter(
|
port = NumericArrayFilter(
|
||||||
field_name='ports',
|
field_name='ports',
|
||||||
lookup_expr='contains'
|
lookup_expr='contains'
|
||||||
|
@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
|||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
|
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
|
||||||
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
|
StaticSelect, DynamicModelMultipleChoiceField
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -578,6 +578,7 @@ class FHRPGroupForm(NetBoxModelForm):
|
|||||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||||
assigned_object=instance
|
assigned_object=instance
|
||||||
)
|
)
|
||||||
|
ipaddress.populate_custom_field_defaults()
|
||||||
ipaddress.save()
|
ipaddress.save()
|
||||||
|
|
||||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
# 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):
|
class ASNType(NetBoxObjectType):
|
||||||
asn = graphene.Field(BigInt)
|
asn = graphene.Field(BigInt)
|
||||||
|
|
||||||
@ -36,7 +58,7 @@ class ASNType(NetBoxObjectType):
|
|||||||
filterset_class = filtersets.ASNFilterSet
|
filterset_class = filtersets.ASNFilterSet
|
||||||
|
|
||||||
|
|
||||||
class AggregateType(NetBoxObjectType):
|
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Aggregate
|
model = models.Aggregate
|
||||||
@ -64,7 +86,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
|
|||||||
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
|
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
|
||||||
|
|
||||||
|
|
||||||
class IPAddressType(NetBoxObjectType):
|
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||||
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
|
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -87,7 +109,7 @@ class IPRangeType(NetBoxObjectType):
|
|||||||
return self.role or None
|
return self.role or None
|
||||||
|
|
||||||
|
|
||||||
class PrefixType(NetBoxObjectType):
|
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Prefix
|
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
|
||||||
|
),
|
||||||
|
]
|
@ -6,7 +6,7 @@ from netbox.search import SearchIndex, register_search
|
|||||||
class AggregateIndex(SearchIndex):
|
class AggregateIndex(SearchIndex):
|
||||||
model = models.Aggregate
|
model = models.Aggregate
|
||||||
fields = (
|
fields = (
|
||||||
('prefix', 100),
|
('prefix', 120),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
('date_added', 2000),
|
('date_added', 2000),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
@ -70,7 +70,7 @@ class L2VPNIndex(SearchIndex):
|
|||||||
class PrefixIndex(SearchIndex):
|
class PrefixIndex(SearchIndex):
|
||||||
model = models.Prefix
|
model = models.Prefix
|
||||||
fields = (
|
fields = (
|
||||||
('prefix', 100),
|
('prefix', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
@ -62,7 +62,7 @@ class RouteTargetTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:vrf_list'
|
url_name='ipam:routetarget_list'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
|
@ -10,6 +10,7 @@ from ipam.models import *
|
|||||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
@ -680,6 +681,14 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'family': '6'}
|
params = {'family': '6'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
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):
|
def test_contains(self):
|
||||||
params = {'contains': '10.0.1.150/24'}
|
params = {'contains': '10.0.1.150/24'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
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']}
|
params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
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):
|
def test_mask_length(self):
|
||||||
params = {'mask_length': '24'}
|
params = {'mask_length': '24'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||||
@ -1420,6 +1449,19 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
)
|
)
|
||||||
Device.objects.bulk_create(devices)
|
Device.objects.bulk_create(devices)
|
||||||
|
|
||||||
|
interface = Interface.objects.create(
|
||||||
|
device=devices[0],
|
||||||
|
name='eth0',
|
||||||
|
type=InterfaceTypeChoices.TYPE_VIRTUAL
|
||||||
|
)
|
||||||
|
interface_ct = ContentType.objects.get_for_model(Interface).pk
|
||||||
|
ip_addresses = (
|
||||||
|
IPAddress(address='192.0.2.1/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk),
|
||||||
|
IPAddress(address='192.0.2.2/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk),
|
||||||
|
IPAddress(address='192.0.2.3/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk),
|
||||||
|
)
|
||||||
|
IPAddress.objects.bulk_create(ip_addresses)
|
||||||
|
|
||||||
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||||
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
|
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
|
||||||
|
|
||||||
@ -1439,6 +1481,9 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
|
Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
|
||||||
)
|
)
|
||||||
Service.objects.bulk_create(services)
|
Service.objects.bulk_create(services)
|
||||||
|
services[0].ipaddresses.add(ip_addresses[0])
|
||||||
|
services[1].ipaddresses.add(ip_addresses[1])
|
||||||
|
services[2].ipaddresses.add(ip_addresses[2])
|
||||||
|
|
||||||
def test_name(self):
|
def test_name(self):
|
||||||
params = {'name': ['Service 1', 'Service 2']}
|
params = {'name': ['Service 1', 'Service 2']}
|
||||||
@ -1470,6 +1515,13 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_ipaddress(self):
|
||||||
|
ips = IPAddress.objects.all()[:2]
|
||||||
|
params = {'ipaddress_id': [ips[0].pk, ips[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = L2VPN.objects.all()
|
queryset = L2VPN.objects.all()
|
||||||
|
@ -107,6 +107,9 @@ CORS_ORIGIN_REGEX_WHITELIST = [
|
|||||||
# r'^(https?://)?(\w+\.)?example\.com$',
|
# 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
|
# 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
|
# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
|
||||||
# on a production system.
|
# on a production system.
|
||||||
@ -127,6 +130,9 @@ EMAIL = {
|
|||||||
'FROM_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
|
# 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.
|
# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
|
||||||
EXEMPT_VIEW_PERMISSIONS = [
|
EXEMPT_VIEW_PERMISSIONS = [
|
||||||
@ -168,16 +174,6 @@ LOGOUT_REDIRECT_URL = 'home'
|
|||||||
# the default value of this setting is derived from the installed location.
|
# the default value of this setting is derived from the installed location.
|
||||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
# 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'
|
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
|
||||||
METRICS_ENABLED = False
|
METRICS_ENABLED = False
|
||||||
|
|
||||||
@ -217,9 +213,6 @@ RQ_DEFAULT_TIMEOUT = 300
|
|||||||
# this setting is derived from the installed location.
|
# this setting is derived from the installed location.
|
||||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||||
|
|
||||||
# The name to use for the csrf token cookie.
|
|
||||||
CSRF_COOKIE_NAME = 'csrftoken'
|
|
||||||
|
|
||||||
# The name to use for the session cookie.
|
# The name to use for the session cookie.
|
||||||
SESSION_COOKIE_NAME = 'sessionid'
|
SESSION_COOKIE_NAME = 'sessionid'
|
||||||
|
|
||||||
@ -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.
|
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
|
||||||
SESSION_FILE_PATH = None
|
SESSION_FILE_PATH = None
|
||||||
|
|
||||||
# Localization
|
# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||||
ENABLE_LOCALIZATION = False
|
# 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 (default: UTC)
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'UTC'
|
||||||
|
@ -131,7 +131,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
|
|||||||
|
|
||||||
def _extend_nullable_fields(self):
|
def _extend_nullable_fields(self):
|
||||||
nullable_custom_fields = [
|
nullable_custom_fields = [
|
||||||
name for name, customfield in self.custom_fields.items() if not customfield.required
|
name for name, customfield in self.custom_fields.items() if (not customfield.required and customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE)
|
||||||
]
|
]
|
||||||
self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
|
self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
|
||||||
|
|
||||||
|
@ -60,6 +60,8 @@ class ObjectListField(DjangoListField):
|
|||||||
filterset_class = django_object_type._meta.filterset_class
|
filterset_class = django_object_type._meta.filterset_class
|
||||||
if filterset_class:
|
if filterset_class:
|
||||||
filterset = filterset_class(data=args, queryset=queryset, request=info.context)
|
filterset = filterset_class(data=args, queryset=queryset, request=info.context)
|
||||||
|
if not filterset.is_valid():
|
||||||
|
return queryset.none()
|
||||||
return filterset.qs
|
return filterset.qs
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
@ -111,7 +112,11 @@ class CloningMixin(models.Model):
|
|||||||
for field_name in getattr(self, 'clone_fields', []):
|
for field_name in getattr(self, 'clone_fields', []):
|
||||||
field = self._meta.get_field(field_name)
|
field = self._meta.get_field(field_name)
|
||||||
field_value = field.value_from_object(self)
|
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
|
attrs[field_name] = field_value
|
||||||
|
|
||||||
# Include tags (if applicable)
|
# Include tags (if applicable)
|
||||||
@ -216,6 +221,13 @@ class CustomFieldsMixin(models.Model):
|
|||||||
|
|
||||||
return dict(groups)
|
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):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
@ -257,6 +269,10 @@ class CustomValidationMixin(models.Model):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
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
|
# Send the post_clean signal
|
||||||
post_clean.send(sender=self.__class__, instance=self)
|
post_clean.send(sender=self.__class__, instance=self)
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ PREFERENCES = {
|
|||||||
'pagination.per_page': UserPreference(
|
'pagination.per_page': UserPreference(
|
||||||
label=_('Page length'),
|
label=_('Page length'),
|
||||||
choices=get_page_lengths(),
|
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)
|
coerce=lambda x: int(x)
|
||||||
),
|
),
|
||||||
'pagination.placement': UserPreference(
|
'pagination.placement': UserPreference(
|
||||||
|
@ -2,6 +2,7 @@ from collections import namedtuple
|
|||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from ipam.fields import IPAddressField, IPNetworkField
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
|
||||||
ObjectFieldValue = namedtuple('ObjectFieldValue', ('name', 'type', 'weight', 'value'))
|
ObjectFieldValue = namedtuple('ObjectFieldValue', ('name', 'type', 'weight', 'value'))
|
||||||
@ -11,6 +12,8 @@ class FieldTypes:
|
|||||||
FLOAT = 'float'
|
FLOAT = 'float'
|
||||||
INTEGER = 'int'
|
INTEGER = 'int'
|
||||||
STRING = 'str'
|
STRING = 'str'
|
||||||
|
INET = 'inet'
|
||||||
|
CIDR = 'cidr'
|
||||||
|
|
||||||
|
|
||||||
class LookupTypes:
|
class LookupTypes:
|
||||||
@ -43,6 +46,10 @@ class SearchIndex:
|
|||||||
field_cls = instance._meta.get_field(field_name).__class__
|
field_cls = instance._meta.get_field(field_name).__class__
|
||||||
if issubclass(field_cls, (models.FloatField, models.DecimalField)):
|
if issubclass(field_cls, (models.FloatField, models.DecimalField)):
|
||||||
return FieldTypes.FLOAT
|
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):
|
if issubclass(field_cls, models.IntegerField):
|
||||||
return FieldTypes.INTEGER
|
return FieldTypes.INTEGER
|
||||||
return FieldTypes.STRING
|
return FieldTypes.STRING
|
||||||
|
@ -3,10 +3,12 @@ from collections import defaultdict
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
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.functions import window
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
import netaddr
|
||||||
|
from netaddr.core import AddrFormatError
|
||||||
|
|
||||||
from extras.models import CachedValue, CustomField
|
from extras.models import CachedValue, CustomField
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
@ -52,11 +54,11 @@ class SearchBackend:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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.
|
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):
|
def removal_handler(self, sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -78,7 +80,13 @@ class SearchBackend:
|
|||||||
|
|
||||||
def clear(self, object_types=None):
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
@ -95,18 +103,24 @@ class CachedValueSearchBackend(SearchBackend):
|
|||||||
|
|
||||||
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||||
|
|
||||||
# Define the search parameters
|
query_filter = Q(**{f'value__{lookup}': value})
|
||||||
params = {
|
|
||||||
f'value__{lookup}': value
|
if object_types:
|
||||||
}
|
query_filter &= Q(object_type__in=object_types)
|
||||||
|
|
||||||
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
||||||
# Partial string matches are valid only on string values
|
# Partial string matches are valid only on string values
|
||||||
params['type'] = FieldTypes.STRING
|
query_filter &= Q(type=FieldTypes.STRING)
|
||||||
if object_types:
|
|
||||||
params['object_type__in'] = object_types
|
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
|
# 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
|
# Annotate the rank of each result for its object according to its weight
|
||||||
row_number=Window(
|
row_number=Window(
|
||||||
expression=window.RowNumber(),
|
expression=window.RowNumber(),
|
||||||
@ -210,6 +224,12 @@ class CachedValueSearchBackend(SearchBackend):
|
|||||||
# Call _raw_delete() on the queryset to avoid first loading instances into memory
|
# Call _raw_delete() on the queryset to avoid first loading instances into memory
|
||||||
return qs._raw_delete(using=qs.db)
|
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
|
@property
|
||||||
def size(self):
|
def size(self):
|
||||||
return CachedValue.objects.count()
|
return CachedValue.objects.count()
|
||||||
|
@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.4.4-dev'
|
VERSION = '3.4.8-dev'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
@ -91,6 +91,7 @@ DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BAS
|
|||||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
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)
|
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||||
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||||
@ -395,8 +396,10 @@ TEMPLATES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Set up authentication backends
|
# Set up authentication backends
|
||||||
|
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
|
||||||
|
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
REMOTE_AUTH_BACKEND,
|
*REMOTE_AUTH_BACKEND,
|
||||||
'netbox.authentication.ObjectPermissionBackend',
|
'netbox.authentication.ObjectPermissionBackend',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField
|
|||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.dateparse import parse_date
|
from django.utils.dateparse import parse_date
|
||||||
from django.utils.encoding import escape_uri_path
|
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
@ -235,7 +235,7 @@ class ActionsColumn(tables.Column):
|
|||||||
|
|
||||||
model = table.Meta.model
|
model = table.Meta.model
|
||||||
request = getattr(table, 'context', {}).get('request')
|
request = getattr(table, 'context', {}).get('request')
|
||||||
url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
|
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
|
||||||
html = ''
|
html = ''
|
||||||
|
|
||||||
# Compile actions menu
|
# Compile actions menu
|
||||||
|
@ -384,8 +384,8 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
'data': record,
|
'data': record,
|
||||||
'instance': instance,
|
'instance': instance,
|
||||||
}
|
}
|
||||||
if form.cleaned_data['format'] == ImportFormatChoices.CSV:
|
if hasattr(form, '_csv_headers'):
|
||||||
model_form_kwargs['headers'] = form._csv_headers
|
model_form_kwargs['headers'] = form._csv_headers # Add CSV headers
|
||||||
model_form = self.model_form(**model_form_kwargs)
|
model_form = self.model_form(**model_form_kwargs)
|
||||||
|
|
||||||
# When updating, omit all form fields other than those specified in the record. (No
|
# When updating, omit all form fields other than those specified in the record. (No
|
||||||
|
@ -436,6 +436,10 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
|||||||
form = self.initialize_form(request)
|
form = self.initialize_form(request)
|
||||||
instance = self.alter_object(self.queryset.model(), 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():
|
if form.is_valid():
|
||||||
new_components = []
|
new_components = []
|
||||||
data = deepcopy(request.POST)
|
data = deepcopy(request.POST)
|
||||||
@ -453,6 +457,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
|||||||
|
|
||||||
if component_form.is_valid():
|
if component_form.is_valid():
|
||||||
new_components.append(component_form)
|
new_components.append(component_form)
|
||||||
|
else:
|
||||||
|
form.errors.update(component_form.errors)
|
||||||
|
break
|
||||||
|
|
||||||
if not form.errors and not component_form.errors:
|
if not form.errors and not component_form.errors:
|
||||||
try:
|
try:
|
||||||
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -56,4 +56,4 @@
|
|||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions';
|
|||||||
import { initReslug } from './reslug';
|
import { initReslug } from './reslug';
|
||||||
import { initSelectAll } from './selectAll';
|
import { initSelectAll } from './selectAll';
|
||||||
import { initSelectMultiple } from './selectMultiple';
|
import { initSelectMultiple } from './selectMultiple';
|
||||||
|
import { initMarkdownPreviews } from './markdownPreview';
|
||||||
|
|
||||||
export function initButtons(): void {
|
export function initButtons(): void {
|
||||||
for (const func of [
|
for (const func of [
|
||||||
@ -13,6 +14,7 @@ export function initButtons(): void {
|
|||||||
initSelectAll,
|
initSelectAll,
|
||||||
initSelectMultiple,
|
initSelectMultiple,
|
||||||
initMoveButtons,
|
initMoveButtons,
|
||||||
|
initMarkdownPreviews,
|
||||||
]) {
|
]) {
|
||||||
func();
|
func();
|
||||||
}
|
}
|
||||||
|
45
netbox/project-static/src/buttons/markdownPreview.ts
Normal file
45
netbox/project-static/src/buttons/markdownPreview.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { isTruthy } from 'src/util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* interface for htmx configRequest event
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementEventMap {
|
||||||
|
'htmx:configRequest': CustomEvent<{
|
||||||
|
parameters: Record<string, string>;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMarkdownPreview(markdownWidget: HTMLDivElement) {
|
||||||
|
const previewButton = markdownWidget.querySelector('button.preview-button') as HTMLButtonElement;
|
||||||
|
const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure the textarea has style attribute height
|
||||||
|
* So that it can be copied over to preview div.
|
||||||
|
*/
|
||||||
|
if (!isTruthy(textarea.style.height)) {
|
||||||
|
const { height } = textarea.getBoundingClientRect();
|
||||||
|
textarea.style.height = `${height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the value of the textarea to the body of the htmx request
|
||||||
|
* and copy the height of text are to the preview div
|
||||||
|
*/
|
||||||
|
previewButton.addEventListener('htmx:configRequest', e => {
|
||||||
|
e.detail.parameters = { text: textarea.value || '' };
|
||||||
|
e.detail.headers['X-CSRFToken'] = window.CSRF_TOKEN;
|
||||||
|
preview.style.minHeight = textarea.style.height;
|
||||||
|
preview.innerHTML = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initMarkdownPreviews(): void {
|
||||||
|
for (const markdownWidget of document.querySelectorAll<HTMLDivElement>('.markdown-widget')) {
|
||||||
|
initMarkdownPreview(markdownWidget);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import { getElements, replaceAll, findFirstAdjacent } from '../util';
|
import { getElements, replaceAll, findFirstAdjacent } from '../util';
|
||||||
|
|
||||||
type InterfaceState = 'enabled' | 'disabled';
|
|
||||||
type ShowHide = 'show' | 'hide';
|
type ShowHide = 'show' | 'hide';
|
||||||
|
|
||||||
function isShowHide(value: unknown): value is ShowHide {
|
function isShowHide(value: unknown): value is ShowHide {
|
||||||
@ -27,54 +26,23 @@ class ButtonState {
|
|||||||
* Underlying Button DOM Element
|
* Underlying Button DOM Element
|
||||||
*/
|
*/
|
||||||
public button: HTMLButtonElement;
|
public button: HTMLButtonElement;
|
||||||
/**
|
|
||||||
* Table rows with `data-enabled` set to `"enabled"`
|
|
||||||
*/
|
|
||||||
private enabledRows: NodeListOf<HTMLTableRowElement>;
|
|
||||||
/**
|
|
||||||
* Table rows with `data-enabled` set to `"disabled"`
|
|
||||||
*/
|
|
||||||
private disabledRows: NodeListOf<HTMLTableRowElement>;
|
|
||||||
|
|
||||||
constructor(button: HTMLButtonElement, table: HTMLTableElement) {
|
/**
|
||||||
|
* Table rows provided in constructor
|
||||||
|
*/
|
||||||
|
private rows: NodeListOf<HTMLTableRowElement>;
|
||||||
|
|
||||||
|
constructor(button: HTMLButtonElement, rows: NodeListOf<HTMLTableRowElement>) {
|
||||||
this.button = button;
|
this.button = button;
|
||||||
this.enabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]');
|
this.rows = rows;
|
||||||
this.disabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This button's controlled type. For example, a button with the class `toggle-disabled` has
|
* Remove visibility of button state rows.
|
||||||
* directive 'disabled' because it controls the visibility of rows with
|
|
||||||
* `data-enabled="disabled"`. Likewise, `toggle-enabled` controls rows with
|
|
||||||
* `data-enabled="enabled"`.
|
|
||||||
*/
|
*/
|
||||||
private get directive(): InterfaceState {
|
private hideRows(): void {
|
||||||
if (this.button.classList.contains('toggle-disabled')) {
|
for (const row of this.rows) {
|
||||||
return 'disabled';
|
row.classList.add('d-none');
|
||||||
} else if (this.button.classList.contains('toggle-enabled')) {
|
|
||||||
return 'enabled';
|
|
||||||
}
|
|
||||||
// If this class has been instantiated but doesn't contain these classes, it's probably because
|
|
||||||
// the classes are missing in the HTML template.
|
|
||||||
console.warn(this.button);
|
|
||||||
throw new Error('Toggle button does not contain expected class');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle visibility of rows with `data-enabled="enabled"`.
|
|
||||||
*/
|
|
||||||
private toggleEnabledRows(): void {
|
|
||||||
for (const row of this.enabledRows) {
|
|
||||||
row.classList.toggle('d-none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle visibility of rows with `data-enabled="disabled"`.
|
|
||||||
*/
|
|
||||||
private toggleDisabledRows(): void {
|
|
||||||
for (const row of this.disabledRows) {
|
|
||||||
row.classList.toggle('d-none');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,17 +79,6 @@ class ButtonState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle visibility for the rows this element controls.
|
|
||||||
*/
|
|
||||||
private toggleRows(): void {
|
|
||||||
if (this.directive === 'enabled') {
|
|
||||||
this.toggleEnabledRows();
|
|
||||||
} else if (this.directive === 'disabled') {
|
|
||||||
this.toggleDisabledRows();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle the DOM element's `data-state` attribute.
|
* Toggle the DOM element's `data-state` attribute.
|
||||||
*/
|
*/
|
||||||
@ -139,17 +96,20 @@ class ButtonState {
|
|||||||
private toggle(): void {
|
private toggle(): void {
|
||||||
this.toggleState();
|
this.toggleState();
|
||||||
this.toggleButton();
|
this.toggleButton();
|
||||||
this.toggleRows();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the button is clicked, toggle all controlled elements.
|
* When the button is clicked, toggle all controlled elements and hide rows based on
|
||||||
|
* buttonstate.
|
||||||
*/
|
*/
|
||||||
public handleClick(event: Event): void {
|
public handleClick(event: Event): void {
|
||||||
const button = event.currentTarget as HTMLButtonElement;
|
const button = event.currentTarget as HTMLButtonElement;
|
||||||
if (button.isEqualNode(this.button)) {
|
if (button.isEqualNode(this.button)) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
}
|
}
|
||||||
|
if (this.buttonState === 'hide') {
|
||||||
|
this.hideRows();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,14 +134,25 @@ class TableState {
|
|||||||
// @ts-expect-error null handling is performed in the constructor
|
// @ts-expect-error null handling is performed in the constructor
|
||||||
private disabledButton: ButtonState;
|
private disabledButton: ButtonState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance of ButtonState for the 'show/hide virtual rows' button.
|
||||||
|
*/
|
||||||
|
// @ts-expect-error null handling is performed in the constructor
|
||||||
|
private virtualButton: ButtonState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Underlying DOM Table Caption Element.
|
* Underlying DOM Table Caption Element.
|
||||||
*/
|
*/
|
||||||
private caption: Nullable<HTMLTableCaptionElement> = null;
|
private caption: Nullable<HTMLTableCaptionElement> = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All table rows in table
|
||||||
|
*/
|
||||||
|
private rows: NodeListOf<HTMLTableRowElement>;
|
||||||
|
|
||||||
constructor(table: HTMLTableElement) {
|
constructor(table: HTMLTableElement) {
|
||||||
this.table = table;
|
this.table = table;
|
||||||
|
this.rows = this.table.querySelectorAll('tr');
|
||||||
try {
|
try {
|
||||||
const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
|
const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
|
||||||
this.table,
|
this.table,
|
||||||
@ -191,6 +162,10 @@ class TableState {
|
|||||||
this.table,
|
this.table,
|
||||||
'button.toggle-disabled',
|
'button.toggle-disabled',
|
||||||
);
|
);
|
||||||
|
const toggleVirtualButton = findFirstAdjacent<HTMLButtonElement>(
|
||||||
|
this.table,
|
||||||
|
'button.toggle-virtual',
|
||||||
|
);
|
||||||
|
|
||||||
const caption = this.table.querySelector('caption');
|
const caption = this.table.querySelector('caption');
|
||||||
this.caption = caption;
|
this.caption = caption;
|
||||||
@ -203,13 +178,28 @@ class TableState {
|
|||||||
throw new TableStateError("Table is missing a 'toggle-disabled' button.", table);
|
throw new TableStateError("Table is missing a 'toggle-disabled' button.", table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toggleVirtualButton === null) {
|
||||||
|
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
|
||||||
|
}
|
||||||
|
|
||||||
// Attach event listeners to the buttons elements.
|
// Attach event listeners to the buttons elements.
|
||||||
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
|
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||||
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
|
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||||
|
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
|
||||||
|
|
||||||
// Instantiate ButtonState for each button for state management.
|
// Instantiate ButtonState for each button for state management.
|
||||||
this.enabledButton = new ButtonState(toggleEnabledButton, this.table);
|
this.enabledButton = new ButtonState(
|
||||||
this.disabledButton = new ButtonState(toggleDisabledButton, this.table);
|
toggleEnabledButton,
|
||||||
|
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]'),
|
||||||
|
);
|
||||||
|
this.disabledButton = new ButtonState(
|
||||||
|
toggleDisabledButton,
|
||||||
|
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]'),
|
||||||
|
);
|
||||||
|
this.virtualButton = new ButtonState(
|
||||||
|
toggleVirtualButton,
|
||||||
|
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TableStateError) {
|
if (err instanceof TableStateError) {
|
||||||
// This class is useless for tables that don't have toggle buttons.
|
// This class is useless for tables that don't have toggle buttons.
|
||||||
@ -246,37 +236,42 @@ class TableState {
|
|||||||
private toggleCaption(): void {
|
private toggleCaption(): void {
|
||||||
const showEnabled = this.enabledButton.buttonState === 'show';
|
const showEnabled = this.enabledButton.buttonState === 'show';
|
||||||
const showDisabled = this.disabledButton.buttonState === 'show';
|
const showDisabled = this.disabledButton.buttonState === 'show';
|
||||||
|
const showVirtual = this.virtualButton.buttonState === 'show';
|
||||||
|
|
||||||
if (showEnabled && !showDisabled) {
|
if (showEnabled && !showDisabled && !showVirtual) {
|
||||||
this.captionText = 'Showing Enabled Interfaces';
|
this.captionText = 'Showing Enabled Interfaces';
|
||||||
} else if (showEnabled && showDisabled) {
|
} else if (showEnabled && showDisabled && !showVirtual) {
|
||||||
this.captionText = 'Showing Enabled & Disabled Interfaces';
|
this.captionText = 'Showing Enabled & Disabled Interfaces';
|
||||||
} else if (!showEnabled && showDisabled) {
|
} else if (!showEnabled && showDisabled && !showVirtual) {
|
||||||
this.captionText = 'Showing Disabled Interfaces';
|
this.captionText = 'Showing Disabled Interfaces';
|
||||||
} else if (!showEnabled && !showDisabled) {
|
} else if (!showEnabled && !showDisabled && !showVirtual) {
|
||||||
this.captionText = 'Hiding Enabled & Disabled Interfaces';
|
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
|
||||||
|
} else if (!showEnabled && !showDisabled && showVirtual) {
|
||||||
|
this.captionText = 'Showing Virtual Interfaces';
|
||||||
|
} else if (showEnabled && !showDisabled && showVirtual) {
|
||||||
|
this.captionText = 'Showing Enabled & Virtual Interfaces';
|
||||||
|
} else if (showEnabled && showDisabled && showVirtual) {
|
||||||
|
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
|
||||||
} else {
|
} else {
|
||||||
this.captionText = '';
|
this.captionText = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When toggle buttons are clicked, pass the event to the relevant button's handler and update
|
* When toggle buttons are clicked, reapply visability all rows and
|
||||||
* this instance's state.
|
* pass the event to all button handlers
|
||||||
*
|
*
|
||||||
* @param event onClick event for toggle buttons.
|
* @param event onClick event for toggle buttons.
|
||||||
* @param instance Instance of TableState (`this` cannot be used since that's context-specific).
|
* @param instance Instance of TableState (`this` cannot be used since that's context-specific).
|
||||||
*/
|
*/
|
||||||
public handleClick(event: Event, instance: TableState): void {
|
public handleClick(event: Event, instance: TableState): void {
|
||||||
const button = event.currentTarget as HTMLButtonElement;
|
for (const row of this.rows) {
|
||||||
const enabled = button.isEqualNode(instance.enabledButton.button);
|
row.classList.remove('d-none');
|
||||||
const disabled = button.isEqualNode(instance.disabledButton.button);
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
instance.enabledButton.handleClick(event);
|
|
||||||
} else if (disabled) {
|
|
||||||
instance.disabledButton.handleClick(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
instance.enabledButton.handleClick(event);
|
||||||
|
instance.disabledButton.handleClick(event);
|
||||||
|
instance.virtualButton.handleClick(event);
|
||||||
instance.toggleCaption();
|
instance.toggleCaption();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -236,12 +236,12 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th.asc > a::after {
|
th.asc > a::after {
|
||||||
content: "\f0140";
|
content: '\f0140';
|
||||||
font-family: 'Material Design Icons';
|
font-family: 'Material Design Icons';
|
||||||
}
|
}
|
||||||
|
|
||||||
th.desc > a::after {
|
th.desc > a::after {
|
||||||
content: "\f0143";
|
content: '\f0143';
|
||||||
font-family: 'Material Design Icons';
|
font-family: 'Material Design Icons';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -416,18 +416,18 @@ nav.search {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Styles for the quicksearch and its clear button;
|
// Styles for the quicksearch and its clear button;
|
||||||
// Overrides input-group styles and adds transition effects
|
// Overrides input-group styles and adds transition effects
|
||||||
.quicksearch {
|
.quicksearch {
|
||||||
input[type="search"] {
|
input[type='search'] {
|
||||||
border-radius: $border-radius !important;
|
border-radius: $border-radius !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-left: -32px !important;
|
margin-left: -32px !important;
|
||||||
z-index: 100 !important;
|
z-index: 100 !important;
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
border-radius: $border-radius !important;
|
border-radius: $border-radius !important;
|
||||||
transition: visibility 0s, opacity 0.2s linear;
|
transition: visibility 0s, opacity 0.2s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -998,9 +998,24 @@ div.card-overlay {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown widget */
|
||||||
|
.markdown-widget {
|
||||||
|
.nav-link {
|
||||||
|
border-bottom: 0;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--nbx-body-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
background-color: var(--nbx-pre-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Preformatted text blocks
|
// Preformatted text blocks
|
||||||
td pre {
|
td pre {
|
||||||
margin-bottom: 0
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
pre.block {
|
pre.block {
|
||||||
padding: $spacer;
|
padding: $spacer;
|
||||||
|
@ -42,3 +42,9 @@ input[type='search']::-webkit-search-results-button,
|
|||||||
input[type='search']::-webkit-search-results-decoration {
|
input[type='search']::-webkit-search-results-decoration {
|
||||||
-webkit-appearance: none !important;
|
-webkit-appearance: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove x-axis padding from highlighted text
|
||||||
|
mark {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
@ -139,7 +139,7 @@
|
|||||||
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ vc_member.vc_priority|default:"" }}
|
{{ vc_member.vc_priority|placeholder }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -7,5 +7,6 @@
|
|||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
|
<button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
|
||||||
<button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
|
<button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
|
||||||
|
<button type="button" class="dropdown-item toggle-virtual" data-state="show">Hide Virtual</button>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock extra_table_controls %}
|
{% endblock extra_table_controls %}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
{% extends 'generic/bulk_import.html' %}
|
|
||||||
|
|
||||||
{% block tabs %}
|
|
||||||
{% include 'dcim/inc/device_import_header.html' %}
|
|
||||||
{% endblock %}
|
|
@ -1,5 +0,0 @@
|
|||||||
{% extends 'generic/bulk_import.html' %}
|
|
||||||
|
|
||||||
{% block tabs %}
|
|
||||||
{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
|
|
||||||
{% endblock %}
|
|
@ -1,8 +0,0 @@
|
|||||||
<ul class="nav nav-tabs px-3">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<a class ="nav-link{% if not active_tab %} active{% endif %}" href="{% url 'dcim:device_import' %}">Racked Devices</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<a class="nav-link{% if active_tab == 'child_import' %} active{% endif %}" href="{% url 'dcim:device_import_child' %}">Child Devices</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user