mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 09:16:10 -06:00
10520 merge feature
This commit is contained in:
commit
5982da4a86
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.4
|
||||||
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.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
119
README.md
119
README.md
@ -1,71 +1,59 @@
|
|||||||
<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 infrasucture:** 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 crtical 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.
|
|
||||||
|
<div align="center">
|
||||||
The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/).
|
<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 public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev).
|
<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>
|
||||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
<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>
|
||||||
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
|
<br />Stats via <a href="https://repography.com">Repography</a>
|
||||||
complete list of requirements, see `requirements.txt`. The code is available
|
</div>
|
||||||
[on GitHub](https://github.com/netbox-community/netbox).
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<h3>Thank you to our sponsors!</h3>
|
|
||||||
|
|
||||||
[](https://netboxlabs.com)
|
[](https://netboxlabs.com)
|
||||||
|
|
||||||
@ -76,34 +64,10 @@ complete list of requirements, see `requirements.txt`. The code is available
|
|||||||
[](https://sentry.io)
|
[](https://sentry.io)
|
||||||
|
|
||||||
[](https://metal.equinix.com)
|
[](https://metal.equinix.com)
|
||||||
|
|
||||||
[](https://stellar.tech)
|
|
||||||
|
|
||||||
</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
|
|
||||||
|
|
||||||
")
|
")
|
||||||
|
|
||||||
@ -112,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.
|
|
||||||
|
@ -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
|
||||||
|
@ -140,6 +140,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
|
||||||
|
@ -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/)
|
||||||
|
25
docs/models/core/datafile.md
Normal file
25
docs/models/core/datafile.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Data Files
|
||||||
|
|
||||||
|
A data file object is the representation in NetBox's database of some file belonging to a remote [data source](./datasource.md). Data files are synchronized automatically, and cannot be modified locally (although they can be deleted).
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
### Source
|
||||||
|
|
||||||
|
The [data source](./datasource.md) to which this file belongs.
|
||||||
|
|
||||||
|
### Path
|
||||||
|
|
||||||
|
The path to the file, relative to its source's URL. For example, a file at `/opt/config-data/routing/bgp/peer.yaml` with a source URL of `file:///opt/config-data/` would have its path set to `routing/bgp/peer.yaml`.
|
||||||
|
|
||||||
|
### Last Updated
|
||||||
|
|
||||||
|
The date and time at which the file most recently updated from its source. Note that this attribute is updated only when the file's contents have been modified. Re-synchronizing the data source will not update this timestamp if the upstream file's data has not changed.
|
||||||
|
|
||||||
|
### Size
|
||||||
|
|
||||||
|
The file's size, in bytes.
|
||||||
|
|
||||||
|
### Hash
|
||||||
|
|
||||||
|
A [SHA256 hash](https://en.wikipedia.org/wiki/SHA-2) of the file's data. This can be compared to a hash taken from the original file to determine whether any changes have been made.
|
47
docs/models/core/datasource.md
Normal file
47
docs/models/core/datasource.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Data Sources
|
||||||
|
|
||||||
|
A data source represents some external repository of data which NetBox can consume, such as a git repository. Files within the data source are synchronized to NetBox by saving them in the database as [data file](./datafile.md) objects.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
### Name
|
||||||
|
|
||||||
|
The data source's human-friendly name.
|
||||||
|
|
||||||
|
### Type
|
||||||
|
|
||||||
|
The type of data source. Supported options include:
|
||||||
|
|
||||||
|
* Local directory
|
||||||
|
* git repository
|
||||||
|
|
||||||
|
### URL
|
||||||
|
|
||||||
|
The URL identifying the remote source. Some examples are included below.
|
||||||
|
|
||||||
|
| Type | Example URL |
|
||||||
|
|------|-------------|
|
||||||
|
| Local | file:///var/my/data/source/ |
|
||||||
|
| git | https://https://github.com/my-organization/my-repo |
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
The source's current synchronization status. Note that this cannot be set manually: It is updated automatically when the source is synchronized.
|
||||||
|
|
||||||
|
### Enabled
|
||||||
|
|
||||||
|
If false, synchronization will be disabled.
|
||||||
|
|
||||||
|
### Ignore Rules
|
||||||
|
|
||||||
|
A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
|
||||||
|
|
||||||
|
| Rule | Description |
|
||||||
|
|----------------|------------------------------------------|
|
||||||
|
| `README` | Ignore any files named `README` |
|
||||||
|
| `*.txt` | Ignore any files with a `.txt` extension |
|
||||||
|
| `data???.json` | Ignore e.g. `data123.json` |
|
||||||
|
|
||||||
|
### Last Synced
|
||||||
|
|
||||||
|
The date and time at which the source was most recently synchronized successfully.
|
@ -18,6 +18,10 @@ A numeric value which influences the order in which context data is merged. Cont
|
|||||||
|
|
||||||
The context data expressed in JSON format.
|
The context data expressed in JSON format.
|
||||||
|
|
||||||
|
### Data File
|
||||||
|
|
||||||
|
Config context data may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local data for the config context: It will be populated automatically from the data file.
|
||||||
|
|
||||||
### Is Active
|
### Is Active
|
||||||
|
|
||||||
If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context.
|
If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context.
|
||||||
|
@ -12,6 +12,10 @@ The name of the export template. This will appear in the "export" dropdown list
|
|||||||
|
|
||||||
The type of NetBox object to which the export template applies.
|
The type of NetBox object to which the export template applies.
|
||||||
|
|
||||||
|
### Data File
|
||||||
|
|
||||||
|
Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local content for the template: It will be populated automatically from the data file.
|
||||||
|
|
||||||
### Template Code
|
### Template Code
|
||||||
|
|
||||||
Jinja2 template code for rendering the exported data.
|
Jinja2 template code for rendering the exported data.
|
||||||
|
@ -170,6 +170,9 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
|
|||||||
|
|
||||||
## Choice Fields
|
## Choice Fields
|
||||||
|
|
||||||
|
!!! warning "Obsolete Fields"
|
||||||
|
NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
|
||||||
|
|
||||||
::: utilities.forms.ChoiceField
|
::: utilities.forms.ChoiceField
|
||||||
options:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
@ -48,7 +48,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
|
||||||
|
|
||||||
|
@ -1,19 +1,31 @@
|
|||||||
# NetBox v3.4
|
# NetBox v3.4
|
||||||
|
|
||||||
## v3.4.4 (FUTURE)
|
## v3.4.5 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.4.4 (2023-02-02)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
* [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice
|
* [#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
|
* [#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
|
### 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
|
* [#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
|
* [#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
|
* [#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
|
* [#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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
|
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
|
||||||
* [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging
|
* [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging
|
||||||
* [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces
|
* [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces
|
||||||
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI
|
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI
|
||||||
* [#11584](https://github.com/netbox-community/netbox/issues/11584) - Add a list view for contact assignments
|
* [#11584](https://github.com/netbox-community/netbox/issues/11584) - Add a list view for contact assignments
|
||||||
* [#11625](https://github.com/netbox-community/netbox/issues/11625) - Add HTMX support to ObjectEditView
|
* [#11625](https://github.com/netbox-community/netbox/issues/11625) - Add HTMX support to ObjectEditView
|
||||||
|
* [#11693](https://github.com/netbox-community/netbox/issues/11693) - Enable syncing export template content from remote sources
|
||||||
|
|
||||||
### Other Changes
|
### Other Changes
|
||||||
|
|
||||||
|
@ -7,8 +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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -35,7 +34,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -63,7 +62,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -101,8 +100,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(CircuitStatusChoices),
|
choices=add_blank_choice(CircuitStatusChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -125,7 +123,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
|
|||||||
from ipam.models import ASN
|
from ipam.models import ASN
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||||
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
|
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, TagFilterField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CircuitFilterForm',
|
'CircuitFilterForm',
|
||||||
@ -107,7 +107,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
},
|
},
|
||||||
label=_('Provider network')
|
label=_('Provider network')
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=CircuitStatusChoices,
|
choices=CircuitStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
@ -7,7 +7,6 @@ from netbox.forms import NetBoxModelForm
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField,
|
CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField,
|
||||||
StaticSelect,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -102,7 +101,6 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
|||||||
'commit_rate': _("Committed rate"),
|
'commit_rate': _("Committed rate"),
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'status': StaticSelect(),
|
|
||||||
'install_date': DatePicker(),
|
'install_date': DatePicker(),
|
||||||
'termination_date': DatePicker(),
|
'termination_date': DatePicker(),
|
||||||
'commit_rate': SelectSpeedWidget(),
|
'commit_rate': SelectSpeedWidget(),
|
||||||
@ -174,7 +172,6 @@ class CircuitTerminationForm(NetBoxModelForm):
|
|||||||
'pp_info': _("Patch panel ID and port number(s)")
|
'pp_info': _("Patch panel ID and port number(s)")
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'term_side': StaticSelect(),
|
|
||||||
'port_speed': SelectSpeedWidget(),
|
'port_speed': SelectSpeedWidget(),
|
||||||
'upstream_speed': SelectSpeedWidget(),
|
'upstream_speed': SelectSpeedWidget(),
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
from django.apps import apps
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -10,7 +9,6 @@ from dcim.models import CabledObjectModel
|
|||||||
from netbox.models import (
|
from netbox.models import (
|
||||||
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin,
|
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin,
|
||||||
)
|
)
|
||||||
from netbox.models.features import WebhooksMixin
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Circuit',
|
'Circuit',
|
||||||
@ -132,7 +130,6 @@ class CircuitTermination(
|
|||||||
CustomFieldsMixin,
|
CustomFieldsMixin,
|
||||||
CustomLinksMixin,
|
CustomLinksMixin,
|
||||||
TagsMixin,
|
TagsMixin,
|
||||||
WebhooksMixin,
|
|
||||||
ChangeLoggedModel,
|
ChangeLoggedModel,
|
||||||
CabledObjectModel
|
CabledObjectModel
|
||||||
):
|
):
|
||||||
|
0
netbox/core/__init__.py
Normal file
0
netbox/core/__init__.py
Normal file
0
netbox/core/api/__init__.py
Normal file
0
netbox/core/api/__init__.py
Normal file
25
netbox/core/api/nested_serializers.py
Normal file
25
netbox/core/api/nested_serializers.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.models import *
|
||||||
|
from netbox.api.serializers import WritableNestedSerializer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'NestedDataFileSerializer',
|
||||||
|
'NestedDataSourceSerializer',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NestedDataSourceSerializer(WritableNestedSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='core-api:datasource-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataSource
|
||||||
|
fields = ['id', 'url', 'display', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedDataFileSerializer(WritableNestedSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='core-api:datafile-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataFile
|
||||||
|
fields = ['id', 'url', 'display', 'path']
|
51
netbox/core/api/serializers.py
Normal file
51
netbox/core/api/serializers.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.choices import *
|
||||||
|
from core.models import *
|
||||||
|
from netbox.api.fields import ChoiceField
|
||||||
|
from netbox.api.serializers import NetBoxModelSerializer
|
||||||
|
from .nested_serializers import *
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataSourceSerializer',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceSerializer(NetBoxModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(
|
||||||
|
view_name='core-api:datasource-detail'
|
||||||
|
)
|
||||||
|
type = ChoiceField(
|
||||||
|
choices=DataSourceTypeChoices
|
||||||
|
)
|
||||||
|
status = ChoiceField(
|
||||||
|
choices=DataSourceStatusChoices,
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Related object counts
|
||||||
|
file_count = serializers.IntegerField(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataSource
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
|
||||||
|
'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileSerializer(NetBoxModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(
|
||||||
|
view_name='core-api:datafile-detail'
|
||||||
|
)
|
||||||
|
source = NestedDataSourceSerializer(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataFile
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||||
|
]
|
13
netbox/core/api/urls.py
Normal file
13
netbox/core/api/urls.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from netbox.api.routers import NetBoxRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
router = NetBoxRouter()
|
||||||
|
router.APIRootView = views.CoreRootView
|
||||||
|
|
||||||
|
# Data sources
|
||||||
|
router.register('data-sources', views.DataSourceViewSet)
|
||||||
|
router.register('data-files', views.DataFileViewSet)
|
||||||
|
|
||||||
|
app_name = 'core-api'
|
||||||
|
urlpatterns = router.urls
|
52
netbox/core/api/views.py
Normal file
52
netbox/core/api/views.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.routers import APIRootView
|
||||||
|
|
||||||
|
from core import filtersets
|
||||||
|
from core.models import *
|
||||||
|
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
||||||
|
from utilities.utils import count_related
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class CoreRootView(APIRootView):
|
||||||
|
"""
|
||||||
|
Core API root view
|
||||||
|
"""
|
||||||
|
def get_view_name(self):
|
||||||
|
return 'Core'
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Data sources
|
||||||
|
#
|
||||||
|
|
||||||
|
class DataSourceViewSet(NetBoxModelViewSet):
|
||||||
|
queryset = DataSource.objects.annotate(
|
||||||
|
file_count=count_related(DataFile, 'source')
|
||||||
|
)
|
||||||
|
serializer_class = serializers.DataSourceSerializer
|
||||||
|
filterset_class = filtersets.DataSourceFilterSet
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def sync(self, request, pk):
|
||||||
|
"""
|
||||||
|
Enqueue a job to synchronize the DataSource.
|
||||||
|
"""
|
||||||
|
if not request.user.has_perm('extras.sync_datasource'):
|
||||||
|
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
|
||||||
|
|
||||||
|
datasource = get_object_or_404(DataSource, pk=pk)
|
||||||
|
datasource.enqueue_sync_job(request)
|
||||||
|
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileViewSet(NetBoxReadOnlyModelViewSet):
|
||||||
|
queryset = DataFile.objects.defer('data').prefetch_related('source')
|
||||||
|
serializer_class = serializers.DataFileSerializer
|
||||||
|
filterset_class = filtersets.DataFileFilterSet
|
8
netbox/core/apps.py
Normal file
8
netbox/core/apps.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
name = "core"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import data_backends, search
|
34
netbox/core/choices.py
Normal file
34
netbox/core/choices.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from utilities.choices import ChoiceSet
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Data sources
|
||||||
|
#
|
||||||
|
|
||||||
|
class DataSourceTypeChoices(ChoiceSet):
|
||||||
|
LOCAL = 'local'
|
||||||
|
GIT = 'git'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(LOCAL, _('Local'), 'gray'),
|
||||||
|
(GIT, _('Git'), 'blue'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceStatusChoices(ChoiceSet):
|
||||||
|
|
||||||
|
NEW = 'new'
|
||||||
|
QUEUED = 'queued'
|
||||||
|
SYNCING = 'syncing'
|
||||||
|
COMPLETED = 'completed'
|
||||||
|
FAILED = 'failed'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(NEW, _('New'), 'blue'),
|
||||||
|
(QUEUED, _('Queued'), 'orange'),
|
||||||
|
(SYNCING, _('Syncing'), 'cyan'),
|
||||||
|
(COMPLETED, _('Completed'), 'green'),
|
||||||
|
(FAILED, _('Failed'), 'red'),
|
||||||
|
)
|
117
netbox/core/data_backends.py
Normal file
117
netbox/core/data_backends.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from urllib.parse import quote, urlunparse, urlparse
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from netbox.registry import registry
|
||||||
|
from .choices import DataSourceTypeChoices
|
||||||
|
from .exceptions import SyncError
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'LocalBackend',
|
||||||
|
'GitBackend',
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('netbox.data_backends')
|
||||||
|
|
||||||
|
|
||||||
|
def register_backend(name):
|
||||||
|
"""
|
||||||
|
Decorator for registering a DataBackend class.
|
||||||
|
"""
|
||||||
|
def _wrapper(cls):
|
||||||
|
registry['data_backends'][name] = cls
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return _wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class DataBackend:
|
||||||
|
parameters = {}
|
||||||
|
|
||||||
|
def __init__(self, url, **kwargs):
|
||||||
|
self.url = url
|
||||||
|
self.params = kwargs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url_scheme(self):
|
||||||
|
return urlparse(self.url).scheme.lower()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fetch(self):
|
||||||
|
raise NotImplemented()
|
||||||
|
|
||||||
|
|
||||||
|
@register_backend(DataSourceTypeChoices.LOCAL)
|
||||||
|
class LocalBackend(DataBackend):
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fetch(self):
|
||||||
|
logger.debug(f"Data source type is local; skipping fetch")
|
||||||
|
local_path = urlparse(self.url).path # Strip file:// scheme
|
||||||
|
|
||||||
|
yield local_path
|
||||||
|
|
||||||
|
|
||||||
|
@register_backend(DataSourceTypeChoices.GIT)
|
||||||
|
class GitBackend(DataBackend):
|
||||||
|
parameters = {
|
||||||
|
'username': forms.CharField(
|
||||||
|
required=False,
|
||||||
|
label=_('Username'),
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||||
|
),
|
||||||
|
'password': forms.CharField(
|
||||||
|
required=False,
|
||||||
|
label=_('Password'),
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||||
|
),
|
||||||
|
'branch': forms.CharField(
|
||||||
|
required=False,
|
||||||
|
label=_('Branch'),
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fetch(self):
|
||||||
|
local_path = tempfile.TemporaryDirectory()
|
||||||
|
|
||||||
|
# Add authentication credentials to URL (if specified)
|
||||||
|
username = self.params.get('username')
|
||||||
|
password = self.params.get('password')
|
||||||
|
if username and password:
|
||||||
|
url_components = list(urlparse(self.url))
|
||||||
|
# Prepend username & password to netloc
|
||||||
|
url_components[1] = quote(f'{username}@{password}:') + url_components[1]
|
||||||
|
url = urlunparse(url_components)
|
||||||
|
else:
|
||||||
|
url = self.url
|
||||||
|
|
||||||
|
# Compile git arguments
|
||||||
|
args = ['git', 'clone', '--depth', '1']
|
||||||
|
if branch := self.params.get('branch'):
|
||||||
|
args.extend(['--branch', branch])
|
||||||
|
args.extend([url, local_path.name])
|
||||||
|
|
||||||
|
# Prep environment variables
|
||||||
|
env_vars = {}
|
||||||
|
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
|
||||||
|
env_vars['http_proxy'] = settings.HTTP_PROXIES.get(self.url_scheme)
|
||||||
|
|
||||||
|
logger.debug(f"Cloning git repo: {' '.join(args)}")
|
||||||
|
try:
|
||||||
|
subprocess.run(args, check=True, capture_output=True, env=env_vars)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise SyncError(
|
||||||
|
f"Fetching remote data failed: {e.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
yield local_path.name
|
||||||
|
|
||||||
|
local_path.cleanup()
|
2
netbox/core/exceptions.py
Normal file
2
netbox/core/exceptions.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
class SyncError(Exception):
|
||||||
|
pass
|
64
netbox/core/filtersets.py
Normal file
64
netbox/core/filtersets.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
import django_filters
|
||||||
|
|
||||||
|
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
|
from .choices import *
|
||||||
|
from .models import *
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataFileFilterSet',
|
||||||
|
'DataSourceFilterSet',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceFilterSet(NetBoxModelFilterSet):
|
||||||
|
type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=DataSourceTypeChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=DataSourceStatusChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataSource
|
||||||
|
fields = ('id', 'name', 'enabled')
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(description__icontains=value) |
|
||||||
|
Q(comments__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileFilterSet(ChangeLoggedModelFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search'
|
||||||
|
)
|
||||||
|
source_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
label=_('Data source (ID)'),
|
||||||
|
)
|
||||||
|
source = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='source__name',
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
label=_('Data source (name)'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataFile
|
||||||
|
fields = ('id', 'path', 'last_updated', 'size', 'hash')
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(path__icontains=value)
|
||||||
|
)
|
4
netbox/core/forms/__init__.py
Normal file
4
netbox/core/forms/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .bulk_edit import *
|
||||||
|
from .bulk_import import *
|
||||||
|
from .filtersets import *
|
||||||
|
from .model_forms import *
|
47
netbox/core/forms/bulk_edit.py
Normal file
47
netbox/core/forms/bulk_edit.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.choices import DataSourceTypeChoices
|
||||||
|
from core.models import *
|
||||||
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
|
from utilities.forms import add_blank_choice, BulkEditNullBooleanSelect, CommentField
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataSourceBulkEditForm',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
type = forms.ChoiceField(
|
||||||
|
choices=add_blank_choice(DataSourceTypeChoices),
|
||||||
|
required=False,
|
||||||
|
initial=''
|
||||||
|
)
|
||||||
|
enabled = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect(),
|
||||||
|
label=_('Enforce unique space')
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
max_length=200,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
comments = CommentField(
|
||||||
|
widget=forms.Textarea,
|
||||||
|
label=_('Comments')
|
||||||
|
)
|
||||||
|
parameters = forms.JSONField(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
ignore_rules = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea()
|
||||||
|
)
|
||||||
|
|
||||||
|
model = DataSource
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules')),
|
||||||
|
)
|
||||||
|
nullable_fields = (
|
||||||
|
'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules',
|
||||||
|
)
|
15
netbox/core/forms/bulk_import.py
Normal file
15
netbox/core/forms/bulk_import.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from core.models import *
|
||||||
|
from netbox.forms import NetBoxModelImportForm
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataSourceImportForm',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceImportForm(NetBoxModelImportForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataSource
|
||||||
|
fields = (
|
||||||
|
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules',
|
||||||
|
)
|
47
netbox/core/forms/filtersets.py
Normal file
47
netbox/core/forms/filtersets.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.choices import *
|
||||||
|
from core.models import *
|
||||||
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataFileFilterForm',
|
||||||
|
'DataSourceFilterForm',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
model = DataSource
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id')),
|
||||||
|
('Data Source', ('type', 'status')),
|
||||||
|
)
|
||||||
|
type = forms.MultipleChoiceField(
|
||||||
|
choices=DataSourceTypeChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
status = forms.MultipleChoiceField(
|
||||||
|
choices=DataSourceStatusChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
enabled = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
model = DataFile
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id')),
|
||||||
|
('File', ('source_id',)),
|
||||||
|
)
|
||||||
|
source_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Data source')
|
||||||
|
)
|
81
netbox/core/forms/model_forms.py
Normal file
81
netbox/core/forms/model_forms.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import copy
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from core.models import *
|
||||||
|
from netbox.forms import NetBoxModelForm
|
||||||
|
from netbox.registry import registry
|
||||||
|
from utilities.forms import CommentField
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataSourceForm',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceForm(NetBoxModelForm):
|
||||||
|
comments = CommentField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataSource
|
||||||
|
fields = [
|
||||||
|
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'type': forms.Select(
|
||||||
|
attrs={
|
||||||
|
'hx-get': '.',
|
||||||
|
'hx-include': '#form_fields input',
|
||||||
|
'hx-target': '#form_fields',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
'ignore_rules': forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
'rows': 5,
|
||||||
|
'class': 'font-monospace',
|
||||||
|
'placeholder': '.cache\n*.txt'
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fieldsets(self):
|
||||||
|
fieldsets = [
|
||||||
|
('Source', ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')),
|
||||||
|
]
|
||||||
|
if self.backend_fields:
|
||||||
|
fieldsets.append(
|
||||||
|
('Backend', self.backend_fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
return fieldsets
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
backend_classes = registry['data_backends']
|
||||||
|
|
||||||
|
if self.is_bound and self.data.get('type') in backend_classes:
|
||||||
|
type_ = self.data['type']
|
||||||
|
elif self.initial and self.initial.get('type') in backend_classes:
|
||||||
|
type_ = self.initial['type']
|
||||||
|
else:
|
||||||
|
type_ = self.fields['type'].initial
|
||||||
|
backend = backend_classes.get(type_)
|
||||||
|
|
||||||
|
self.backend_fields = []
|
||||||
|
for name, form_field in backend.parameters.items():
|
||||||
|
field_name = f'backend_{name}'
|
||||||
|
self.backend_fields.append(field_name)
|
||||||
|
self.fields[field_name] = copy.copy(form_field)
|
||||||
|
if self.instance and self.instance.parameters:
|
||||||
|
self.fields[field_name].initial = self.instance.parameters.get(name)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
parameters = {}
|
||||||
|
for name in self.fields:
|
||||||
|
if name.startswith('backend_'):
|
||||||
|
parameters[name[8:]] = self.cleaned_data[name]
|
||||||
|
self.instance.parameters = parameters
|
||||||
|
|
||||||
|
return super().save(*args, **kwargs)
|
0
netbox/core/graphql/__init__.py
Normal file
0
netbox/core/graphql/__init__.py
Normal file
12
netbox/core/graphql/schema.py
Normal file
12
netbox/core/graphql/schema.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from netbox.graphql.fields import ObjectField, ObjectListField
|
||||||
|
from .types import *
|
||||||
|
|
||||||
|
|
||||||
|
class CoreQuery(graphene.ObjectType):
|
||||||
|
data_file = ObjectField(DataFileType)
|
||||||
|
data_file_list = ObjectListField(DataFileType)
|
||||||
|
|
||||||
|
data_source = ObjectField(DataSourceType)
|
||||||
|
data_source_list = ObjectListField(DataSourceType)
|
21
netbox/core/graphql/types.py
Normal file
21
netbox/core/graphql/types.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from core import filtersets, models
|
||||||
|
from netbox.graphql.types import BaseObjectType, NetBoxObjectType
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataFileType',
|
||||||
|
'DataSourceType',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileType(BaseObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = models.DataFile
|
||||||
|
exclude = ('data',)
|
||||||
|
filterset_class = filtersets.DataFileFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceType(NetBoxObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = models.DataSource
|
||||||
|
fields = '__all__'
|
||||||
|
filterset_class = filtersets.DataSourceFilterSet
|
29
netbox/core/jobs.py
Normal file
29
netbox/core/jobs.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from extras.choices import JobResultStatusChoices
|
||||||
|
from netbox.search.backends import search_backend
|
||||||
|
from .choices import *
|
||||||
|
from .exceptions import SyncError
|
||||||
|
from .models import DataSource
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_datasource(job_result, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Call sync() on a DataSource.
|
||||||
|
"""
|
||||||
|
datasource = DataSource.objects.get(name=job_result.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
job_result.start()
|
||||||
|
datasource.sync()
|
||||||
|
|
||||||
|
# Update the search cache for DataFiles belonging to this source
|
||||||
|
search_backend.cache(datasource.datafiles.iterator())
|
||||||
|
|
||||||
|
except SyncError as e:
|
||||||
|
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||||
|
job_result.save()
|
||||||
|
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||||
|
logging.error(e)
|
0
netbox/core/management/__init__.py
Normal file
0
netbox/core/management/__init__.py
Normal file
0
netbox/core/management/commands/__init__.py
Normal file
0
netbox/core/management/commands/__init__.py
Normal file
41
netbox/core/management/commands/syncdatasource.py
Normal file
41
netbox/core/management/commands/syncdatasource.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from core.models import DataSource
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Synchronize a data source from its remote upstream"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('name', nargs='*', help="Data source(s) to synchronize")
|
||||||
|
parser.add_argument(
|
||||||
|
"--all", action='store_true', dest='sync_all',
|
||||||
|
help="Synchronize all data sources"
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
|
||||||
|
# Find DataSources to sync
|
||||||
|
if options['sync_all']:
|
||||||
|
datasources = DataSource.objects.all()
|
||||||
|
elif options['name']:
|
||||||
|
datasources = DataSource.objects.filter(name__in=options['name'])
|
||||||
|
# Check for invalid names
|
||||||
|
found_names = {ds['name'] for ds in datasources.values('name')}
|
||||||
|
if invalid_names := set(options['name']) - found_names:
|
||||||
|
raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}")
|
||||||
|
else:
|
||||||
|
raise CommandError(f"Must specify at least one data source, or set --all.")
|
||||||
|
|
||||||
|
if len(options['name']) > 1:
|
||||||
|
self.stdout.write(f"Syncing {len(datasources)} data sources.")
|
||||||
|
|
||||||
|
for i, datasource in enumerate(datasources, start=1):
|
||||||
|
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
|
||||||
|
self.stdout.flush()
|
||||||
|
datasource.sync()
|
||||||
|
self.stdout.write(datasource.get_status_display())
|
||||||
|
self.stdout.flush()
|
||||||
|
|
||||||
|
if len(options['name']) > 1:
|
||||||
|
self.stdout.write(f"Finished.")
|
62
netbox/core/migrations/0001_initial.py
Normal file
62
netbox/core/migrations/0001_initial.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-02 02:37
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import taggit.managers
|
||||||
|
import utilities.json
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0084_staging'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DataSource',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||||
|
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||||
|
('description', models.CharField(blank=True, max_length=200)),
|
||||||
|
('comments', models.TextField(blank=True)),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('type', models.CharField(default='local', max_length=50)),
|
||||||
|
('source_url', models.CharField(max_length=200)),
|
||||||
|
('status', models.CharField(default='new', editable=False, max_length=50)),
|
||||||
|
('enabled', models.BooleanField(default=True)),
|
||||||
|
('ignore_rules', models.TextField(blank=True)),
|
||||||
|
('parameters', models.JSONField(blank=True, null=True)),
|
||||||
|
('last_synced', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||||
|
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('name',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DataFile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('last_updated', models.DateTimeField(editable=False)),
|
||||||
|
('path', models.CharField(editable=False, max_length=1000)),
|
||||||
|
('size', models.PositiveIntegerField(editable=False)),
|
||||||
|
('hash', models.CharField(editable=False, max_length=64, validators=[django.core.validators.RegexValidator(message='Length must be 64 hexadecimal characters.', regex='^[0-9a-f]{64}$')])),
|
||||||
|
('data', models.BinaryField()),
|
||||||
|
('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='datafiles', to='core.datasource')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('source', 'path'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='datafile',
|
||||||
|
constraint=models.UniqueConstraint(fields=('source', 'path'), name='core_datafile_unique_source_path'),
|
||||||
|
),
|
||||||
|
]
|
0
netbox/core/migrations/__init__.py
Normal file
0
netbox/core/migrations/__init__.py
Normal file
1
netbox/core/models/__init__.py
Normal file
1
netbox/core/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .data import *
|
313
netbox/core/models/data.py
Normal file
313
netbox/core/models/data.py
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from fnmatch import fnmatchcase
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from extras.models import JobResult
|
||||||
|
from netbox.models import PrimaryModel
|
||||||
|
from netbox.models.features import ChangeLoggingMixin
|
||||||
|
from netbox.registry import registry
|
||||||
|
from utilities.files import sha256_hash
|
||||||
|
from utilities.querysets import RestrictedQuerySet
|
||||||
|
from ..choices import *
|
||||||
|
from ..exceptions import SyncError
|
||||||
|
from ..signals import post_sync, pre_sync
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataFile',
|
||||||
|
'DataSource',
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('netbox.core.data')
|
||||||
|
|
||||||
|
|
||||||
|
class DataSource(PrimaryModel):
|
||||||
|
"""
|
||||||
|
A remote source, such as a git repository, from which DataFiles are synchronized.
|
||||||
|
"""
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True
|
||||||
|
)
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=DataSourceTypeChoices,
|
||||||
|
default=DataSourceTypeChoices.LOCAL
|
||||||
|
)
|
||||||
|
source_url = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
verbose_name=_('URL')
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=DataSourceStatusChoices,
|
||||||
|
default=DataSourceStatusChoices.NEW,
|
||||||
|
editable=False
|
||||||
|
)
|
||||||
|
enabled = models.BooleanField(
|
||||||
|
default=True
|
||||||
|
)
|
||||||
|
ignore_rules = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Patterns (one per line) matching files to ignore when syncing")
|
||||||
|
)
|
||||||
|
parameters = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
last_synced = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
editable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('name',)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.name}'
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('core:datasource', args=[self.pk])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def docs_url(self):
|
||||||
|
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
|
||||||
|
|
||||||
|
def get_type_color(self):
|
||||||
|
return DataSourceTypeChoices.colors.get(self.type)
|
||||||
|
|
||||||
|
def get_status_color(self):
|
||||||
|
return DataSourceStatusChoices.colors.get(self.status)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url_scheme(self):
|
||||||
|
return urlparse(self.source_url).scheme.lower()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready_for_sync(self):
|
||||||
|
return self.enabled and self.status not in (
|
||||||
|
DataSourceStatusChoices.QUEUED,
|
||||||
|
DataSourceStatusChoices.SYNCING
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# Ensure URL scheme matches selected type
|
||||||
|
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
|
||||||
|
raise ValidationError({
|
||||||
|
'url': f"URLs for local sources must start with file:// (or omit the scheme)"
|
||||||
|
})
|
||||||
|
|
||||||
|
def enqueue_sync_job(self, request):
|
||||||
|
"""
|
||||||
|
Enqueue a background job to synchronize the DataSource by calling sync().
|
||||||
|
"""
|
||||||
|
# Set the status to "syncing"
|
||||||
|
self.status = DataSourceStatusChoices.QUEUED
|
||||||
|
|
||||||
|
# Enqueue a sync job
|
||||||
|
job_result = JobResult.enqueue_job(
|
||||||
|
import_string('core.jobs.sync_datasource'),
|
||||||
|
name=self.name,
|
||||||
|
obj_type=ContentType.objects.get_for_model(DataSource),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return job_result
|
||||||
|
|
||||||
|
def get_backend(self):
|
||||||
|
backend_cls = registry['data_backends'].get(self.type)
|
||||||
|
backend_params = self.parameters or {}
|
||||||
|
|
||||||
|
return backend_cls(self.source_url, **backend_params)
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
"""
|
||||||
|
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
|
||||||
|
"""
|
||||||
|
if not self.ready_for_sync:
|
||||||
|
raise SyncError(f"Cannot initiate sync; data source not ready/enabled")
|
||||||
|
|
||||||
|
# Emit the pre_sync signal
|
||||||
|
pre_sync.send(sender=self.__class__, instance=self)
|
||||||
|
|
||||||
|
self.status = DataSourceStatusChoices.SYNCING
|
||||||
|
DataSource.objects.filter(pk=self.pk).update(status=self.status)
|
||||||
|
|
||||||
|
# Replicate source data locally
|
||||||
|
backend = self.get_backend()
|
||||||
|
with backend.fetch() as local_path:
|
||||||
|
|
||||||
|
logger.debug(f'Syncing files from source root {local_path}')
|
||||||
|
data_files = self.datafiles.all()
|
||||||
|
known_paths = {df.path for df in data_files}
|
||||||
|
logger.debug(f'Starting with {len(known_paths)} known files')
|
||||||
|
|
||||||
|
# Check for any updated/deleted files
|
||||||
|
updated_files = []
|
||||||
|
deleted_file_ids = []
|
||||||
|
for datafile in data_files:
|
||||||
|
|
||||||
|
try:
|
||||||
|
if datafile.refresh_from_disk(source_root=local_path):
|
||||||
|
updated_files.append(datafile)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# File no longer exists
|
||||||
|
deleted_file_ids.append(datafile.pk)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Bulk update modified files
|
||||||
|
updated_count = DataFile.objects.bulk_update(updated_files, ('last_updated', 'size', 'hash', 'data'))
|
||||||
|
logger.debug(f"Updated {updated_count} files")
|
||||||
|
|
||||||
|
# Bulk delete deleted files
|
||||||
|
deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete()
|
||||||
|
logger.debug(f"Deleted {updated_count} files")
|
||||||
|
|
||||||
|
# Walk the local replication to find new files
|
||||||
|
new_paths = self._walk(local_path) - known_paths
|
||||||
|
|
||||||
|
# Bulk create new files
|
||||||
|
new_datafiles = []
|
||||||
|
for path in new_paths:
|
||||||
|
datafile = DataFile(source=self, path=path)
|
||||||
|
datafile.refresh_from_disk(source_root=local_path)
|
||||||
|
datafile.full_clean()
|
||||||
|
new_datafiles.append(datafile)
|
||||||
|
created_count = len(DataFile.objects.bulk_create(new_datafiles, batch_size=100))
|
||||||
|
logger.debug(f"Created {created_count} data files")
|
||||||
|
|
||||||
|
# Update status & last_synced time
|
||||||
|
self.status = DataSourceStatusChoices.COMPLETED
|
||||||
|
self.last_synced = timezone.now()
|
||||||
|
DataSource.objects.filter(pk=self.pk).update(status=self.status, last_synced=self.last_synced)
|
||||||
|
|
||||||
|
# Emit the post_sync signal
|
||||||
|
post_sync.send(sender=self.__class__, instance=self)
|
||||||
|
|
||||||
|
def _walk(self, root):
|
||||||
|
"""
|
||||||
|
Return a set of all non-excluded files within the root path.
|
||||||
|
"""
|
||||||
|
logger.debug(f"Walking {root}...")
|
||||||
|
paths = set()
|
||||||
|
|
||||||
|
for path, dir_names, file_names in os.walk(root):
|
||||||
|
path = path.split(root)[1].lstrip('/') # Strip root path
|
||||||
|
if path.startswith('.'):
|
||||||
|
continue
|
||||||
|
for file_name in file_names:
|
||||||
|
if not self._ignore(file_name):
|
||||||
|
paths.add(os.path.join(path, file_name))
|
||||||
|
|
||||||
|
logger.debug(f"Found {len(paths)} files")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def _ignore(self, filename):
|
||||||
|
"""
|
||||||
|
Returns a boolean indicating whether the file should be ignored per the DataSource's configured
|
||||||
|
ignore rules.
|
||||||
|
"""
|
||||||
|
if filename.startswith('.'):
|
||||||
|
return True
|
||||||
|
for rule in self.ignore_rules.splitlines():
|
||||||
|
if fnmatchcase(filename, rule):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DataFile(models.Model):
|
||||||
|
"""
|
||||||
|
The database representation of a remote file fetched from a remote DataSource. DataFile instances should be created,
|
||||||
|
updated, or deleted only by calling DataSource.sync().
|
||||||
|
"""
|
||||||
|
created = models.DateTimeField(
|
||||||
|
auto_now_add=True
|
||||||
|
)
|
||||||
|
last_updated = models.DateTimeField(
|
||||||
|
editable=False
|
||||||
|
)
|
||||||
|
source = models.ForeignKey(
|
||||||
|
to='core.DataSource',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='datafiles',
|
||||||
|
editable=False
|
||||||
|
)
|
||||||
|
path = models.CharField(
|
||||||
|
max_length=1000,
|
||||||
|
editable=False,
|
||||||
|
help_text=_("File path relative to the data source's root")
|
||||||
|
)
|
||||||
|
size = models.PositiveIntegerField(
|
||||||
|
editable=False
|
||||||
|
)
|
||||||
|
hash = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
editable=False,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters."))
|
||||||
|
],
|
||||||
|
help_text=_("SHA256 hash of the file data")
|
||||||
|
)
|
||||||
|
data = models.BinaryField()
|
||||||
|
|
||||||
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('source', 'path')
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('source', 'path'),
|
||||||
|
name='%(app_label)s_%(class)s_unique_source_path'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('core:datafile', args=[self.pk])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_as_string(self):
|
||||||
|
try:
|
||||||
|
return self.data.tobytes().decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
"""
|
||||||
|
Attempt to read the file data as JSON/YAML and return a native Python object.
|
||||||
|
"""
|
||||||
|
# TODO: Something more robust
|
||||||
|
return yaml.safe_load(self.data_as_string)
|
||||||
|
|
||||||
|
def refresh_from_disk(self, source_root):
|
||||||
|
"""
|
||||||
|
Update instance attributes from the file on disk. Returns True if any attribute
|
||||||
|
has changed.
|
||||||
|
"""
|
||||||
|
file_path = os.path.join(source_root, self.path)
|
||||||
|
file_hash = sha256_hash(file_path).hexdigest()
|
||||||
|
|
||||||
|
# Update instance file attributes & data
|
||||||
|
if is_modified := file_hash != self.hash:
|
||||||
|
self.last_updated = timezone.now()
|
||||||
|
self.size = os.path.getsize(file_path)
|
||||||
|
self.hash = file_hash
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
self.data = f.read()
|
||||||
|
|
||||||
|
return is_modified
|
21
netbox/core/search.py
Normal file
21
netbox/core/search.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from netbox.search import SearchIndex, register_search
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@register_search
|
||||||
|
class DataSourceIndex(SearchIndex):
|
||||||
|
model = models.DataSource
|
||||||
|
fields = (
|
||||||
|
('name', 100),
|
||||||
|
('source_url', 300),
|
||||||
|
('description', 500),
|
||||||
|
('comments', 5000),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_search
|
||||||
|
class DataFileIndex(SearchIndex):
|
||||||
|
model = models.DataFile
|
||||||
|
fields = (
|
||||||
|
('path', 200),
|
||||||
|
)
|
10
netbox/core/signals.py
Normal file
10
netbox/core/signals.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import django.dispatch
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'post_sync',
|
||||||
|
'pre_sync',
|
||||||
|
)
|
||||||
|
|
||||||
|
# DataSource signals
|
||||||
|
pre_sync = django.dispatch.Signal()
|
||||||
|
post_sync = django.dispatch.Signal()
|
1
netbox/core/tables/__init__.py
Normal file
1
netbox/core/tables/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .data import *
|
52
netbox/core/tables/data.py
Normal file
52
netbox/core/tables/data.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import django_tables2 as tables
|
||||||
|
|
||||||
|
from core.models import *
|
||||||
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'DataFileTable',
|
||||||
|
'DataSourceTable',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceTable(NetBoxTable):
|
||||||
|
name = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
type = columns.ChoiceFieldColumn()
|
||||||
|
status = columns.ChoiceFieldColumn()
|
||||||
|
enabled = columns.BooleanColumn()
|
||||||
|
tags = columns.TagColumn(
|
||||||
|
url_name='core:datasource_list'
|
||||||
|
)
|
||||||
|
file_count = tables.Column(
|
||||||
|
verbose_name='Files'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = DataSource
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
|
||||||
|
'last_updated', 'file_count',
|
||||||
|
)
|
||||||
|
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileTable(NetBoxTable):
|
||||||
|
source = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
path = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
last_updated = columns.DateTimeColumn()
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
actions=('delete',)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = DataFile
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||||
|
)
|
||||||
|
default_columns = ('pk', 'source', 'path', 'size', 'last_updated')
|
0
netbox/core/tests/__init__.py
Normal file
0
netbox/core/tests/__init__.py
Normal file
93
netbox/core/tests/test_api.py
Normal file
93
netbox/core/tests/test_api.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from utilities.testing import APITestCase, APIViewTestCases
|
||||||
|
from ..choices import *
|
||||||
|
from ..models import *
|
||||||
|
|
||||||
|
|
||||||
|
class AppTest(APITestCase):
|
||||||
|
|
||||||
|
def test_root(self):
|
||||||
|
url = reverse('core-api:api-root')
|
||||||
|
response = self.client.get('{}?format=api'.format(url), **self.header)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceTest(APIViewTestCases.APIViewTestCase):
|
||||||
|
model = DataSource
|
||||||
|
brief_fields = ['display', 'id', 'name', 'url']
|
||||||
|
bulk_update_data = {
|
||||||
|
'enabled': False,
|
||||||
|
'description': 'foo bar baz',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
data_sources = (
|
||||||
|
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
||||||
|
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
||||||
|
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
||||||
|
)
|
||||||
|
DataSource.objects.bulk_create(data_sources)
|
||||||
|
|
||||||
|
cls.create_data = [
|
||||||
|
{
|
||||||
|
'name': 'Data Source 4',
|
||||||
|
'type': DataSourceTypeChoices.GIT,
|
||||||
|
'source_url': 'https://example.com/git/source4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Data Source 5',
|
||||||
|
'type': DataSourceTypeChoices.GIT,
|
||||||
|
'source_url': 'https://example.com/git/source5'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Data Source 6',
|
||||||
|
'type': DataSourceTypeChoices.GIT,
|
||||||
|
'source_url': 'https://example.com/git/source6'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileTest(
|
||||||
|
APIViewTestCases.GetObjectViewTestCase,
|
||||||
|
APIViewTestCases.ListObjectsViewTestCase,
|
||||||
|
APIViewTestCases.GraphQLTestCase
|
||||||
|
):
|
||||||
|
model = DataFile
|
||||||
|
brief_fields = ['display', 'id', 'path', 'url']
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
datasource = DataSource.objects.create(
|
||||||
|
name='Data Source 1',
|
||||||
|
type=DataSourceTypeChoices.LOCAL,
|
||||||
|
source_url='file:///var/tmp/source1/'
|
||||||
|
)
|
||||||
|
|
||||||
|
data_files = (
|
||||||
|
DataFile(
|
||||||
|
source=datasource,
|
||||||
|
path='dir1/file1.txt',
|
||||||
|
last_updated=timezone.now(),
|
||||||
|
size=1000,
|
||||||
|
hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
|
||||||
|
),
|
||||||
|
DataFile(
|
||||||
|
source=datasource,
|
||||||
|
path='dir1/file2.txt',
|
||||||
|
last_updated=timezone.now(),
|
||||||
|
size=2000,
|
||||||
|
hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
|
||||||
|
),
|
||||||
|
DataFile(
|
||||||
|
source=datasource,
|
||||||
|
path='dir1/file3.txt',
|
||||||
|
last_updated=timezone.now(),
|
||||||
|
size=3000,
|
||||||
|
hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DataFile.objects.bulk_create(data_files)
|
120
netbox/core/tests/test_filtersets.py
Normal file
120
netbox/core/tests/test_filtersets.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from utilities.testing import ChangeLoggedFilterSetTests
|
||||||
|
from ..choices import *
|
||||||
|
from ..filtersets import *
|
||||||
|
from ..models import *
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
filterset = DataSourceFilterSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
data_sources = (
|
||||||
|
DataSource(
|
||||||
|
name='Data Source 1',
|
||||||
|
type=DataSourceTypeChoices.LOCAL,
|
||||||
|
source_url='file:///var/tmp/source1/',
|
||||||
|
status=DataSourceStatusChoices.NEW,
|
||||||
|
enabled=True
|
||||||
|
),
|
||||||
|
DataSource(
|
||||||
|
name='Data Source 2',
|
||||||
|
type=DataSourceTypeChoices.LOCAL,
|
||||||
|
source_url='file:///var/tmp/source2/',
|
||||||
|
status=DataSourceStatusChoices.SYNCING,
|
||||||
|
enabled=True
|
||||||
|
),
|
||||||
|
DataSource(
|
||||||
|
name='Data Source 3',
|
||||||
|
type=DataSourceTypeChoices.GIT,
|
||||||
|
source_url='https://example.com/git/source3',
|
||||||
|
status=DataSourceStatusChoices.COMPLETED,
|
||||||
|
enabled=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DataSource.objects.bulk_create(data_sources)
|
||||||
|
|
||||||
|
def test_name(self):
|
||||||
|
params = {'name': ['Data Source 1', 'Data Source 2']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_type(self):
|
||||||
|
params = {'type': [DataSourceTypeChoices.LOCAL]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_enabled(self):
|
||||||
|
params = {'enabled': 'true'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'enabled': 'false'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_status(self):
|
||||||
|
params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
|
queryset = DataFile.objects.all()
|
||||||
|
filterset = DataFileFilterSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
data_sources = (
|
||||||
|
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
||||||
|
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
||||||
|
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
||||||
|
)
|
||||||
|
DataSource.objects.bulk_create(data_sources)
|
||||||
|
|
||||||
|
data_files = (
|
||||||
|
DataFile(
|
||||||
|
source=data_sources[0],
|
||||||
|
path='dir1/file1.txt',
|
||||||
|
last_updated=datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
size=1000,
|
||||||
|
hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
|
||||||
|
),
|
||||||
|
DataFile(
|
||||||
|
source=data_sources[1],
|
||||||
|
path='dir1/file2.txt',
|
||||||
|
last_updated=datetime(2023, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
size=2000,
|
||||||
|
hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
|
||||||
|
),
|
||||||
|
DataFile(
|
||||||
|
source=data_sources[2],
|
||||||
|
path='dir1/file3.txt',
|
||||||
|
last_updated=datetime(2023, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
size=3000,
|
||||||
|
hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DataFile.objects.bulk_create(data_files)
|
||||||
|
|
||||||
|
def test_source(self):
|
||||||
|
sources = DataSource.objects.all()
|
||||||
|
params = {'source_id': [sources[0].pk, sources[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'source': [sources[0].name, sources[1].name]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_path(self):
|
||||||
|
params = {'path': ['dir1/file1.txt', 'dir1/file2.txt']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_size(self):
|
||||||
|
params = {'size': [1000, 2000]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_hash(self):
|
||||||
|
params = {'hash': [
|
||||||
|
'442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1',
|
||||||
|
'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
|
||||||
|
]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
90
netbox/core/tests/test_views.py
Normal file
90
netbox/core/tests/test_views.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from utilities.testing import ViewTestCases, create_tags
|
||||||
|
from ..choices import *
|
||||||
|
from ..models import *
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
|
model = DataSource
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
data_sources = (
|
||||||
|
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
||||||
|
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
||||||
|
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
||||||
|
)
|
||||||
|
DataSource.objects.bulk_create(data_sources)
|
||||||
|
|
||||||
|
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||||
|
|
||||||
|
cls.form_data = {
|
||||||
|
'name': 'Data Source X',
|
||||||
|
'type': DataSourceTypeChoices.GIT,
|
||||||
|
'source_url': 'http:///exmaple/com/foo/bar/',
|
||||||
|
'description': 'Something',
|
||||||
|
'comments': 'Foo bar baz',
|
||||||
|
'tags': [t.pk for t in tags],
|
||||||
|
}
|
||||||
|
|
||||||
|
cls.csv_data = (
|
||||||
|
f"name,type,source_url,enabled",
|
||||||
|
f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
|
||||||
|
f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
|
||||||
|
f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false",
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.csv_update_data = (
|
||||||
|
"id,name,description",
|
||||||
|
f"{data_sources[0].pk},Data Source 7,New description7",
|
||||||
|
f"{data_sources[1].pk},Data Source 8,New description8",
|
||||||
|
f"{data_sources[2].pk},Data Source 9,New description9",
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.bulk_edit_data = {
|
||||||
|
'enabled': False,
|
||||||
|
'description': 'New description',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileTestCase(
|
||||||
|
ViewTestCases.GetObjectViewTestCase,
|
||||||
|
ViewTestCases.DeleteObjectViewTestCase,
|
||||||
|
ViewTestCases.ListObjectsViewTestCase,
|
||||||
|
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
||||||
|
):
|
||||||
|
model = DataFile
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
datasource = DataSource.objects.create(
|
||||||
|
name='Data Source 1',
|
||||||
|
type=DataSourceTypeChoices.LOCAL,
|
||||||
|
source_url='file:///var/tmp/source1/'
|
||||||
|
)
|
||||||
|
|
||||||
|
data_files = (
|
||||||
|
DataFile(
|
||||||
|
source=datasource,
|
||||||
|
path='dir1/file1.txt',
|
||||||
|
last_updated=timezone.now(),
|
||||||
|
size=1000,
|
||||||
|
hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
|
||||||
|
),
|
||||||
|
DataFile(
|
||||||
|
source=datasource,
|
||||||
|
path='dir1/file2.txt',
|
||||||
|
last_updated=timezone.now(),
|
||||||
|
size=2000,
|
||||||
|
hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
|
||||||
|
),
|
||||||
|
DataFile(
|
||||||
|
source=datasource,
|
||||||
|
path='dir1/file3.txt',
|
||||||
|
last_updated=timezone.now(),
|
||||||
|
size=3000,
|
||||||
|
hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DataFile.objects.bulk_create(data_files)
|
22
netbox/core/urls.py
Normal file
22
netbox/core/urls.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from utilities.urls import get_model_urls
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'core'
|
||||||
|
urlpatterns = (
|
||||||
|
|
||||||
|
# Data sources
|
||||||
|
path('data-sources/', views.DataSourceListView.as_view(), name='datasource_list'),
|
||||||
|
path('data-sources/add/', views.DataSourceEditView.as_view(), name='datasource_add'),
|
||||||
|
path('data-sources/import/', views.DataSourceBulkImportView.as_view(), name='datasource_import'),
|
||||||
|
path('data-sources/edit/', views.DataSourceBulkEditView.as_view(), name='datasource_bulk_edit'),
|
||||||
|
path('data-sources/delete/', views.DataSourceBulkDeleteView.as_view(), name='datasource_bulk_delete'),
|
||||||
|
path('data-sources/<int:pk>/', include(get_model_urls('core', 'datasource'))),
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
path('data-files/', views.DataFileListView.as_view(), name='datafile_list'),
|
||||||
|
path('data-files/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'),
|
||||||
|
path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
|
||||||
|
|
||||||
|
)
|
118
netbox/core/views.py
Normal file
118
netbox/core/views.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
|
||||||
|
from netbox.views import generic
|
||||||
|
from netbox.views.generic.base import BaseObjectView
|
||||||
|
from utilities.utils import count_related
|
||||||
|
from utilities.views import register_model_view
|
||||||
|
from . import filtersets, forms, tables
|
||||||
|
from .models import *
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Data sources
|
||||||
|
#
|
||||||
|
|
||||||
|
class DataSourceListView(generic.ObjectListView):
|
||||||
|
queryset = DataSource.objects.annotate(
|
||||||
|
file_count=count_related(DataFile, 'source')
|
||||||
|
)
|
||||||
|
filterset = filtersets.DataSourceFilterSet
|
||||||
|
filterset_form = forms.DataSourceFilterForm
|
||||||
|
table = tables.DataSourceTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(DataSource)
|
||||||
|
class DataSourceView(generic.ObjectView):
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
related_models = (
|
||||||
|
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'related_models': related_models,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(DataSource, 'sync')
|
||||||
|
class DataSourceSyncView(BaseObjectView):
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
|
||||||
|
def get_required_permission(self):
|
||||||
|
return 'core.sync_datasource'
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
# Redirect GET requests to the object view
|
||||||
|
datasource = get_object_or_404(self.queryset, pk=pk)
|
||||||
|
return redirect(datasource.get_absolute_url())
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
datasource = get_object_or_404(self.queryset, pk=pk)
|
||||||
|
job_result = datasource.enqueue_sync_job(request)
|
||||||
|
|
||||||
|
messages.success(request, f"Queued job #{job_result.pk} to sync {datasource}")
|
||||||
|
return redirect(datasource.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(DataSource, 'edit')
|
||||||
|
class DataSourceEditView(generic.ObjectEditView):
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
form = forms.DataSourceForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(DataSource, 'delete')
|
||||||
|
class DataSourceDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceBulkImportView(generic.BulkImportView):
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
model_form = forms.DataSourceImportForm
|
||||||
|
table = tables.DataSourceTable
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceBulkEditView(generic.BulkEditView):
|
||||||
|
queryset = DataSource.objects.annotate(
|
||||||
|
count_files=count_related(DataFile, 'source')
|
||||||
|
)
|
||||||
|
filterset = filtersets.DataSourceFilterSet
|
||||||
|
table = tables.DataSourceTable
|
||||||
|
form = forms.DataSourceBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = DataSource.objects.annotate(
|
||||||
|
count_files=count_related(DataFile, 'source')
|
||||||
|
)
|
||||||
|
filterset = filtersets.DataSourceFilterSet
|
||||||
|
table = tables.DataSourceTable
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Data files
|
||||||
|
#
|
||||||
|
|
||||||
|
class DataFileListView(generic.ObjectListView):
|
||||||
|
queryset = DataFile.objects.defer('data')
|
||||||
|
filterset = filtersets.DataFileFilterSet
|
||||||
|
filterset_form = forms.DataFileFilterForm
|
||||||
|
table = tables.DataFileTable
|
||||||
|
actions = ('bulk_delete',)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(DataFile)
|
||||||
|
class DataFileView(generic.ObjectView):
|
||||||
|
queryset = DataFile.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(DataFile, 'delete')
|
||||||
|
class DataFileDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = DataFile.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = DataFile.objects.defer('data')
|
||||||
|
filterset = filtersets.DataFileFilterSet
|
||||||
|
table = tables.DataFileTable
|
@ -309,6 +309,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
|||||||
class DeviceTypeSerializer(NetBoxModelSerializer):
|
class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||||
manufacturer = NestedManufacturerSerializer()
|
manufacturer = NestedManufacturerSerializer()
|
||||||
|
default_platform = NestedPlatformSerializer(required=False, allow_null=True)
|
||||||
u_height = serializers.DecimalField(
|
u_height = serializers.DecimalField(
|
||||||
max_digits=4,
|
max_digits=4,
|
||||||
decimal_places=1,
|
decimal_places=1,
|
||||||
@ -324,7 +325,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||||
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
|
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
|
||||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
||||||
]
|
]
|
||||||
|
@ -436,6 +436,16 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label=_('Manufacturer (slug)'),
|
label=_('Manufacturer (slug)'),
|
||||||
)
|
)
|
||||||
|
default_platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
label=_('Default platform (ID)'),
|
||||||
|
)
|
||||||
|
default_platform = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='default_platform__slug',
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Default platform (slug)'),
|
||||||
|
)
|
||||||
has_front_image = django_filters.BooleanFilter(
|
has_front_image = django_filters.BooleanFilter(
|
||||||
label=_('Has a front image'),
|
label=_('Has a front image'),
|
||||||
method='_has_front_image'
|
method='_has_front_image'
|
||||||
|
@ -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, SelectSpeedWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -96,8 +96,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(SiteStatusChoices),
|
choices=add_blank_choice(SiteStatusChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
region = DynamicModelChoiceField(
|
region = DynamicModelChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
@ -130,15 +129,14 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
time_zone = TimeZoneFormField(
|
time_zone = TimeZoneFormField(
|
||||||
choices=add_blank_choice(TimeZoneFormField().choices),
|
choices=add_blank_choice(TimeZoneFormField().choices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -166,8 +164,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(LocationStatusChoices),
|
choices=add_blank_choice(LocationStatusChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -238,8 +235,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(RackStatusChoices),
|
choices=add_blank_choice(RackStatusChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
queryset=RackRole.objects.all(),
|
queryset=RackRole.objects.all(),
|
||||||
@ -256,13 +252,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(RackTypeChoices),
|
choices=add_blank_choice(RackTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
width = forms.ChoiceField(
|
width = forms.ChoiceField(
|
||||||
choices=add_blank_choice(RackWidthChoices),
|
choices=add_blank_choice(RackWidthChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
u_height = forms.IntegerField(
|
u_height = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -283,8 +277,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
outer_unit = forms.ChoiceField(
|
outer_unit = forms.ChoiceField(
|
||||||
choices=add_blank_choice(RackDimensionUnitChoices),
|
choices=add_blank_choice(RackDimensionUnitChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
mounting_depth = forms.IntegerField(
|
mounting_depth = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -301,15 +294,14 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
weight_unit = forms.ChoiceField(
|
weight_unit = forms.ChoiceField(
|
||||||
choices=add_blank_choice(WeightUnitChoices),
|
choices=add_blank_choice(WeightUnitChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -333,8 +325,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
queryset=User.objects.order_by(
|
queryset=User.objects.order_by(
|
||||||
'username'
|
'username'
|
||||||
),
|
),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -345,7 +336,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -374,6 +365,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
default_platform = DynamicModelChoiceField(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
part_number = forms.CharField(
|
part_number = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -388,8 +383,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
airflow = forms.ChoiceField(
|
airflow = forms.ChoiceField(
|
||||||
choices=add_blank_choice(DeviceAirflowChoices),
|
choices=add_blank_choice(DeviceAirflowChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
weight = forms.DecimalField(
|
weight = forms.DecimalField(
|
||||||
min_value=0,
|
min_value=0,
|
||||||
@ -398,21 +392,20 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
weight_unit = forms.ChoiceField(
|
weight_unit = forms.ChoiceField(
|
||||||
choices=add_blank_choice(WeightUnitChoices),
|
choices=add_blank_choice(WeightUnitChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
|
('Device Type', ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
|
||||||
('Weight', ('weight', 'weight_unit')),
|
('Weight', ('weight', 'weight_unit')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
||||||
@ -433,15 +426,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
weight_unit = forms.ChoiceField(
|
weight_unit = forms.ChoiceField(
|
||||||
choices=add_blank_choice(WeightUnitChoices),
|
choices=add_blank_choice(WeightUnitChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -528,13 +520,11 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(DeviceStatusChoices),
|
choices=add_blank_choice(DeviceStatusChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
airflow = forms.ChoiceField(
|
airflow = forms.ChoiceField(
|
||||||
choices=add_blank_choice(DeviceAirflowChoices),
|
choices=add_blank_choice(DeviceAirflowChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
serial = forms.CharField(
|
serial = forms.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -546,7 +536,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -576,8 +566,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(ModuleStatusChoices),
|
choices=add_blank_choice(ModuleStatusChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
serial = forms.CharField(
|
serial = forms.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -589,7 +578,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -604,13 +593,11 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(CableTypeChoices),
|
choices=add_blank_choice(CableTypeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(LinkStatusChoices),
|
choices=add_blank_choice(LinkStatusChoices),
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(),
|
|
||||||
initial=''
|
initial=''
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
@ -631,15 +618,14 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
length_unit = forms.ChoiceField(
|
length_unit = forms.ChoiceField(
|
||||||
choices=add_blank_choice(CableLengthUnitChoices),
|
choices=add_blank_choice(CableLengthUnitChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -663,7 +649,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -709,7 +695,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -732,26 +718,22 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerFeedStatusChoices),
|
choices=add_blank_choice(PowerFeedStatusChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerFeedTypeChoices),
|
choices=add_blank_choice(PowerFeedTypeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
supply = forms.ChoiceField(
|
supply = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerFeedSupplyChoices),
|
choices=add_blank_choice(PowerFeedSupplyChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
phase = forms.ChoiceField(
|
phase = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerFeedPhaseChoices),
|
choices=add_blank_choice(PowerFeedPhaseChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
voltage = forms.IntegerField(
|
voltage = forms.IntegerField(
|
||||||
required=False
|
required=False
|
||||||
@ -771,7 +753,7 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label=_('Comments')
|
label=_('Comments')
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -798,8 +780,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(ConsolePortTypeChoices),
|
choices=add_blank_choice(ConsolePortTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
nullable_fields = ('label', 'type', 'description')
|
nullable_fields = ('label', 'type', 'description')
|
||||||
@ -816,8 +797,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(ConsolePortTypeChoices),
|
choices=add_blank_choice(ConsolePortTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
@ -837,8 +817,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerPortTypeChoices),
|
choices=add_blank_choice(PowerPortTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
maximum_draw = forms.IntegerField(
|
maximum_draw = forms.IntegerField(
|
||||||
min_value=1,
|
min_value=1,
|
||||||
@ -874,8 +853,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerOutletTypeChoices),
|
choices=add_blank_choice(PowerOutletTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
power_port = forms.ModelChoiceField(
|
power_port = forms.ModelChoiceField(
|
||||||
queryset=PowerPortTemplate.objects.all(),
|
queryset=PowerPortTemplate.objects.all(),
|
||||||
@ -883,8 +861,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
feed_leg = forms.ChoiceField(
|
feed_leg = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerOutletFeedLegChoices),
|
choices=add_blank_choice(PowerOutletFeedLegChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
@ -915,8 +892,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(InterfaceTypeChoices),
|
choices=add_blank_choice(InterfaceTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -934,14 +910,12 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
|
|||||||
choices=add_blank_choice(InterfacePoEModeChoices),
|
choices=add_blank_choice(InterfacePoEModeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial='',
|
||||||
widget=StaticSelect(),
|
|
||||||
label=_('PoE mode')
|
label=_('PoE mode')
|
||||||
)
|
)
|
||||||
poe_type = forms.ChoiceField(
|
poe_type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(InterfacePoETypeChoices),
|
choices=add_blank_choice(InterfacePoETypeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial='',
|
||||||
widget=StaticSelect(),
|
|
||||||
label=_('PoE type')
|
label=_('PoE type')
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -959,8 +933,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PortTypeChoices),
|
choices=add_blank_choice(PortTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
color = ColorField(
|
color = ColorField(
|
||||||
required=False
|
required=False
|
||||||
@ -983,8 +956,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PortTypeChoices),
|
choices=add_blank_choice(PortTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
color = ColorField(
|
color = ColorField(
|
||||||
required=False
|
required=False
|
||||||
@ -1199,14 +1171,12 @@ class InterfaceBulkEditForm(
|
|||||||
choices=add_blank_choice(InterfacePoEModeChoices),
|
choices=add_blank_choice(InterfacePoEModeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial='',
|
||||||
widget=StaticSelect(),
|
|
||||||
label=_('PoE mode')
|
label=_('PoE mode')
|
||||||
)
|
)
|
||||||
poe_type = forms.ChoiceField(
|
poe_type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(InterfacePoETypeChoices),
|
choices=add_blank_choice(InterfacePoETypeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial='',
|
||||||
widget=StaticSelect(),
|
|
||||||
label=_('PoE type')
|
label=_('PoE type')
|
||||||
)
|
)
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
@ -1216,8 +1186,7 @@ class InterfaceBulkEditForm(
|
|||||||
mode = forms.ChoiceField(
|
mode = forms.ChoiceField(
|
||||||
choices=add_blank_choice(InterfaceModeChoices),
|
choices=add_blank_choice(InterfaceModeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
vlan_group = DynamicModelChoiceField(
|
vlan_group = DynamicModelChoiceField(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
@ -1417,8 +1386,7 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(VirtualDeviceContextStatusChoices),
|
choices=add_blank_choice(VirtualDeviceContextStatusChoices)
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
|
@ -281,12 +281,17 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
|||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
to_field_name='name'
|
to_field_name='name'
|
||||||
)
|
)
|
||||||
|
default_platform = forms.ModelChoiceField(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
|
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||||
'description', 'comments',
|
'subdevice_role', 'airflow', 'description', 'comments',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,8 +10,8 @@ from ipam.models import ASN, L2VPN, VRF
|
|||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField,
|
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm,
|
||||||
StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
|
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
|
||||||
)
|
)
|
||||||
from wireless.choices import *
|
from wireless.choices import *
|
||||||
|
|
||||||
@ -150,7 +150,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
|||||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=SiteStatusChoices,
|
choices=SiteStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -208,7 +208,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
|||||||
},
|
},
|
||||||
label=_('Parent')
|
label=_('Parent')
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=LocationStatusChoices,
|
choices=LocationStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -258,15 +258,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
|||||||
},
|
},
|
||||||
label=_('Location')
|
label=_('Location')
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=RackStatusChoices,
|
choices=RackStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=RackTypeChoices,
|
choices=RackTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
width = MultipleChoiceField(
|
width = forms.MultipleChoiceField(
|
||||||
choices=RackWidthChoices,
|
choices=RackWidthChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -378,7 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
|||||||
model = DeviceType
|
model = DeviceType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
|
('Hardware', ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')),
|
||||||
('Images', ('has_front_image', 'has_rear_image')),
|
('Images', ('has_front_image', 'has_rear_image')),
|
||||||
('Components', (
|
('Components', (
|
||||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
||||||
@ -391,91 +391,96 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Manufacturer')
|
label=_('Manufacturer')
|
||||||
)
|
)
|
||||||
|
default_platform_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Default platform')
|
||||||
|
)
|
||||||
part_number = forms.CharField(
|
part_number = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
subdevice_role = MultipleChoiceField(
|
subdevice_role = forms.MultipleChoiceField(
|
||||||
choices=add_blank_choice(SubdeviceRoleChoices),
|
choices=add_blank_choice(SubdeviceRoleChoices),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
airflow = MultipleChoiceField(
|
airflow = forms.MultipleChoiceField(
|
||||||
choices=add_blank_choice(DeviceAirflowChoices),
|
choices=add_blank_choice(DeviceAirflowChoices),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
has_front_image = forms.NullBooleanField(
|
has_front_image = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has a front image',
|
label='Has a front image',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
has_rear_image = forms.NullBooleanField(
|
has_rear_image = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has a rear image',
|
label='Has a rear image',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
console_ports = forms.NullBooleanField(
|
console_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has console ports',
|
label='Has console ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
console_server_ports = forms.NullBooleanField(
|
console_server_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has console server ports',
|
label='Has console server ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
power_ports = forms.NullBooleanField(
|
power_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has power ports',
|
label='Has power ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
power_outlets = forms.NullBooleanField(
|
power_outlets = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has power outlets',
|
label='Has power outlets',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
interfaces = forms.NullBooleanField(
|
interfaces = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has interfaces',
|
label='Has interfaces',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pass_through_ports = forms.NullBooleanField(
|
pass_through_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has pass-through ports',
|
label='Has pass-through ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
device_bays = forms.NullBooleanField(
|
device_bays = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has device bays',
|
label='Has device bays',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
module_bays = forms.NullBooleanField(
|
module_bays = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has module bays',
|
label='Has module bays',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
inventory_items = forms.NullBooleanField(
|
inventory_items = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has inventory items',
|
label='Has inventory items',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -512,42 +517,42 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
|||||||
console_ports = forms.NullBooleanField(
|
console_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has console ports',
|
label='Has console ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
console_server_ports = forms.NullBooleanField(
|
console_server_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has console server ports',
|
label='Has console server ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
power_ports = forms.NullBooleanField(
|
power_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has power ports',
|
label='Has power ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
power_outlets = forms.NullBooleanField(
|
power_outlets = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has power outlets',
|
label='Has power outlets',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
interfaces = forms.NullBooleanField(
|
interfaces = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has interfaces',
|
label='Has interfaces',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pass_through_ports = forms.NullBooleanField(
|
pass_through_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has pass-through ports',
|
label='Has pass-through ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -657,11 +662,11 @@ class DeviceFilterForm(
|
|||||||
null_option='None',
|
null_option='None',
|
||||||
label=_('Platform')
|
label=_('Platform')
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=DeviceStatusChoices,
|
choices=DeviceStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
airflow = MultipleChoiceField(
|
airflow = forms.MultipleChoiceField(
|
||||||
choices=add_blank_choice(DeviceAirflowChoices),
|
choices=add_blank_choice(DeviceAirflowChoices),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -678,56 +683,56 @@ class DeviceFilterForm(
|
|||||||
has_primary_ip = forms.NullBooleanField(
|
has_primary_ip = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has a primary IP',
|
label='Has a primary IP',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
virtual_chassis_member = forms.NullBooleanField(
|
virtual_chassis_member = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Virtual chassis member',
|
label='Virtual chassis member',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
console_ports = forms.NullBooleanField(
|
console_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has console ports',
|
label='Has console ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
console_server_ports = forms.NullBooleanField(
|
console_server_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has console server ports',
|
label='Has console server ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
power_ports = forms.NullBooleanField(
|
power_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has power ports',
|
label='Has power ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
power_outlets = forms.NullBooleanField(
|
power_outlets = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has power outlets',
|
label='Has power outlets',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
interfaces = forms.NullBooleanField(
|
interfaces = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has interfaces',
|
label='Has interfaces',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pass_through_ports = forms.NullBooleanField(
|
pass_through_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has pass-through ports',
|
label='Has pass-through ports',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -750,14 +755,14 @@ class VirtualDeviceContextFilterForm(
|
|||||||
label=_('Device'),
|
label=_('Device'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(VirtualDeviceContextStatusChoices)
|
choices=add_blank_choice(VirtualDeviceContextStatusChoices)
|
||||||
)
|
)
|
||||||
has_primary_ip = forms.NullBooleanField(
|
has_primary_ip = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Has a primary IP',
|
label='Has a primary IP',
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -785,7 +790,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
|||||||
label=_('Type'),
|
label=_('Type'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=ModuleStatusChoices,
|
choices=ModuleStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -878,11 +883,11 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
},
|
},
|
||||||
label=_('Device')
|
label=_('Device')
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=add_blank_choice(CableTypeChoices),
|
choices=add_blank_choice(CableTypeChoices),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(LinkStatusChoices)
|
choices=add_blank_choice(LinkStatusChoices)
|
||||||
)
|
)
|
||||||
@ -980,24 +985,21 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
|
|||||||
},
|
},
|
||||||
label=_('Rack')
|
label=_('Rack')
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=PowerFeedStatusChoices,
|
choices=PowerFeedStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerFeedTypeChoices),
|
choices=add_blank_choice(PowerFeedTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
supply = forms.ChoiceField(
|
supply = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerFeedSupplyChoices),
|
choices=add_blank_choice(PowerFeedSupplyChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
phase = forms.ChoiceField(
|
phase = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PowerFeedPhaseChoices),
|
choices=add_blank_choice(PowerFeedPhaseChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
voltage = forms.IntegerField(
|
voltage = forms.IntegerField(
|
||||||
required=False
|
required=False
|
||||||
@ -1018,13 +1020,13 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
|
|||||||
class CabledFilterForm(forms.Form):
|
class CabledFilterForm(forms.Form):
|
||||||
cabled = forms.NullBooleanField(
|
cabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
occupied = forms.NullBooleanField(
|
occupied = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -1033,7 +1035,7 @@ class CabledFilterForm(forms.Form):
|
|||||||
class PathEndpointFilterForm(CabledFilterForm):
|
class PathEndpointFilterForm(CabledFilterForm):
|
||||||
connected = forms.NullBooleanField(
|
connected = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -1047,11 +1049,11 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||||
('Connection', ('cabled', 'connected', 'occupied')),
|
('Connection', ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=ConsolePortTypeChoices,
|
choices=ConsolePortTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
speed = MultipleChoiceField(
|
speed = forms.MultipleChoiceField(
|
||||||
choices=ConsolePortSpeedChoices,
|
choices=ConsolePortSpeedChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -1066,11 +1068,11 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
|||||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||||
('Connection', ('cabled', 'connected', 'occupied')),
|
('Connection', ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=ConsolePortTypeChoices,
|
choices=ConsolePortTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
speed = MultipleChoiceField(
|
speed = forms.MultipleChoiceField(
|
||||||
choices=ConsolePortSpeedChoices,
|
choices=ConsolePortSpeedChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -1085,7 +1087,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||||
('Connection', ('cabled', 'connected', 'occupied')),
|
('Connection', ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=PowerPortTypeChoices,
|
choices=PowerPortTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -1100,7 +1102,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||||
('Connection', ('cabled', 'connected', 'occupied')),
|
('Connection', ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=PowerOutletTypeChoices,
|
choices=PowerOutletTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -1127,11 +1129,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
},
|
},
|
||||||
label=_('Virtual Device Context')
|
label=_('Virtual Device Context')
|
||||||
)
|
)
|
||||||
kind = MultipleChoiceField(
|
kind = forms.MultipleChoiceField(
|
||||||
choices=InterfaceKindChoices,
|
choices=InterfaceKindChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=InterfaceTypeChoices,
|
choices=InterfaceTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -1140,19 +1142,19 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
label='Speed',
|
label='Speed',
|
||||||
widget=SelectSpeedWidget()
|
widget=SelectSpeedWidget()
|
||||||
)
|
)
|
||||||
duplex = MultipleChoiceField(
|
duplex = forms.MultipleChoiceField(
|
||||||
choices=InterfaceDuplexChoices,
|
choices=InterfaceDuplexChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
mgmt_only = forms.NullBooleanField(
|
mgmt_only = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -1164,22 +1166,22 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='WWN'
|
label='WWN'
|
||||||
)
|
)
|
||||||
poe_mode = MultipleChoiceField(
|
poe_mode = forms.MultipleChoiceField(
|
||||||
choices=InterfacePoEModeChoices,
|
choices=InterfacePoEModeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label='PoE mode'
|
label='PoE mode'
|
||||||
)
|
)
|
||||||
poe_type = MultipleChoiceField(
|
poe_type = forms.MultipleChoiceField(
|
||||||
choices=InterfacePoEModeChoices,
|
choices=InterfacePoETypeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label='PoE type'
|
label='PoE type'
|
||||||
)
|
)
|
||||||
rf_role = MultipleChoiceField(
|
rf_role = forms.MultipleChoiceField(
|
||||||
choices=WirelessRoleChoices,
|
choices=WirelessRoleChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label='Wireless role'
|
label='Wireless role'
|
||||||
)
|
)
|
||||||
rf_channel = MultipleChoiceField(
|
rf_channel = forms.MultipleChoiceField(
|
||||||
choices=WirelessChannelChoices,
|
choices=WirelessChannelChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label='Wireless channel'
|
label='Wireless channel'
|
||||||
@ -1219,7 +1221,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
('Cable', ('cabled', 'occupied')),
|
('Cable', ('cabled', 'occupied')),
|
||||||
)
|
)
|
||||||
model = FrontPort
|
model = FrontPort
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=PortTypeChoices,
|
choices=PortTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -1237,7 +1239,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||||
('Cable', ('cabled', 'occupied')),
|
('Cable', ('cabled', 'occupied')),
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=PortTypeChoices,
|
choices=PortTypeChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -1296,7 +1298,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
|||||||
)
|
)
|
||||||
discovered = forms.NullBooleanField(
|
discovered = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -12,8 +12,8 @@ from netbox.forms import NetBoxModelForm
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
|
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
|
||||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
|
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK,
|
||||||
SlugField, StaticSelect, SelectSpeedWidget,
|
SlugField, SelectSpeedWidget,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup
|
from virtualization.models import Cluster, ClusterGroup
|
||||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||||
@ -129,8 +129,7 @@ class SiteForm(TenancyForm, NetBoxModelForm):
|
|||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
time_zone = TimeZoneFormField(
|
time_zone = TimeZoneFormField(
|
||||||
choices=add_blank_choice(TimeZoneFormField().choices),
|
choices=add_blank_choice(TimeZoneFormField().choices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
@ -149,18 +148,16 @@ class SiteForm(TenancyForm, NetBoxModelForm):
|
|||||||
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
|
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
|
||||||
)
|
)
|
||||||
widgets = {
|
widgets = {
|
||||||
'physical_address': SmallTextarea(
|
'physical_address': forms.Textarea(
|
||||||
attrs={
|
attrs={
|
||||||
'rows': 3,
|
'rows': 3,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
'shipping_address': SmallTextarea(
|
'shipping_address': forms.Textarea(
|
||||||
attrs={
|
attrs={
|
||||||
'rows': 3,
|
'rows': 3,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
'status': StaticSelect(),
|
|
||||||
'time_zone': StaticSelect(),
|
|
||||||
}
|
}
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'name': _("Full name of the site"),
|
'name': _("Full name of the site"),
|
||||||
@ -218,9 +215,6 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
|||||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
|
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
|
||||||
'tags',
|
'tags',
|
||||||
)
|
)
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RackRoleForm(NetBoxModelForm):
|
class RackRoleForm(NetBoxModelForm):
|
||||||
@ -287,13 +281,6 @@ class RackForm(TenancyForm, NetBoxModelForm):
|
|||||||
'facility_id': _("The unique rack ID assigned by the facility"),
|
'facility_id': _("The unique rack ID assigned by the facility"),
|
||||||
'u_height': _("Height in rack units"),
|
'u_height': _("Height in rack units"),
|
||||||
}
|
}
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'width': StaticSelect(),
|
|
||||||
'outer_unit': StaticSelect(),
|
|
||||||
'weight_unit': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RackReservationForm(TenancyForm, NetBoxModelForm):
|
class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||||
@ -340,8 +327,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
|||||||
user = forms.ModelChoiceField(
|
user = forms.ModelChoiceField(
|
||||||
queryset=User.objects.order_by(
|
queryset=User.objects.order_by(
|
||||||
'username'
|
'username'
|
||||||
),
|
)
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
@ -378,13 +364,17 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||||||
manufacturer = DynamicModelChoiceField(
|
manufacturer = DynamicModelChoiceField(
|
||||||
queryset=Manufacturer.objects.all()
|
queryset=Manufacturer.objects.all()
|
||||||
)
|
)
|
||||||
|
default_platform = DynamicModelChoiceField(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
slug = SlugField(
|
slug = SlugField(
|
||||||
slug_source='model'
|
slug_source='model'
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags')),
|
('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags', 'default_platform')),
|
||||||
('Chassis', (
|
('Chassis', (
|
||||||
'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||||
)),
|
)),
|
||||||
@ -395,18 +385,15 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
|
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
|
||||||
'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags',
|
'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'default_platform'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'airflow': StaticSelect(),
|
|
||||||
'subdevice_role': StaticSelect(),
|
|
||||||
'front_image': ClearableFileInput(attrs={
|
'front_image': ClearableFileInput(attrs={
|
||||||
'accept': DEVICETYPE_IMAGE_FORMATS
|
'accept': DEVICETYPE_IMAGE_FORMATS
|
||||||
}),
|
}),
|
||||||
'rear_image': ClearableFileInput(attrs={
|
'rear_image': ClearableFileInput(attrs={
|
||||||
'accept': DEVICETYPE_IMAGE_FORMATS
|
'accept': DEVICETYPE_IMAGE_FORMATS
|
||||||
}),
|
}),
|
||||||
'weight_unit': StaticSelect(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -427,10 +414,6 @@ class ModuleTypeForm(NetBoxModelForm):
|
|||||||
'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags',
|
'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
widgets = {
|
|
||||||
'weight_unit': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceRoleForm(NetBoxModelForm):
|
class DeviceRoleForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
@ -594,13 +577,6 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
'local_context_data': _("Local config context data overwrites all source contexts in the final rendered "
|
'local_context_data': _("Local config context data overwrites all source contexts in the final rendered "
|
||||||
"config context"),
|
"config context"),
|
||||||
}
|
}
|
||||||
widgets = {
|
|
||||||
'face': StaticSelect(),
|
|
||||||
'status': StaticSelect(),
|
|
||||||
'airflow': StaticSelect(),
|
|
||||||
'primary_ip4': StaticSelect(),
|
|
||||||
'primary_ip6': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@ -734,11 +710,6 @@ class CableForm(TenancyForm, NetBoxModelForm):
|
|||||||
'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
||||||
'comments', 'tags',
|
'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect,
|
|
||||||
'type': StaticSelect,
|
|
||||||
'length_unit': StaticSelect,
|
|
||||||
}
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
'length': {
|
'length': {
|
||||||
'max_value': 'Maximum length is 32767 (any unit)'
|
'max_value': 'Maximum length is 32767 (any unit)'
|
||||||
@ -853,12 +824,6 @@ class PowerFeedForm(NetBoxModelForm):
|
|||||||
'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments',
|
'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments',
|
||||||
'tags',
|
'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'supply': StaticSelect(),
|
|
||||||
'phase': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -1022,9 +987,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device_type', 'module_type', 'name', 'label', 'type', 'description',
|
'device_type', 'module_type', 'name', 'label', 'type', 'description',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
|
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
|
||||||
@ -1037,9 +999,6 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device_type', 'module_type', 'name', 'label', 'type', 'description',
|
'device_type', 'module_type', 'name', 'label', 'type', 'description',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PowerPortTemplateForm(ModularComponentTemplateForm):
|
class PowerPortTemplateForm(ModularComponentTemplateForm):
|
||||||
@ -1054,9 +1013,6 @@ class PowerPortTemplateForm(ModularComponentTemplateForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletTemplateForm(ModularComponentTemplateForm):
|
class PowerOutletTemplateForm(ModularComponentTemplateForm):
|
||||||
@ -1077,10 +1033,6 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'feed_leg': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateForm(ModularComponentTemplateForm):
|
class InterfaceTemplateForm(ModularComponentTemplateForm):
|
||||||
@ -1094,11 +1046,6 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type',
|
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'poe_mode': StaticSelect(),
|
|
||||||
'poe_type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTemplateForm(ModularComponentTemplateForm):
|
class FrontPortTemplateForm(ModularComponentTemplateForm):
|
||||||
@ -1124,9 +1071,6 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
|
|||||||
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
|
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
|
||||||
'description',
|
'description',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RearPortTemplateForm(ModularComponentTemplateForm):
|
class RearPortTemplateForm(ModularComponentTemplateForm):
|
||||||
@ -1139,9 +1083,6 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
|
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleBayTemplateForm(ComponentTemplateForm):
|
class ModuleBayTemplateForm(ComponentTemplateForm):
|
||||||
@ -1249,10 +1190,6 @@ class ConsolePortForm(ModularDeviceComponentForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'speed': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortForm(ModularDeviceComponentForm):
|
class ConsoleServerPortForm(ModularDeviceComponentForm):
|
||||||
@ -1268,10 +1205,6 @@ class ConsoleServerPortForm(ModularDeviceComponentForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'speed': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PowerPortForm(ModularDeviceComponentForm):
|
class PowerPortForm(ModularDeviceComponentForm):
|
||||||
@ -1289,9 +1222,6 @@ class PowerPortForm(ModularDeviceComponentForm):
|
|||||||
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
|
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
|
||||||
'description', 'tags',
|
'description', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletForm(ModularDeviceComponentForm):
|
class PowerOutletForm(ModularDeviceComponentForm):
|
||||||
@ -1316,10 +1246,6 @@ class PowerOutletForm(ModularDeviceComponentForm):
|
|||||||
'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
|
'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
|
||||||
'tags',
|
'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'feed_leg': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||||
@ -1424,14 +1350,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
|||||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'type': StaticSelect(),
|
|
||||||
'speed': SelectSpeedWidget(),
|
'speed': SelectSpeedWidget(),
|
||||||
'poe_mode': StaticSelect(),
|
|
||||||
'poe_type': StaticSelect(),
|
|
||||||
'duplex': StaticSelect(),
|
|
||||||
'mode': StaticSelect(),
|
|
||||||
'rf_role': StaticSelect(),
|
|
||||||
'rf_channel': StaticSelect(),
|
|
||||||
}
|
}
|
||||||
labels = {
|
labels = {
|
||||||
'mode': '802.1Q Mode',
|
'mode': '802.1Q Mode',
|
||||||
@ -1464,9 +1383,6 @@ class FrontPortForm(ModularDeviceComponentForm):
|
|||||||
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
|
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
|
||||||
'description', 'tags',
|
'description', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RearPortForm(ModularDeviceComponentForm):
|
class RearPortForm(ModularDeviceComponentForm):
|
||||||
@ -1481,9 +1397,6 @@ class RearPortForm(ModularDeviceComponentForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
|
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleBayForm(DeviceComponentForm):
|
class ModuleBayForm(DeviceComponentForm):
|
||||||
@ -1514,8 +1427,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
|||||||
installed_device = forms.ModelChoiceField(
|
installed_device = forms.ModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
label=_('Child Device'),
|
label=_('Child Device'),
|
||||||
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device."),
|
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.")
|
||||||
widget=StaticSelect(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, device_bay, *args, **kwargs):
|
def __init__(self, device_bay, *args, **kwargs):
|
||||||
@ -1764,8 +1676,3 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
|
|||||||
'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier',
|
'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier',
|
||||||
'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags'
|
'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags'
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
'primary_ip4': StaticSelect(),
|
|
||||||
'primary_ip6': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
19
netbox/dcim/migrations/0169_devicetype_default_platform.py
Normal file
19
netbox/dcim/migrations/0169_devicetype_default_platform.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 4.1.6 on 2023-02-10 18:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0168_interface_template_enabled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='devicetype',
|
||||||
|
name='default_platform',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.platform'),
|
||||||
|
),
|
||||||
|
]
|
@ -9,7 +9,6 @@ from mptt.models import MPTTModel, TreeForeignKey
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import WebhooksMixin
|
|
||||||
from utilities.fields import ColorField, NaturalOrderingField
|
from utilities.fields import ColorField, NaturalOrderingField
|
||||||
from utilities.mptt import TreeManager
|
from utilities.mptt import TreeManager
|
||||||
from utilities.ordering import naturalize_interface
|
from utilities.ordering import naturalize_interface
|
||||||
@ -33,7 +32,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
|
class ComponentTemplateModel(ChangeLoggedModel):
|
||||||
device_type = models.ForeignKey(
|
device_type = models.ForeignKey(
|
||||||
to='dcim.DeviceType',
|
to='dcim.DeviceType',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
@ -82,6 +82,14 @@ class DeviceType(PrimaryModel, WeightMixin):
|
|||||||
slug = models.SlugField(
|
slug = models.SlugField(
|
||||||
max_length=100
|
max_length=100
|
||||||
)
|
)
|
||||||
|
default_platform = models.ForeignKey(
|
||||||
|
to='dcim.Platform',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='+',
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name='Default platform'
|
||||||
|
)
|
||||||
part_number = models.CharField(
|
part_number = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -121,7 +129,7 @@ class DeviceType(PrimaryModel, WeightMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||||
)
|
)
|
||||||
prerequisite_models = (
|
prerequisite_models = (
|
||||||
'dcim.Manufacturer',
|
'dcim.Manufacturer',
|
||||||
@ -165,6 +173,7 @@ class DeviceType(PrimaryModel, WeightMixin):
|
|||||||
'manufacturer': self.manufacturer.name,
|
'manufacturer': self.manufacturer.name,
|
||||||
'model': self.model,
|
'model': self.model,
|
||||||
'slug': self.slug,
|
'slug': self.slug,
|
||||||
|
'default_platform': self.default_platform.name if self.default_platform else None,
|
||||||
'part_number': self.part_number,
|
'part_number': self.part_number,
|
||||||
'u_height': float(self.u_height),
|
'u_height': float(self.u_height),
|
||||||
'is_full_depth': self.is_full_depth,
|
'is_full_depth': self.is_full_depth,
|
||||||
@ -801,6 +810,10 @@ class Device(PrimaryModel, ConfigContextModel):
|
|||||||
if is_new and not self.airflow:
|
if is_new and not self.airflow:
|
||||||
self.airflow = self.device_type.airflow
|
self.airflow = self.device_type.airflow
|
||||||
|
|
||||||
|
# Inherit default_platform from DeviceType if not set
|
||||||
|
if is_new and not self.platform:
|
||||||
|
self.platform = self.device_type.default_platform
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# If this is a new Device, instantiate all the related components per the DeviceType definition
|
# If this is a new Device, instantiate all the related components per the DeviceType definition
|
||||||
|
@ -77,6 +77,9 @@ class DeviceTypeTable(NetBoxTable):
|
|||||||
manufacturer = tables.Column(
|
manufacturer = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
default_platform = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
is_full_depth = columns.BooleanColumn(
|
is_full_depth = columns.BooleanColumn(
|
||||||
verbose_name='Full Depth'
|
verbose_name='Full Depth'
|
||||||
)
|
)
|
||||||
@ -100,7 +103,7 @@ class DeviceTypeTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.DeviceType
|
model = models.DeviceType
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||||
'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
|
@ -699,9 +699,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
)
|
)
|
||||||
Manufacturer.objects.bulk_create(manufacturers)
|
Manufacturer.objects.bulk_create(manufacturers)
|
||||||
|
|
||||||
|
platforms = (
|
||||||
|
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
|
||||||
|
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1]),
|
||||||
|
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]),
|
||||||
|
)
|
||||||
|
Platform.objects.bulk_create(platforms)
|
||||||
|
|
||||||
device_types = (
|
device_types = (
|
||||||
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
|
DeviceType(manufacturer=manufacturers[0], default_platform=platforms[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||||
DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
|
DeviceType(manufacturer=manufacturers[1], default_platform=platforms[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||||
DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
|
DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
|
||||||
)
|
)
|
||||||
DeviceType.objects.bulk_create(device_types)
|
DeviceType.objects.bulk_create(device_types)
|
||||||
@ -785,6 +792,13 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_default_platform(self):
|
||||||
|
platforms = Platform.objects.all()[:2]
|
||||||
|
params = {'default_platform_id': [platforms[0].pk, platforms[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'default_platform': [platforms[0].slug, platforms[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_has_front_image(self):
|
def test_has_front_image(self):
|
||||||
params = {'has_front_image': True}
|
params = {'has_front_image': True}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
@ -503,6 +503,12 @@ class DeviceTypeTestCase(
|
|||||||
)
|
)
|
||||||
Manufacturer.objects.bulk_create(manufacturers)
|
Manufacturer.objects.bulk_create(manufacturers)
|
||||||
|
|
||||||
|
platforms = (
|
||||||
|
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
|
||||||
|
Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]),
|
||||||
|
)
|
||||||
|
Platform.objects.bulk_create(platforms)
|
||||||
|
|
||||||
DeviceType.objects.bulk_create([
|
DeviceType.objects.bulk_create([
|
||||||
DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]),
|
DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]),
|
||||||
DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]),
|
DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]),
|
||||||
@ -513,6 +519,7 @@ class DeviceTypeTestCase(
|
|||||||
|
|
||||||
cls.form_data = {
|
cls.form_data = {
|
||||||
'manufacturer': manufacturers[1].pk,
|
'manufacturer': manufacturers[1].pk,
|
||||||
|
'default_platform': platforms[0].pk,
|
||||||
'model': 'Device Type X',
|
'model': 'Device Type X',
|
||||||
'slug': 'device-type-x',
|
'slug': 'device-type-x',
|
||||||
'part_number': '123ABC',
|
'part_number': '123ABC',
|
||||||
@ -525,6 +532,7 @@ class DeviceTypeTestCase(
|
|||||||
|
|
||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
'manufacturer': manufacturers[1].pk,
|
'manufacturer': manufacturers[1].pk,
|
||||||
|
'default_platform': platforms[1].pk,
|
||||||
'u_height': 3,
|
'u_height': 3,
|
||||||
'is_full_depth': False,
|
'is_full_depth': False,
|
||||||
}
|
}
|
||||||
@ -673,6 +681,7 @@ class DeviceTypeTestCase(
|
|||||||
"""
|
"""
|
||||||
IMPORT_DATA = """
|
IMPORT_DATA = """
|
||||||
manufacturer: Generic
|
manufacturer: Generic
|
||||||
|
default_platform: Platform
|
||||||
model: TEST-1000
|
model: TEST-1000
|
||||||
slug: test-1000
|
slug: test-1000
|
||||||
u_height: 2
|
u_height: 2
|
||||||
@ -755,8 +764,11 @@ inventory-items:
|
|||||||
manufacturer: Generic
|
manufacturer: Generic
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Create the manufacturer
|
# Create the manufacturer and platform
|
||||||
Manufacturer(name='Generic', slug='generic').save()
|
manufacturer = Manufacturer(name='Generic', slug='generic')
|
||||||
|
manufacturer.save()
|
||||||
|
platform = Platform(name='Platform', slug='test-platform', manufacturer=manufacturer)
|
||||||
|
platform.save()
|
||||||
|
|
||||||
# Add all required permissions to the test user
|
# Add all required permissions to the test user
|
||||||
self.add_permissions(
|
self.add_permissions(
|
||||||
@ -783,6 +795,7 @@ inventory-items:
|
|||||||
|
|
||||||
device_type = DeviceType.objects.get(model='TEST-1000')
|
device_type = DeviceType.objects.get(model='TEST-1000')
|
||||||
self.assertEqual(device_type.comments, 'Test comment')
|
self.assertEqual(device_type.comments, 'Test comment')
|
||||||
|
self.assertEqual(device_type.default_platform.pk, platform.pk)
|
||||||
|
|
||||||
# Verify all of the components were created
|
# Verify all of the components were created
|
||||||
self.assertEqual(device_type.consoleporttemplates.count(), 3)
|
self.assertEqual(device_type.consoleporttemplates.count(), 3)
|
||||||
|
@ -595,6 +595,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):
|
||||||
|
@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
|||||||
from drf_yasg.utils import swagger_serializer_method
|
from drf_yasg.utils import swagger_serializer_method
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
|
||||||
from dcim.api.nested_serializers import (
|
from dcim.api.nested_serializers import (
|
||||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||||
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||||
@ -141,12 +142,19 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
|||||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
|
data_source = NestedDataSourceSerializer(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
data_file = NestedDataFileSerializer(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type',
|
'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type',
|
||||||
'file_extension', 'as_attachment', 'created', 'last_updated',
|
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
|
||||||
|
'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -358,13 +366,20 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
|||||||
required=False,
|
required=False,
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
|
data_source = NestedDataSourceSerializer(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
data_file = NestedDataFileSerializer(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
||||||
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
||||||
'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
|
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
|
||||||
|
'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ from extras.models import CustomField
|
|||||||
from extras.reports import get_report, get_reports, run_report
|
from extras.reports import get_report, get_reports, run_report
|
||||||
from extras.scripts import get_script, get_scripts, run_script
|
from extras.scripts import get_script, get_scripts, run_script
|
||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
|
from netbox.api.features import SyncedDataMixin
|
||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
from utilities.exceptions import RQWorkerNotRunningException
|
from utilities.exceptions import RQWorkerNotRunningException
|
||||||
@ -91,9 +92,9 @@ class CustomLinkViewSet(NetBoxModelViewSet):
|
|||||||
# Export templates
|
# Export templates
|
||||||
#
|
#
|
||||||
|
|
||||||
class ExportTemplateViewSet(NetBoxModelViewSet):
|
class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||||
metadata_class = ContentTypeMetadata
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ExportTemplate.objects.all()
|
queryset = ExportTemplate.objects.prefetch_related('data_source', 'data_file')
|
||||||
serializer_class = serializers.ExportTemplateSerializer
|
serializer_class = serializers.ExportTemplateSerializer
|
||||||
filterset_class = filtersets.ExportTemplateFilterSet
|
filterset_class = filtersets.ExportTemplateFilterSet
|
||||||
|
|
||||||
@ -147,9 +148,10 @@ class JournalEntryViewSet(NetBoxModelViewSet):
|
|||||||
# Config contexts
|
# Config contexts
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConfigContextViewSet(NetBoxModelViewSet):
|
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||||
queryset = ConfigContext.objects.prefetch_related(
|
queryset = ConfigContext.objects.prefetch_related(
|
||||||
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
|
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source',
|
||||||
|
'data_file',
|
||||||
)
|
)
|
||||||
serializer_class = serializers.ConfigContextSerializer
|
serializer_class = serializers.ConfigContextSerializer
|
||||||
filterset_class = filtersets.ConfigContextFilterSet
|
filterset_class = filtersets.ConfigContextFilterSet
|
||||||
|
@ -8,6 +8,7 @@ EXTRAS_FEATURES = [
|
|||||||
'export_templates',
|
'export_templates',
|
||||||
'job_results',
|
'job_results',
|
||||||
'journaling',
|
'journaling',
|
||||||
|
'synced_data',
|
||||||
'tags',
|
'tags',
|
||||||
'webhooks'
|
'webhooks'
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.models import DataFile, DataSource
|
||||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
@ -126,10 +127,18 @@ class ExportTemplateFilterSet(BaseFilterSet):
|
|||||||
field_name='content_types__id'
|
field_name='content_types__id'
|
||||||
)
|
)
|
||||||
content_types = ContentTypeFilter()
|
content_types = ContentTypeFilter()
|
||||||
|
data_source_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
label=_('Data source (ID)'),
|
||||||
|
)
|
||||||
|
data_file_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
label=_('Data file (ID)'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
fields = ['id', 'content_types', 'name', 'description']
|
fields = ['id', 'content_types', 'name', 'description', 'data_synced']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
@ -422,10 +431,18 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label=_('Tag (slug)'),
|
label=_('Tag (slug)'),
|
||||||
)
|
)
|
||||||
|
data_source_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
label=_('Data source (ID)'),
|
||||||
|
)
|
||||||
|
data_file_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
label=_('Data file (ID)'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = ['id', 'name', 'is_active']
|
fields = ['id', 'name', 'is_active', 'data_synced']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
|
|||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect,
|
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -41,8 +41,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
|||||||
label=_("UI visibility"),
|
label=_("UI visibility"),
|
||||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||||
required=False,
|
required=False,
|
||||||
initial='',
|
initial=''
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
nullable_fields = ('group_name', 'description',)
|
nullable_fields = ('group_name', 'description',)
|
||||||
@ -66,8 +65,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
|
|||||||
)
|
)
|
||||||
button_class = forms.ChoiceField(
|
button_class = forms.ChoiceField(
|
||||||
choices=add_blank_choice(CustomLinkButtonClassChoices),
|
choices=add_blank_choice(CustomLinkButtonClassChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.models import DataFile, DataSource
|
||||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
@ -11,8 +12,8 @@ from netbox.forms.base import NetBoxModelFilterSetForm
|
|||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField,
|
add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField,
|
||||||
ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField,
|
ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm,
|
||||||
StaticSelect, TagFilterField,
|
TagFilterField,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
from .mixins import SavedFiltersMixin
|
from .mixins import SavedFiltersMixin
|
||||||
@ -20,9 +21,9 @@ from .mixins import SavedFiltersMixin
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextFilterForm',
|
'ConfigContextFilterForm',
|
||||||
'CustomFieldFilterForm',
|
'CustomFieldFilterForm',
|
||||||
'JobResultFilterForm',
|
|
||||||
'CustomLinkFilterForm',
|
'CustomLinkFilterForm',
|
||||||
'ExportTemplateFilterForm',
|
'ExportTemplateFilterForm',
|
||||||
|
'JobResultFilterForm',
|
||||||
'JournalEntryFilterForm',
|
'JournalEntryFilterForm',
|
||||||
'LocalConfigContextFilterForm',
|
'LocalConfigContextFilterForm',
|
||||||
'ObjectChangeFilterForm',
|
'ObjectChangeFilterForm',
|
||||||
@ -43,7 +44,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Object type')
|
label=_('Object type')
|
||||||
)
|
)
|
||||||
type = MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=CustomFieldTypeChoices,
|
choices=CustomFieldTypeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Field type')
|
label=_('Field type')
|
||||||
@ -56,15 +57,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
required = forms.NullBooleanField(
|
required = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ui_visibility = forms.ChoiceField(
|
ui_visibility = forms.ChoiceField(
|
||||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('UI visibility'),
|
label=_('UI visibility')
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
|
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=JobResultStatusChoices,
|
choices=JobResultStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -141,13 +141,13 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
new_window = forms.NullBooleanField(
|
new_window = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -159,8 +159,22 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id')),
|
(None, ('q', 'filter_id')),
|
||||||
|
('Data', ('data_source_id', 'data_file_id')),
|
||||||
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
||||||
)
|
)
|
||||||
|
data_source_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Data source')
|
||||||
|
)
|
||||||
|
data_file_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=DataFile.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Data file'),
|
||||||
|
query_params={
|
||||||
|
'source_id': '$data_source_id'
|
||||||
|
}
|
||||||
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
limit_choices_to=FeatureQuery('export_templates'),
|
limit_choices_to=FeatureQuery('export_templates'),
|
||||||
@ -175,7 +189,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
as_attachment = forms.NullBooleanField(
|
as_attachment = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -193,13 +207,13 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
shared = forms.NullBooleanField(
|
shared = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -220,32 +234,32 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Object type')
|
label=_('Object type')
|
||||||
)
|
)
|
||||||
http_method = MultipleChoiceField(
|
http_method = forms.MultipleChoiceField(
|
||||||
choices=WebhookHttpMethodChoices,
|
choices=WebhookHttpMethodChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label=_('HTTP method')
|
label=_('HTTP method')
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
type_create = forms.NullBooleanField(
|
type_create = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
type_update = forms.NullBooleanField(
|
type_update = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
type_delete = forms.NullBooleanField(
|
type_delete = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -263,11 +277,25 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id', 'tag_id')),
|
(None, ('q', 'filter_id', 'tag_id')),
|
||||||
|
('Data', ('data_source_id', 'data_file_id')),
|
||||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||||
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
||||||
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
||||||
('Tenant', ('tenant_group_id', 'tenant_id'))
|
('Tenant', ('tenant_group_id', 'tenant_id'))
|
||||||
)
|
)
|
||||||
|
data_source_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Data source')
|
||||||
|
)
|
||||||
|
data_file_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=DataFile.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Data file'),
|
||||||
|
query_params={
|
||||||
|
'source_id': '$data_source_id'
|
||||||
|
}
|
||||||
|
)
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -340,7 +368,7 @@ class LocalConfigContextFilterForm(forms.Form):
|
|||||||
local_context_data = forms.NullBooleanField(
|
local_context_data = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Has local config context data'),
|
label=_('Has local config context data'),
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -381,8 +409,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
|||||||
)
|
)
|
||||||
kind = forms.ChoiceField(
|
kind = forms.ChoiceField(
|
||||||
choices=add_blank_choice(JournalEntryKindChoices),
|
choices=add_blank_choice(JournalEntryKindChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
@ -406,8 +433,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
action = forms.ChoiceField(
|
action = forms.ChoiceField(
|
||||||
choices=add_blank_choice(ObjectChangeActionChoices),
|
choices=add_blank_choice(ObjectChangeActionChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
user_id = DynamicModelMultipleChoiceField(
|
user_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
|
@ -2,13 +2,15 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.models import DataFile, DataSource
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.choices import CustomFieldVisibilityChoices
|
from extras.choices import CustomFieldVisibilityChoices
|
||||||
from utilities.forms.fields import DynamicModelMultipleChoiceField
|
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CustomFieldsMixin',
|
'CustomFieldsMixin',
|
||||||
'SavedFiltersMixin',
|
'SavedFiltersMixin',
|
||||||
|
'SyncedDataMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -72,3 +74,19 @@ class SavedFiltersMixin(forms.Form):
|
|||||||
'usable': True,
|
'usable': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncedDataMixin(forms.Form):
|
||||||
|
data_source = DynamicModelChoiceField(
|
||||||
|
queryset=DataSource.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Data source')
|
||||||
|
)
|
||||||
|
data_file = DynamicModelChoiceField(
|
||||||
|
queryset=DataFile.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('File'),
|
||||||
|
query_params={
|
||||||
|
'source_id': '$data_source',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ -5,13 +5,14 @@ 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
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
|
from extras.forms.mixins import SyncedDataMixin
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
|
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
|
||||||
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
|
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
@ -57,11 +58,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
|||||||
'type': _("The type of data stored in this field. For object/multi-object fields, select the related object "
|
'type': _("The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||||
"type below.")
|
"type below.")
|
||||||
}
|
}
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
'filter_logic': StaticSelect(),
|
|
||||||
'ui_visibility': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||||
@ -79,7 +75,6 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
|||||||
model = CustomLink
|
model = CustomLink
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
widgets = {
|
widgets = {
|
||||||
'button_class': StaticSelect(),
|
|
||||||
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
}
|
}
|
||||||
@ -95,19 +90,28 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
|
|||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
limit_choices_to=FeatureQuery('export_templates')
|
limit_choices_to=FeatureQuery('export_templates')
|
||||||
)
|
)
|
||||||
|
template_code = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={'class': 'font-monospace'})
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Export Template', ('name', 'content_types', 'description')),
|
('Export Template', ('name', 'content_types', 'description')),
|
||||||
('Template', ('template_code',)),
|
('Content', ('data_source', 'data_file', 'template_code',)),
|
||||||
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
|
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
widgets = {
|
|
||||||
'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
|
def clean(self):
|
||||||
}
|
super().clean()
|
||||||
|
|
||||||
|
if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
|
||||||
|
raise forms.ValidationError("Must specify either local content or a data file")
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||||
@ -162,7 +166,6 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
|||||||
'type_delete': 'Deletions',
|
'type_delete': 'Deletions',
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'http_method': StaticSelect(),
|
|
||||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
@ -183,7 +186,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
||||||
regions = DynamicModelMultipleChoiceField(
|
regions = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
@ -236,10 +239,13 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
|||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
data = JSONField()
|
data = JSONField(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
|
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
|
||||||
|
('Data Source', ('data_source', 'data_file')),
|
||||||
('Assignment', (
|
('Assignment', (
|
||||||
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
|
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
|
||||||
@ -251,9 +257,17 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
|||||||
fields = (
|
fields = (
|
||||||
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
||||||
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||||
'tenants', 'tags',
|
'tenants', 'tags', 'data_source', 'data_file',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'):
|
||||||
|
raise forms.ValidationError("Must specify either local data or a data file")
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||||
|
|
||||||
@ -267,8 +281,7 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
|||||||
class JournalEntryForm(NetBoxModelForm):
|
class JournalEntryForm(NetBoxModelForm):
|
||||||
kind = forms.ChoiceField(
|
kind = forms.ChoiceField(
|
||||||
choices=add_blank_choice(JournalEntryKindChoices),
|
choices=add_blank_choice(JournalEntryKindChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_input(self):
|
def requires_input(self):
|
||||||
|
@ -9,7 +9,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
APPS = ('circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
|
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
|
||||||
|
|
||||||
BANNER_TEXT = """### NetBox interactive shell ({node})
|
BANNER_TEXT = """### NetBox interactive shell ({node})
|
||||||
### Python {python} | Django {django} | NetBox {netbox}
|
### Python {python} | Django {django} | NetBox {netbox}
|
||||||
|
55
netbox/extras/migrations/0085_synced_data.py
Normal file
55
netbox/extras/migrations/0085_synced_data.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0001_initial'),
|
||||||
|
('extras', '0084_staging'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# ConfigContexts
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='configcontext',
|
||||||
|
name='data_file',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='configcontext',
|
||||||
|
name='data_path',
|
||||||
|
field=models.CharField(blank=True, editable=False, max_length=1000),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='configcontext',
|
||||||
|
name='data_source',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='configcontext',
|
||||||
|
name='data_synced',
|
||||||
|
field=models.DateTimeField(blank=True, editable=False, null=True),
|
||||||
|
),
|
||||||
|
# ExportTemplates
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exporttemplate',
|
||||||
|
name='data_file',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exporttemplate',
|
||||||
|
name='data_path',
|
||||||
|
field=models.CharField(blank=True, editable=False, max_length=1000),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exporttemplate',
|
||||||
|
name='data_source',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exporttemplate',
|
||||||
|
name='data_synced',
|
||||||
|
field=models.DateTimeField(blank=True, editable=False, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -2,13 +2,13 @@ from django.conf import settings
|
|||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
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 SyncedDataMixin
|
||||||
from utilities.utils import deepmerge
|
from utilities.utils import deepmerge
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContext',
|
'ConfigContext',
|
||||||
'ConfigContextModel',
|
'ConfigContextModel',
|
||||||
@ -19,7 +19,7 @@ __all__ = (
|
|||||||
# Config contexts
|
# Config contexts
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
class ConfigContext(SyncedDataMixin, 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
|
||||||
@ -130,6 +130,13 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
|||||||
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
|
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def sync_data(self):
|
||||||
|
"""
|
||||||
|
Synchronize context data from the designated DataFile (if any).
|
||||||
|
"""
|
||||||
|
self.data = self.data_file.get_data()
|
||||||
|
self.data_synced = timezone.now()
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextModel(models.Model):
|
class ConfigContextModel(models.Model):
|
||||||
"""
|
"""
|
||||||
|
@ -17,12 +17,12 @@ from django.utils.translation import gettext as _
|
|||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
||||||
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 import (
|
||||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||||
JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
JSONField, LaxURLField, 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
|
||||||
@ -54,7 +54,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
|||||||
return self.get_queryset().filter(content_types=content_type)
|
return self.get_queryset().filter(content_types=content_type)
|
||||||
|
|
||||||
|
|
||||||
class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
related_name='custom_fields',
|
related_name='custom_fields',
|
||||||
@ -372,7 +372,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
(False, 'False'),
|
(False, 'False'),
|
||||||
)
|
)
|
||||||
field = forms.NullBooleanField(
|
field = forms.NullBooleanField(
|
||||||
required=required, initial=initial, widget=StaticSelect(choices=choices)
|
required=required, initial=initial, widget=forms.Select(choices=choices)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Date
|
# Date
|
||||||
@ -393,14 +393,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
|
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||||
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
||||||
field = field_class(
|
field = field_class(choices=choices, required=required, initial=initial)
|
||||||
choices=choices, required=required, initial=initial, widget=StaticSelect()
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
|
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
|
||||||
field = field_class(
|
field = field_class(choices=choices, required=required, initial=initial)
|
||||||
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
|
|
||||||
)
|
|
||||||
|
|
||||||
# URL
|
# URL
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||||
|
@ -11,6 +11,7 @@ from django.core.validators import MinValueValidator, ValidationError
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpResponse, QueryDict
|
from django.http import HttpResponse, QueryDict
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.urls.exceptions import NoReverseMatch
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -25,7 +26,8 @@ from netbox.config import get_config
|
|||||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import (
|
from netbox.models.features import (
|
||||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
|
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin,
|
||||||
|
TagsMixin,
|
||||||
)
|
)
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.utils import render_jinja2
|
from utilities.utils import render_jinja2
|
||||||
@ -44,7 +46,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
||||||
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
|
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
|
||||||
@ -201,7 +203,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
|||||||
return render_jinja2(self.payload_url, context)
|
return render_jinja2(self.payload_url, context)
|
||||||
|
|
||||||
|
|
||||||
class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
|
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
|
||||||
code to be rendered with an object as context.
|
code to be rendered with an object as context.
|
||||||
@ -280,7 +282,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
related_name='export_templates',
|
related_name='export_templates',
|
||||||
@ -334,6 +336,13 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
|||||||
'name': f'"{self.name}" is a reserved name. Please choose a different name.'
|
'name': f'"{self.name}" is a reserved name. Please choose a different name.'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def sync_data(self):
|
||||||
|
"""
|
||||||
|
Synchronize template content from the designated DataFile (if any).
|
||||||
|
"""
|
||||||
|
self.template_code = self.data_file.data_as_string
|
||||||
|
self.data_synced = timezone.now()
|
||||||
|
|
||||||
def render(self, queryset):
|
def render(self, queryset):
|
||||||
"""
|
"""
|
||||||
Render the contents of the template.
|
Render the contents of the template.
|
||||||
@ -367,7 +376,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A set of predefined keyword parameters that can be reused to filter for specific objects.
|
A set of predefined keyword parameters that can be reused to filter for specific objects.
|
||||||
"""
|
"""
|
||||||
@ -438,7 +447,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
return qd.urlencode()
|
return qd.urlencode()
|
||||||
|
|
||||||
|
|
||||||
class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
|
class ImageAttachment(ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
An uploaded image which is associated with an object.
|
An uploaded image which is associated with an object.
|
||||||
"""
|
"""
|
||||||
@ -514,7 +523,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
|
|||||||
return objectchange
|
return objectchange
|
||||||
|
|
||||||
|
|
||||||
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
|
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
|
||||||
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
|
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
|
||||||
@ -634,7 +643,7 @@ class JobResult(models.Model):
|
|||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.name, RQ_QUEUE_DEFAULT)
|
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.model, RQ_QUEUE_DEFAULT)
|
||||||
queue = django_rq.get_queue(rq_queue_name)
|
queue = django_rq.get_queue(rq_queue_name)
|
||||||
job = queue.fetch_job(str(self.job_id))
|
job = queue.fetch_job(str(self.job_id))
|
||||||
|
|
||||||
@ -642,7 +651,10 @@ class JobResult(models.Model):
|
|||||||
job.cancel()
|
job.cancel()
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
|
try:
|
||||||
|
return reverse(f'extras:{self.obj_type.model}_result', args=[self.pk])
|
||||||
|
except NoReverseMatch:
|
||||||
|
return None
|
||||||
|
|
||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return JobResultStatusChoices.colors.get(self.status)
|
return JobResultStatusChoices.colors.get(self.status)
|
||||||
@ -693,7 +705,7 @@ class JobResult(models.Model):
|
|||||||
schedule_at: Schedule the job to be executed at the passed date and time
|
schedule_at: Schedule the job to be executed at the passed date and time
|
||||||
interval: Recurrence interval (in minutes)
|
interval: Recurrence interval (in minutes)
|
||||||
"""
|
"""
|
||||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
|
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.model, RQ_QUEUE_DEFAULT)
|
||||||
queue = django_rq.get_queue(rq_queue_name)
|
queue = django_rq.get_queue(rq_queue_name)
|
||||||
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
|
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
|
||||||
job_result: JobResult = JobResult.objects.create(
|
job_result: JobResult = JobResult.objects.create(
|
||||||
|
@ -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 ExportTemplatesMixin
|
||||||
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(ExportTemplatesMixin, ChangeLoggedModel, TagBase):
|
||||||
id = models.BigAutoField(
|
id = models.BigAutoField(
|
||||||
primary_key=True
|
primary_key=True
|
||||||
)
|
)
|
||||||
|
@ -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 getattr(self, name):
|
||||||
|
return import_string(f"{self.__module__}.{self.name}")
|
||||||
|
|
||||||
|
# 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,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
|
||||||
@ -470,6 +470,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(
|
||||||
|
@ -90,15 +90,24 @@ class ExportTemplateTable(NetBoxTable):
|
|||||||
)
|
)
|
||||||
content_types = columns.ContentTypesColumn()
|
content_types = columns.ContentTypesColumn()
|
||||||
as_attachment = columns.BooleanColumn()
|
as_attachment = columns.BooleanColumn()
|
||||||
|
data_source = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
data_file = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
is_synced = columns.BooleanColumn(
|
||||||
|
verbose_name='Synced'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||||
'created', 'last_updated',
|
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -188,21 +197,30 @@ class TaggedItemTable(NetBoxTable):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigContextTable(NetBoxTable):
|
class ConfigContextTable(NetBoxTable):
|
||||||
|
data_source = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
data_file = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
is_active = columns.BooleanColumn(
|
is_active = columns.BooleanColumn(
|
||||||
verbose_name='Active'
|
verbose_name='Active'
|
||||||
)
|
)
|
||||||
|
is_synced = columns.BooleanColumn(
|
||||||
|
verbose_name='Synced'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
|
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
|
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||||
'last_updated',
|
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeTable(NetBoxTable):
|
class ObjectChangeTable(NetBoxTable):
|
||||||
|
@ -29,6 +29,7 @@ urlpatterns = [
|
|||||||
path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'),
|
path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'),
|
||||||
path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'),
|
path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'),
|
||||||
path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
|
path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
|
||||||
|
path('export-templates/sync/', views.ExportTemplateBulkSyncDataView.as_view(), name='exporttemplate_bulk_sync'),
|
||||||
path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
|
path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
|
||||||
|
|
||||||
# Saved filters
|
# Saved filters
|
||||||
@ -60,6 +61,7 @@ urlpatterns = [
|
|||||||
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
|
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
|
||||||
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
|
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
|
||||||
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
|
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
|
||||||
|
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
|
||||||
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
|
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
|
||||||
|
|
||||||
# Image attachments
|
# Image attachments
|
||||||
|
@ -121,6 +121,8 @@ class ExportTemplateListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ExportTemplateFilterSet
|
filterset = filtersets.ExportTemplateFilterSet
|
||||||
filterset_form = forms.ExportTemplateFilterForm
|
filterset_form = forms.ExportTemplateFilterForm
|
||||||
table = tables.ExportTemplateTable
|
table = tables.ExportTemplateTable
|
||||||
|
template_name = 'extras/exporttemplate_list.html'
|
||||||
|
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ExportTemplate)
|
@register_model_view(ExportTemplate)
|
||||||
@ -158,6 +160,10 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.ExportTemplateTable
|
table = tables.ExportTemplateTable
|
||||||
|
|
||||||
|
|
||||||
|
class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
||||||
|
queryset = ExportTemplate.objects.all()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Saved filters
|
# Saved filters
|
||||||
#
|
#
|
||||||
@ -352,7 +358,8 @@ class ConfigContextListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ConfigContextFilterSet
|
filterset = filtersets.ConfigContextFilterSet
|
||||||
filterset_form = forms.ConfigContextFilterForm
|
filterset_form = forms.ConfigContextFilterForm
|
||||||
table = tables.ConfigContextTable
|
table = tables.ConfigContextTable
|
||||||
actions = ('add', 'bulk_edit', 'bulk_delete')
|
template_name = 'extras/configcontext_list.html'
|
||||||
|
actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync')
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConfigContext)
|
@register_model_view(ConfigContext)
|
||||||
@ -416,6 +423,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.ConfigContextTable
|
table = tables.ConfigContextTable
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
|
||||||
|
queryset = ConfigContext.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class ObjectConfigContextView(generic.ObjectView):
|
class ObjectConfigContextView(generic.ObjectView):
|
||||||
base_template = None
|
base_template = None
|
||||||
template_name = 'extras/object_configcontext.html'
|
template_name = 'extras/object_configcontext.html'
|
||||||
|
@ -9,8 +9,8 @@ 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, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
|
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||||
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
|
NumericArrayField,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -48,7 +48,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -116,7 +116,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -205,8 +205,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(PrefixStatusChoices),
|
choices=add_blank_choice(PrefixStatusChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
@ -227,7 +226,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -254,8 +253,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(IPRangeStatusChoices),
|
choices=add_blank_choice(IPRangeStatusChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
@ -266,7 +264,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -296,13 +294,11 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(IPAddressStatusChoices),
|
choices=add_blank_choice(IPAddressStatusChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
role = forms.ChoiceField(
|
role = forms.ChoiceField(
|
||||||
choices=add_blank_choice(IPAddressRoleChoices),
|
choices=add_blank_choice(IPAddressRoleChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
dns_name = forms.CharField(
|
dns_name = forms.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@ -314,7 +310,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -331,8 +327,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
protocol = forms.ChoiceField(
|
protocol = forms.ChoiceField(
|
||||||
choices=add_blank_choice(FHRPGroupProtocolChoices),
|
choices=add_blank_choice(FHRPGroupProtocolChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
group_id = forms.IntegerField(
|
group_id = forms.IntegerField(
|
||||||
min_value=0,
|
min_value=0,
|
||||||
@ -342,7 +337,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
auth_type = forms.ChoiceField(
|
auth_type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(FHRPGroupAuthTypeChoices),
|
choices=add_blank_choice(FHRPGroupAuthTypeChoices),
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect(),
|
|
||||||
label=_('Authentication type')
|
label=_('Authentication type')
|
||||||
)
|
)
|
||||||
auth_key = forms.CharField(
|
auth_key = forms.CharField(
|
||||||
@ -359,7 +353,7 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -430,8 +424,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
)
|
)
|
||||||
status = forms.ChoiceField(
|
status = forms.ChoiceField(
|
||||||
choices=add_blank_choice(VLANStatusChoices),
|
choices=add_blank_choice(VLANStatusChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
@ -442,7 +435,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -459,8 +452,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
protocol = forms.ChoiceField(
|
protocol = forms.ChoiceField(
|
||||||
choices=add_blank_choice(ServiceProtocolChoices),
|
choices=add_blank_choice(ServiceProtocolChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
ports = NumericArrayField(
|
ports = NumericArrayField(
|
||||||
base_field=forms.IntegerField(
|
base_field=forms.IntegerField(
|
||||||
@ -474,7 +466,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -492,8 +484,7 @@ class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
|
|||||||
class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(L2VPNTypeChoices),
|
choices=add_blank_choice(L2VPNTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -504,7 +495,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=forms.Textarea,
|
||||||
label='Comments'
|
label='Comments'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm
|
|||||||
from tenancy.forms import TenancyFilterForm
|
from tenancy.forms import TenancyFilterForm
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||||
MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||||
)
|
)
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
|
|||||||
is_private = forms.NullBooleanField(
|
is_private = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Private'),
|
label=_('Private'),
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -104,8 +104,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
family = forms.ChoiceField(
|
family = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||||
label=_('Address family'),
|
label=_('Address family')
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
rir_id = DynamicModelMultipleChoiceField(
|
rir_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=RIR.objects.all(),
|
queryset=RIR.objects.all(),
|
||||||
@ -164,10 +163,9 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
family = forms.ChoiceField(
|
family = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||||
label=_('Address family'),
|
label=_('Address family')
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
mask_length = MultipleChoiceField(
|
mask_length = forms.MultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=PREFIX_MASK_LENGTH_CHOICES,
|
choices=PREFIX_MASK_LENGTH_CHOICES,
|
||||||
label=_('Mask length')
|
label=_('Mask length')
|
||||||
@ -183,7 +181,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Present in VRF')
|
label=_('Present in VRF')
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=PrefixStatusChoices,
|
choices=PrefixStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -215,14 +213,14 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
is_pool = forms.NullBooleanField(
|
is_pool = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Is a pool'),
|
label=_('Is a pool'),
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
mark_utilized = forms.NullBooleanField(
|
mark_utilized = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Marked as 100% utilized'),
|
label=_('Marked as 100% utilized'),
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -239,8 +237,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
family = forms.ChoiceField(
|
family = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||||
label=_('Address family'),
|
label=_('Address family')
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
vrf_id = DynamicModelMultipleChoiceField(
|
vrf_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -248,7 +245,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
label=_('Assigned VRF'),
|
label=_('Assigned VRF'),
|
||||||
null_option='Global'
|
null_option='Global'
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=IPRangeStatusChoices,
|
choices=IPRangeStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -282,14 +279,12 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
family = forms.ChoiceField(
|
family = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||||
label=_('Address family'),
|
label=_('Address family')
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
mask_length = forms.ChoiceField(
|
mask_length = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
choices=IPADDRESS_MASK_LENGTH_CHOICES,
|
choices=IPADDRESS_MASK_LENGTH_CHOICES,
|
||||||
label=_('Mask length'),
|
label=_('Mask length')
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
vrf_id = DynamicModelMultipleChoiceField(
|
vrf_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -312,18 +307,18 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Assigned VM'),
|
label=_('Assigned VM'),
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=IPAddressStatusChoices,
|
choices=IPAddressStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
role = MultipleChoiceField(
|
role = forms.MultipleChoiceField(
|
||||||
choices=IPAddressRoleChoices,
|
choices=IPAddressRoleChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
assigned_to_interface = forms.NullBooleanField(
|
assigned_to_interface = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Assigned to an interface'),
|
label=_('Assigned to an interface'),
|
||||||
widget=StaticSelect(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -340,7 +335,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
|
|||||||
name = forms.CharField(
|
name = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
protocol = MultipleChoiceField(
|
protocol = forms.MultipleChoiceField(
|
||||||
choices=FHRPGroupProtocolChoices,
|
choices=FHRPGroupProtocolChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -349,7 +344,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='Group ID'
|
label='Group ID'
|
||||||
)
|
)
|
||||||
auth_type = MultipleChoiceField(
|
auth_type = forms.MultipleChoiceField(
|
||||||
choices=FHRPGroupAuthTypeChoices,
|
choices=FHRPGroupAuthTypeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
label='Authentication type'
|
label='Authentication type'
|
||||||
@ -444,7 +439,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
},
|
},
|
||||||
label=_('VLAN group')
|
label=_('VLAN group')
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
choices=VLANStatusChoices,
|
choices=VLANStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -474,8 +469,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
|||||||
)
|
)
|
||||||
protocol = forms.ChoiceField(
|
protocol = forms.ChoiceField(
|
||||||
choices=add_blank_choice(ServiceProtocolChoices),
|
choices=add_blank_choice(ServiceProtocolChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
port = forms.IntegerField(
|
port = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -497,8 +491,7 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
choices=add_blank_choice(L2VPNTypeChoices),
|
choices=add_blank_choice(L2VPNTypeChoices),
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
import_target_id = DynamicModelMultipleChoiceField(
|
import_target_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=RouteTarget.objects.all(),
|
queryset=RouteTarget.objects.all(),
|
||||||
|
@ -13,7 +13,7 @@ from tenancy.forms import TenancyForm
|
|||||||
from utilities.exceptions import PermissionsViolation
|
from utilities.exceptions import PermissionsViolation
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
|
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
|
||||||
DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple,
|
DynamicModelMultipleChoiceField, NumericArrayField, SlugField,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
|
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
|
||||||
|
|
||||||
@ -254,9 +254,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
|||||||
'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant',
|
'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant',
|
||||||
'description', 'comments', 'tags',
|
'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IPRangeForm(TenancyForm, NetBoxModelForm):
|
class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||||
@ -282,9 +279,6 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
|
|||||||
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description',
|
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description',
|
||||||
'comments', 'tags',
|
'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IPAddressForm(TenancyForm, NetBoxModelForm):
|
class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||||
@ -411,10 +405,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description',
|
'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description',
|
||||||
'comments', 'tags',
|
'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
'role': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -510,10 +500,6 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
|
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
'role': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||||
@ -559,11 +545,6 @@ class FHRPGroupForm(NetBoxModelForm):
|
|||||||
'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'ip_vrf', 'ip_address', 'ip_status', 'description',
|
'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'ip_vrf', 'ip_address', 'ip_status', 'description',
|
||||||
'comments', 'tags',
|
'comments', 'tags',
|
||||||
)
|
)
|
||||||
widgets = {
|
|
||||||
'protocol': StaticSelect(),
|
|
||||||
'auth_type': StaticSelect(),
|
|
||||||
'ip_status': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
instance = super().save(*args, **kwargs)
|
instance = super().save(*args, **kwargs)
|
||||||
@ -700,9 +681,6 @@ class VLANGroupForm(NetBoxModelForm):
|
|||||||
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
|
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
|
||||||
'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
|
'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'scope_type': StaticSelect,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
instance = kwargs.get('instance')
|
instance = kwargs.get('instance')
|
||||||
@ -740,7 +718,6 @@ class VLANForm(TenancyForm, NetBoxModelForm):
|
|||||||
('virtualization.cluster', 'Cluster'),
|
('virtualization.cluster', 'Cluster'),
|
||||||
),
|
),
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect,
|
|
||||||
label=_('Group scope')
|
label=_('Group scope')
|
||||||
)
|
)
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
@ -800,9 +777,6 @@ class VLANForm(TenancyForm, NetBoxModelForm):
|
|||||||
'status': _("Operational status of this VLAN"),
|
'status': _("Operational status of this VLAN"),
|
||||||
'role': _("The primary function of this VLAN"),
|
'role': _("The primary function of this VLAN"),
|
||||||
}
|
}
|
||||||
widgets = {
|
|
||||||
'status': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceTemplateForm(NetBoxModelForm):
|
class ServiceTemplateForm(NetBoxModelForm):
|
||||||
@ -824,9 +798,6 @@ class ServiceTemplateForm(NetBoxModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ServiceTemplate
|
model = ServiceTemplate
|
||||||
fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags')
|
fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags')
|
||||||
widgets = {
|
|
||||||
'protocol': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceForm(NetBoxModelForm):
|
class ServiceForm(NetBoxModelForm):
|
||||||
@ -865,10 +836,6 @@ class ServiceForm(NetBoxModelForm):
|
|||||||
'ipaddresses': _("IP address assignment is optional. If no IPs are selected, the service is assumed to be "
|
'ipaddresses': _("IP address assignment is optional. If no IPs are selected, the service is assumed to be "
|
||||||
"reachable via all IPs assigned to the device."),
|
"reachable via all IPs assigned to the device."),
|
||||||
}
|
}
|
||||||
widgets = {
|
|
||||||
'protocol': StaticSelect(),
|
|
||||||
'ipaddresses': StaticSelectMultiple(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceCreateForm(ServiceForm):
|
class ServiceCreateForm(ServiceForm):
|
||||||
@ -934,9 +901,6 @@ class L2VPNForm(TenancyForm, NetBoxModelForm):
|
|||||||
'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description',
|
'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description',
|
||||||
'comments', 'tags'
|
'comments', 'tags'
|
||||||
)
|
)
|
||||||
widgets = {
|
|
||||||
'type': StaticSelect(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class L2VPNTerminationForm(NetBoxModelForm):
|
class L2VPNTerminationForm(NetBoxModelForm):
|
||||||
|
@ -5,7 +5,6 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||||
from netbox.models.features import WebhooksMixin
|
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
|
|
||||||
@ -73,7 +72,7 @@ class FHRPGroup(PrimaryModel):
|
|||||||
return reverse('ipam:fhrpgroup', args=[self.pk])
|
return reverse('ipam:fhrpgroup', args=[self.pk])
|
||||||
|
|
||||||
|
|
||||||
class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel):
|
class FHRPGroupAssignment(ChangeLoggedModel):
|
||||||
interface_type = models.ForeignKey(
|
interface_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
on_delete=models.CASCADE
|
on_delete=models.CASCADE
|
||||||
|
30
netbox/netbox/api/features.py
Normal file
30
netbox/netbox/api/features.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from utilities.permissions import get_permission_for_model
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'SyncedDataMixin',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncedDataMixin:
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def sync(self, request, pk):
|
||||||
|
"""
|
||||||
|
Provide a /sync API endpoint to synchronize an object's data from its associated DataFile (if any).
|
||||||
|
"""
|
||||||
|
permission = get_permission_for_model(self.queryset.model, 'sync')
|
||||||
|
if not request.user.has_perm(permission):
|
||||||
|
raise PermissionDenied(f"Missing permission: {permission}")
|
||||||
|
|
||||||
|
obj = get_object_or_404(self.queryset, pk=pk)
|
||||||
|
if obj.data_file:
|
||||||
|
obj.sync_data()
|
||||||
|
obj.save()
|
||||||
|
serializer = self.serializer_class(obj, context={'request': request})
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
@ -27,6 +27,7 @@ class APIRootView(APIView):
|
|||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'circuits': reverse('circuits-api:api-root', request=request, format=format),
|
'circuits': reverse('circuits-api:api-root', request=request, format=format),
|
||||||
|
'core': reverse('core-api:api-root', request=request, format=format),
|
||||||
'dcim': reverse('dcim-api:api-root', request=request, format=format),
|
'dcim': reverse('dcim-api:api-root', request=request, format=format),
|
||||||
'extras': reverse('extras-api:api-root', request=request, format=format),
|
'extras': reverse('extras-api:api-root', request=request, format=format),
|
||||||
'ipam': reverse('ipam-api:api-root', request=request, format=format),
|
'ipam': reverse('ipam-api:api-root', request=request, format=format),
|
||||||
|
@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from netbox.search import LookupTypes
|
from netbox.search import LookupTypes
|
||||||
from netbox.search.backends import search_backend
|
from netbox.search.backends import search_backend
|
||||||
from utilities.forms import BootstrapMixin, StaticSelect, StaticSelectMultiple
|
from utilities.forms import BootstrapMixin
|
||||||
|
|
||||||
from .base import *
|
from .base import *
|
||||||
|
|
||||||
@ -32,14 +32,12 @@ class SearchForm(BootstrapMixin, forms.Form):
|
|||||||
obj_types = forms.MultipleChoiceField(
|
obj_types = forms.MultipleChoiceField(
|
||||||
choices=[],
|
choices=[],
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object type(s)'),
|
label=_('Object type(s)')
|
||||||
widget=StaticSelectMultiple()
|
|
||||||
)
|
)
|
||||||
lookup = forms.ChoiceField(
|
lookup = forms.ChoiceField(
|
||||||
choices=LOOKUP_CHOICES,
|
choices=LOOKUP_CHOICES,
|
||||||
initial=LookupTypes.PARTIAL,
|
initial=LookupTypes.PARTIAL,
|
||||||
required=False,
|
required=False
|
||||||
widget=StaticSelect()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import graphene
|
import graphene
|
||||||
|
|
||||||
from circuits.graphql.schema import CircuitsQuery
|
from circuits.graphql.schema import CircuitsQuery
|
||||||
|
from core.graphql.schema import CoreQuery
|
||||||
from dcim.graphql.schema import DCIMQuery
|
from dcim.graphql.schema import DCIMQuery
|
||||||
from extras.graphql.schema import ExtrasQuery
|
from extras.graphql.schema import ExtrasQuery
|
||||||
from ipam.graphql.schema import IPAMQuery
|
from ipam.graphql.schema import IPAMQuery
|
||||||
@ -14,6 +15,7 @@ from wireless.graphql.schema import WirelessQuery
|
|||||||
class Query(
|
class Query(
|
||||||
UsersQuery,
|
UsersQuery,
|
||||||
CircuitsQuery,
|
CircuitsQuery,
|
||||||
|
CoreQuery,
|
||||||
DCIMQuery,
|
DCIMQuery,
|
||||||
ExtrasQuery,
|
ExtrasQuery,
|
||||||
IPAMQuery,
|
IPAMQuery,
|
||||||
|
@ -38,7 +38,7 @@ class NetBoxFeatureSet(
|
|||||||
# Base model classes
|
# Base model classes
|
||||||
#
|
#
|
||||||
|
|
||||||
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model):
|
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, WebhooksMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
Base model for ancillary models; provides limited functionality for models which don't
|
Base model for ancillary models; provides limited functionality for models which don't
|
||||||
support NetBox's full feature set.
|
support NetBox's full feature set.
|
||||||
|
@ -2,11 +2,12 @@ from collections import defaultdict
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.db.models.signals import class_prepared
|
|
||||||
from django.dispatch import receiver
|
|
||||||
|
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import class_prepared
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
||||||
@ -25,6 +26,7 @@ __all__ = (
|
|||||||
'ExportTemplatesMixin',
|
'ExportTemplatesMixin',
|
||||||
'JobResultsMixin',
|
'JobResultsMixin',
|
||||||
'JournalingMixin',
|
'JournalingMixin',
|
||||||
|
'SyncedDataMixin',
|
||||||
'TagsMixin',
|
'TagsMixin',
|
||||||
'WebhooksMixin',
|
'WebhooksMixin',
|
||||||
)
|
)
|
||||||
@ -313,12 +315,82 @@ class WebhooksMixin(models.Model):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class SyncedDataMixin(models.Model):
|
||||||
|
"""
|
||||||
|
Enables population of local data from a DataFile object, synchronized from a remote DatSource.
|
||||||
|
"""
|
||||||
|
data_source = models.ForeignKey(
|
||||||
|
to='core.DataSource',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
related_name='+',
|
||||||
|
help_text=_("Remote data source")
|
||||||
|
)
|
||||||
|
data_file = models.ForeignKey(
|
||||||
|
to='core.DataFile',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
related_name='+'
|
||||||
|
)
|
||||||
|
data_path = models.CharField(
|
||||||
|
max_length=1000,
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
help_text=_("Path to remote file (relative to data source root)")
|
||||||
|
)
|
||||||
|
data_synced = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
editable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_synced(self):
|
||||||
|
return self.data_file and self.data_synced >= self.data_file.last_updated
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.data_file:
|
||||||
|
self.sync_data()
|
||||||
|
self.data_path = self.data_file.path
|
||||||
|
|
||||||
|
if self.data_source and not self.data_file:
|
||||||
|
raise ValidationError({
|
||||||
|
'data_file': _(f"Must specify a data file when designating a data source.")
|
||||||
|
})
|
||||||
|
if self.data_file and not self.data_source:
|
||||||
|
self.data_source = self.data_file.source
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
def resolve_data_file(self):
|
||||||
|
"""
|
||||||
|
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
|
||||||
|
either attribute is unset, or if no matching DataFile is found.
|
||||||
|
"""
|
||||||
|
from core.models import DataFile
|
||||||
|
|
||||||
|
if self.data_source and self.data_path:
|
||||||
|
try:
|
||||||
|
return DataFile.objects.get(source=self.data_source, path=self.data_path)
|
||||||
|
except DataFile.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def sync_data(self):
|
||||||
|
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
|
||||||
|
|
||||||
|
|
||||||
FEATURES_MAP = (
|
FEATURES_MAP = (
|
||||||
('custom_fields', CustomFieldsMixin),
|
('custom_fields', CustomFieldsMixin),
|
||||||
('custom_links', CustomLinksMixin),
|
('custom_links', CustomLinksMixin),
|
||||||
('export_templates', ExportTemplatesMixin),
|
('export_templates', ExportTemplatesMixin),
|
||||||
('job_results', JobResultsMixin),
|
('job_results', JobResultsMixin),
|
||||||
('journaling', JournalingMixin),
|
('journaling', JournalingMixin),
|
||||||
|
('synced_data', SyncedDataMixin),
|
||||||
('tags', TagsMixin),
|
('tags', TagsMixin),
|
||||||
('webhooks', WebhooksMixin),
|
('webhooks', WebhooksMixin),
|
||||||
)
|
)
|
||||||
@ -344,3 +416,9 @@ def _register_features(sender, **kwargs):
|
|||||||
'changelog',
|
'changelog',
|
||||||
kwargs={'model': sender}
|
kwargs={'model': sender}
|
||||||
)('netbox.views.generic.ObjectChangeLogView')
|
)('netbox.views.generic.ObjectChangeLogView')
|
||||||
|
if issubclass(sender, SyncedDataMixin):
|
||||||
|
register_model_view(
|
||||||
|
sender,
|
||||||
|
'sync',
|
||||||
|
kwargs={'model': sender}
|
||||||
|
)('netbox.views.generic.ObjectSyncDataView')
|
||||||
|
@ -287,6 +287,7 @@ OTHER_MENU = Menu(
|
|||||||
MenuGroup(
|
MenuGroup(
|
||||||
label=_('Integrations'),
|
label=_('Integrations'),
|
||||||
items=(
|
items=(
|
||||||
|
get_model_item('core', 'datasource', _('Data Sources')),
|
||||||
get_model_item('extras', 'webhook', _('Webhooks')),
|
get_model_item('extras', 'webhook', _('Webhooks')),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
link='extras:report_list',
|
link='extras:report_list',
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user