merge feature

This commit is contained in:
Arthur 2023-02-21 09:15:24 -08:00
commit b9021f2051
210 changed files with 4473 additions and 1411 deletions

View File

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

View File

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

View File

@ -24,7 +24,7 @@ jobs:
necessary. necessary.
close-pr-message: > close-pr-message: >
This PR has been automatically closed due to lack of activity. This PR has been automatically closed due to lack of activity.
days-before-stale: 60 days-before-stale: 90
days-before-close: 30 days-before-close: 30
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone' exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
operations-per-run: 100 operations-per-run: 100

119
README.md
View File

@ -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>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
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.
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) * **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.
[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](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.
[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues) * **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls) * **Organization:** Manage tenant and contact assignments natively.
[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](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!
![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI") ![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
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>
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com) [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
@ -76,34 +64,10 @@ complete list of requirements, see `requirements.txt`. The code is available
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io) [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com) [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech)
</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
![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)") ![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)")
@ -112,8 +76,3 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
![Screenshot of prefixes hierarchy](docs/media/screenshots/prefixes-list.png "Prefixes hierarchy") ![Screenshot of prefixes hierarchy](docs/media/screenshots/prefixes-list.png "Prefixes hierarchy")
![Screenshot of cable trace](docs/media/screenshots/cable-trace.png "Cable tracing") ![Screenshot of cable trace](docs/media/screenshots/cable-trace.png "Cable tracing")
### Related projects
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
for a list of relevant community projects.

View File

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

View File

@ -5,6 +5,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Clearing expired authentication sessions from the database * Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention) * Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#jobresult_retention) * Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#jobresult_retention)
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.

View File

@ -69,6 +69,14 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
--- ---
## FILE_UPLOAD_MAX_MEMORY_SIZE
Default: 2621440 (i.e. 2.5 MB).
The maximum size (in bytes) that an upload will be before it gets streamed to the file system. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
---
## GRAPHQL_ENABLED ## GRAPHQL_ENABLED
!!! tip "Dynamic Configuration Parameter" !!! tip "Dynamic Configuration Parameter"

View File

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

View File

@ -54,15 +54,19 @@ Each model should have a corresponding FilterSet class defined. This is used to
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns. Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
## 9. Create the object template ## 9. Create a SearchIndex subclass
If this model will be included in global search results, create a subclass of `netbox.search.SearchIndex` for it and specify the fields to be indexed.
## 10. Create the object template
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`. Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
## 10. Add the model to the navigation menu ## 11. Add the model to the navigation menu
Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`. Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.
## 11. REST API components ## 12. REST API components
Create the following for each model: Create the following for each model:
@ -71,13 +75,13 @@ Create the following for each model:
* API view in `api/views.py` * API view in `api/views.py`
* Endpoint route in `api/urls.py` * Endpoint route in `api/urls.py`
## 12. GraphQL API components ## 13. GraphQL API components
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
## 13. Add tests ## 14. Add tests
Add tests for the following: Add tests for the following:
@ -85,7 +89,7 @@ Add tests for the following:
* API views * API views
* Filter sets * Filter sets
## 14. Documentation ## 15. Documentation
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate. Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.

View File

@ -8,6 +8,14 @@ The registry can be inspected by importing `registry` from `extras.registry`.
## Stores ## Stores
### `data_backends`
A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md).
### `denormalized_fields`
Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates.
### `model_features` ### `model_features`
A dictionary of particular features (e.g. custom fields) mapped to the NetBox models which support them, arranged by app. For example: A dictionary of particular features (e.g. custom fields) mapped to the NetBox models which support them, arranged by app. For example:
@ -20,38 +28,23 @@ A dictionary of particular features (e.g. custom fields) mapped to the NetBox mo
... ...
}, },
'webhooks': { 'webhooks': {
... 'extras': ['configcontext', 'tag', ...],
'dcim': ['site', 'rack', 'devicetype', ...],
}, },
... ...
} }
``` ```
### `plugin_menu_items` Supported model features are listed in the [features matrix](./models.md#features-matrix).
Navigation menu items provided by NetBox plugins. Each plugin is registered as a key with the list of menu items it provides. An example: ### `plugins`
```python This store maintains all registered items for plugins, such as navigation menus, template extensions, etc.
{
'Plugin A': (
<MenuItem>, <MenuItem>, <MenuItem>,
),
'Plugin B': (
<MenuItem>, <MenuItem>, <MenuItem>,
),
}
```
### `plugin_template_extensions` ### `search`
Plugin content that gets embedded into core NetBox templates. The store comprises NetBox models registered as dictionary keys, each pointing to a list of applicable template extension classes that exist. An example: A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.
```python ### `views`
{
'dcim.site': [ A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`.
<TemplateExtension>, <TemplateExtension>, <TemplateExtension>,
],
'dcim.rack': [
<TemplateExtension>, <TemplateExtension>,
],
}
```

View File

@ -2,38 +2,43 @@
## Model Types ## Model Types
A NetBox model represents a discrete object type such as a device or IP address. Per [Django convention](https://docs.djangoproject.com/en/stable/topics/db/models/), each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type. A NetBox model represents a discrete object type such as a device or IP address. Per [Django convention](https://docs.djangoproject.com/en/stable/topics/db/models/), each model is defined as a Python class and has its own table in the PostgreSQL database. All NetBox data models can be categorized by type.
The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/) framework can be used to reference models within the database. A ContentType instance references a model by its `app_label` and `name`: For example, the Site model is referred to as `dcim.site`. The content type combined with an object's primary key form a globally unique identifier for the object (e.g. `dcim.site:123`). The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/) framework is used to map Django models to database tables. A ContentType instance references a model by its `app_label` and `name`: For example, the Site model within the DCIM app is referred to as `dcim.site`. The content type combined with an object's primary key form a globally unique identifier for the object (e.g. `dcim.site:123`).
### Features Matrix ### Features Matrix
* [Change logging](../features/change-logging.md) - Changes to these objects are automatically recorded in the change log Depending on its classification, each NetBox model may support various features which enhance its operation. Each feature is enabled by inheriting from its designated mixin class, and some features also make use of the [application registry](./application-registry.md#model_features).
* [Webhooks](../integrations/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
* [Custom fields](../customization/custom-fields.md) - These models support the addition of user-defined fields
* [Export templates](../customization/export-templates.md) - Users can create custom export templates for these models
* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags
* [Journaling](../features/journaling.md) - These models support persistent historical commentary
* Nesting - These models can be nested recursively to create a hierarchy
| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | | Feature | Feature Mixin | Registry Key | Description |
| ------------------ | ---------------- | ---------------- |------------------| ---------------- | ---------------- | ---------------- | ---------------- | |------------------------------------------------------------|-------------------------|--------------------|--------------------------------------------------------------------------------|
| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | - | Changes to these objects are automatically recorded in the change log |
| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | Cloning | `CloningMixin` | - | Provides the `clone()` method to prepare a copy |
| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: | | [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
| Component Template | :material-check: | :material-check: | | | | | | | [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
| [Job results](../features/background-jobs.md) | `JobResultsMixin` | `job_results` | Users can create custom export templates for these models |
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
| [Webhooks](../integrations/webhooks.md) | `WebhooksMixin` | `webhooks` | NetBox is capable of generating outgoing webhooks for these objects |
## Models Index ## Models Index
### Primary Models ### Primary Models
These are considered the "core" application models which are used to model network infrastructure.
* [circuits.Circuit](../models/circuits/circuit.md) * [circuits.Circuit](../models/circuits/circuit.md)
* [circuits.Provider](../models/circuits/provider.md) * [circuits.Provider](../models/circuits/provider.md)
* [circuits.ProviderNetwork](../models/circuits/providernetwork.md) * [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
* [core.DataSource](../models/core/datasource.md)
* [dcim.Cable](../models/dcim/cable.md) * [dcim.Cable](../models/dcim/cable.md)
* [dcim.Device](../models/dcim/device.md) * [dcim.Device](../models/dcim/device.md)
* [dcim.DeviceType](../models/dcim/devicetype.md) * [dcim.DeviceType](../models/dcim/devicetype.md)
* [dcim.Module](../models/dcim/module.md)
* [dcim.ModuleType](../models/dcim/moduletype.md)
* [dcim.PowerFeed](../models/dcim/powerfeed.md) * [dcim.PowerFeed](../models/dcim/powerfeed.md)
* [dcim.PowerPanel](../models/dcim/powerpanel.md) * [dcim.PowerPanel](../models/dcim/powerpanel.md)
* [dcim.Rack](../models/dcim/rack.md) * [dcim.Rack](../models/dcim/rack.md)
@ -47,10 +52,10 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.IPAddress](../models/ipam/ipaddress.md) * [ipam.IPAddress](../models/ipam/ipaddress.md)
* [ipam.IPRange](../models/ipam/iprange.md) * [ipam.IPRange](../models/ipam/iprange.md)
* [ipam.L2VPN](../models/ipam/l2vpn.md) * [ipam.L2VPN](../models/ipam/l2vpn.md)
* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md)
* [ipam.Prefix](../models/ipam/prefix.md) * [ipam.Prefix](../models/ipam/prefix.md)
* [ipam.RouteTarget](../models/ipam/routetarget.md) * [ipam.RouteTarget](../models/ipam/routetarget.md)
* [ipam.Service](../models/ipam/service.md) * [ipam.Service](../models/ipam/service.md)
* [ipam.ServiceTemplate](../models/ipam/servicetemplate.md)
* [ipam.VLAN](../models/ipam/vlan.md) * [ipam.VLAN](../models/ipam/vlan.md)
* [ipam.VRF](../models/ipam/vrf.md) * [ipam.VRF](../models/ipam/vrf.md)
* [tenancy.Contact](../models/tenancy/contact.md) * [tenancy.Contact](../models/tenancy/contact.md)
@ -62,6 +67,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
### Organizational Models ### Organizational Models
Organization models are used to organize and classify primary models.
* [circuits.CircuitType](../models/circuits/circuittype.md) * [circuits.CircuitType](../models/circuits/circuittype.md)
* [dcim.DeviceRole](../models/dcim/devicerole.md) * [dcim.DeviceRole](../models/dcim/devicerole.md)
* [dcim.Manufacturer](../models/dcim/manufacturer.md) * [dcim.Manufacturer](../models/dcim/manufacturer.md)
@ -76,6 +83,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
### Nested Group Models ### Nested Group Models
Nested group models behave like organizational model, but self-nest within a recursive hierarchy. For example, the Region model can be used to represent a hierarchy of countries, states, and cities.
* [dcim.Location](../models/dcim/location.md) (formerly RackGroup) * [dcim.Location](../models/dcim/location.md) (formerly RackGroup)
* [dcim.Region](../models/dcim/region.md) * [dcim.Region](../models/dcim/region.md)
* [dcim.SiteGroup](../models/dcim/sitegroup.md) * [dcim.SiteGroup](../models/dcim/sitegroup.md)
@ -85,12 +94,15 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
### Component Models ### Component Models
Component models represent individual physical or virtual components belonging to a device or virtual machine.
* [dcim.ConsolePort](../models/dcim/consoleport.md) * [dcim.ConsolePort](../models/dcim/consoleport.md)
* [dcim.ConsoleServerPort](../models/dcim/consoleserverport.md) * [dcim.ConsoleServerPort](../models/dcim/consoleserverport.md)
* [dcim.DeviceBay](../models/dcim/devicebay.md) * [dcim.DeviceBay](../models/dcim/devicebay.md)
* [dcim.FrontPort](../models/dcim/frontport.md) * [dcim.FrontPort](../models/dcim/frontport.md)
* [dcim.Interface](../models/dcim/interface.md) * [dcim.Interface](../models/dcim/interface.md)
* [dcim.InventoryItem](../models/dcim/inventoryitem.md) * [dcim.InventoryItem](../models/dcim/inventoryitem.md)
* [dcim.ModuleBay](../models/dcim/modulebay.md)
* [dcim.PowerOutlet](../models/dcim/poweroutlet.md) * [dcim.PowerOutlet](../models/dcim/poweroutlet.md)
* [dcim.PowerPort](../models/dcim/powerport.md) * [dcim.PowerPort](../models/dcim/powerport.md)
* [dcim.RearPort](../models/dcim/rearport.md) * [dcim.RearPort](../models/dcim/rearport.md)
@ -98,11 +110,15 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
### Component Template Models ### Component Template Models
These function as templates to effect the replication of device and virtual machine components. Component template models support a limited feature set, including change logging, custom validation, and webhooks.
* [dcim.ConsolePortTemplate](../models/dcim/consoleporttemplate.md) * [dcim.ConsolePortTemplate](../models/dcim/consoleporttemplate.md)
* [dcim.ConsoleServerPortTemplate](../models/dcim/consoleserverporttemplate.md) * [dcim.ConsoleServerPortTemplate](../models/dcim/consoleserverporttemplate.md)
* [dcim.DeviceBayTemplate](../models/dcim/devicebaytemplate.md) * [dcim.DeviceBayTemplate](../models/dcim/devicebaytemplate.md)
* [dcim.FrontPortTemplate](../models/dcim/frontporttemplate.md) * [dcim.FrontPortTemplate](../models/dcim/frontporttemplate.md)
* [dcim.InterfaceTemplate](../models/dcim/interfacetemplate.md) * [dcim.InterfaceTemplate](../models/dcim/interfacetemplate.md)
* [dcim.InventoryItemTemplate](../models/dcim/inventoryitemtemplate.md)
* [dcim.ModuleBayTemplate](../models/dcim/modulebaytemplate.md)
* [dcim.PowerOutletTemplate](../models/dcim/poweroutlettemplate.md) * [dcim.PowerOutletTemplate](../models/dcim/poweroutlettemplate.md)
* [dcim.PowerPortTemplate](../models/dcim/powerporttemplate.md) * [dcim.PowerPortTemplate](../models/dcim/powerporttemplate.md)
* [dcim.RearPortTemplate](../models/dcim/rearporttemplate.md) * [dcim.RearPortTemplate](../models/dcim/rearporttemplate.md)

View File

@ -0,0 +1,13 @@
# Background Jobs
NetBox includes the ability to execute certain functions as background tasks. These include:
* [Report](../customization/reports.md) execution
* [Custom script](../customization/custom-scripts.md) execution
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [JobResult model](../models/extras/jobresult.md). Background tasks are executed by the `rqworker` process(es).
## Scheduled Jobs
Background jobs can be configured to run immediately, or at a set time in the future. Scheduled jobs can also be configured to repeat at a set interval.

View File

@ -0,0 +1,38 @@
# Configuration Rendering
One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network.
```mermaid
flowchart TD
ConfigContext & ConfigTemplate --> Config{{Rendered configuration}}
click ConfigContext "../../models/extras/configcontext/"
click ConfigTemplate "../../models/extras/configtemplate/"
```
## Configuration Templates
Configuration templates are written in the [Jinja2 templating language](https://jinja.palletsprojects.com/), and may be automatically populated from remote data sources. Context data is applied to a template during rendering to output a complete configuration file. Below is an example template.
```jinja2
{% extends 'base.j2' %}
{% block content %}
system {
host-name {{ device.name }};
domain-name example.com;
time-zone UTC;
authentication-order [ password radius ];
ntp {
{% for server in ntp_servers %}
server {{ server }};
{% endfor %}
}
}
{% for interface in device.interfaces.all() %}
{% include 'common/interface.j2' %}
{% endfor %}
{% endblock %}
```
When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device.

View File

@ -11,6 +11,8 @@ Configuration context data (or "config contexts" for short) is a powerful featur
} }
``` ```
Context data can be consumed by remote API clients, or it can be employed natively to render [configuration templates](./configuration-rendering.md).
Config contexts can be computed for objects based on the following criteria: Config contexts can be computed for objects based on the following criteria:
| Type | Devices | Virtual Machines | | Type | Devices | Virtual Machines |

View File

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

View File

@ -0,0 +1,9 @@
# Synchronized Data
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
The following features support the use of synchronized data:
* [Configuration templates](../features/configuration-rendering.md)
* [Configuration context data](../features/context-data.md)
* [Export templates](../customization/export-templates.md)

View File

@ -4,7 +4,7 @@
NetBox was originally developed by its lead maintainer, [Jeremy Stretch](https://github.com/jeremystretch), while he was working as a network engineer at [DigitalOcean](https://www.digitalocean.com/) in 2015 as part of an effort to automate their network provisioning. Recognizing the new tool's potential, DigitalOcean agreed to release it as an open source project in June 2016. NetBox was originally developed by its lead maintainer, [Jeremy Stretch](https://github.com/jeremystretch), while he was working as a network engineer at [DigitalOcean](https://www.digitalocean.com/) in 2015 as part of an effort to automate their network provisioning. Recognizing the new tool's potential, DigitalOcean agreed to release it as an open source project in June 2016.
Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation. Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation. Today, the open source project is stewarded by [NetBox Labs](https://netboxlabs.com/) and a team of volunteer maintainers. Beyond the core product, myriad [plugins](https://netbox.dev/plugins/) have been developed by the NetBox community to enhance and expand its feature set.
## Key Features ## Key Features
@ -17,6 +17,7 @@ NetBox was built specifically to serve the needs of network engineers and operat
* AS number (ASN) management * AS number (ASN) management
* Rack elevations with SVG rendering * Rack elevations with SVG rendering
* Device modeling using pre-defined types * Device modeling using pre-defined types
* Virtual chassis and device contexts
* Network, power, and console cabling with SVG traces * Network, power, and console cabling with SVG traces
* Power distribution modeling * Power distribution modeling
* Data circuit and provider tracking * Data circuit and provider tracking
@ -29,12 +30,13 @@ NetBox was built specifically to serve the needs of network engineers and operat
* Tenant ownership assignment * Tenant ownership assignment
* Device & VM configuration contexts for advanced configuration rendering * Device & VM configuration contexts for advanced configuration rendering
* Custom fields for data model extension * Custom fields for data model extension
* Support for custom validation rules * Custom validation rules
* Custom reports & scripts executable directly within the UI * Custom reports & scripts executable directly within the UI
* Extensive plugin framework for adding custom functionality * Extensive plugin framework for adding custom functionality
* Single sign-on (SSO) authentication * Single sign-on (SSO) authentication
* Robust object-based permissions * Robust object-based permissions
* Detailed, automatic change logging * Detailed, automatic change logging
* Global search engine
* NAPALM integration * NAPALM integration
## What NetBox Is Not ## What NetBox Is Not

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

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

View File

@ -72,6 +72,10 @@ The device's operational status.
A device may be associated with a particular [platform](./platform.md) to indicate its operating system. Note that only platforms assigned to the associated manufacturer (or to no manufacturer) will be available for selection. A device may be associated with a particular [platform](./platform.md) to indicate its operating system. Note that only platforms assigned to the associated manufacturer (or to no manufacturer) will be available for selection.
### Configuration Template
The [configuration template](../extras/configtemplate.md) from which the configuration for this device can be rendered. If set, this will override any config template referenced by the device's role or platform.
### Primary IPv4 & IPv6 Addresses ### Primary IPv4 & IPv6 Addresses
Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes. Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes.

View File

@ -19,3 +19,7 @@ The color used when displaying the role in the NetBox UI.
### VM Role ### VM Role
If selected, this role may be assigned to [virtual machines](../virtualization/virtualmachine.md) If selected, this role may be assigned to [virtual machines](../virtualization/virtualmachine.md)
### Configuration Template
The default [configuration template](../extras/configtemplate.md) for devices assigned to this role.

View File

@ -22,6 +22,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
If designated, this platform will be available for use only to devices assigned to this [manufacturer](./manufacturer.md). This can be handy e.g. for limiting network operating systems to use on hardware produced by the relevant vendor. However, it should not be used when defining general-purpose software platforms. If designated, this platform will be available for use only to devices assigned to this [manufacturer](./manufacturer.md). This can be handy e.g. for limiting network operating systems to use on hardware produced by the relevant vendor. However, it should not be used when defining general-purpose software platforms.
### Configuration Template
The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
### NAPALM Driver ### NAPALM Driver
The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform. The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.

View File

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

View File

@ -0,0 +1,29 @@
# Configuration Templates
Configuration templates can be used to render [devices](../dcim/device.md) configurations from [context data](../../features/context-data.md). Templates are written in the [Jinja2 language](https://jinja.palletsprojects.com/) and can be associated with devices roles, platforms, and/or individual devices.
Context data is made available to [devices](../dcim/device.md) and/or [virtual machines](../virtualization/virtualmachine.md) based on their relationships to other objects in NetBox. For example, context data can be associated only with devices assigned to a particular site, or only to virtual machines in a certain cluster.
See the [configuration rendering documentation](../../features/configuration-rendering.md) for more information.
## Fields
### Name
A unique human-friendly name.
### Weight
A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
### 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 template code: It will be populated automatically from the data file.
### Template Code
Jinja2 template code, if being defined locally rather than replicated from a data file.
### Environment Parameters
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.

View File

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

View File

@ -0,0 +1,54 @@
# Job Results
The JobResult model is used to schedule and record the execution of [background tasks](../../features/background-jobs.md).
## Fields
### Name
The name or other identifier of the NetBox object with which the job is associated.
## Object Type
The type of object (model) associated with this job.
### Created
The date and time at which the job itself was created.
### Scheduled
The date and time at which the job is/was scheduled to execute (if not submitted for immediate execution at the time of creation).
### Interval
The interval (in minutes) at which a scheduled job should re-execute.
### Completed
The date and time at which the job completed (if complete).
### User
The user who created the job.
### Status
The job's current status. Potential values include:
| Value | Description |
|-------|-------------|
| Pending | Awaiting execution by an RQ worker process |
| Scheduled | Scheduled for a future date/time |
| Running | Currently executing |
| Completed | Successfully completed |
| Failed | The job did not complete successfully |
| Errored | An unexpected error was encountered during execution |
### Data
Any data associated with the execution of the job, such as log output.
### Job ID
The job's UUID, used for unique identification within a queue.

View File

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

View File

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

View File

@ -97,7 +97,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
### Examples ### Examples
`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied. `status` is "active" and `primary_ip4` is defined _or_ the "exempt" tag is applied.
```json ```json
{ {
@ -109,8 +109,8 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
"value": "active" "value": "active"
}, },
{ {
"attr": "primary_ip", "attr": "primary_ip4",
"value": "", "value": null,
"negate": true "negate": true
} }
] ]

View File

@ -1,19 +1,51 @@
# NetBox v3.4 # NetBox v3.4
## v3.4.4 (FUTURE) ## v3.4.5 (FUTURE)
### Enhancements
* [#11110](https://github.com/netbox-community/netbox/issues/11110) - Add `start_address` and `end_address` filters for IP ranges
* [#11592](https://github.com/netbox-community/netbox/issues/11592) - Introduce `FILE_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
* [#11685](https://github.com/netbox-community/netbox/issues/11685) - Match on containing prefixes and aggregates when querying for IP addresses using global search
### Bug Fixes
* [#11032](https://github.com/netbox-community/netbox/issues/11032) - Fix false custom validation errors during component creation
* [#11226](https://github.com/netbox-community/netbox/issues/11226) - Ensure scripts and reports within submodules are automatically reloaded
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Avoid exception when rendering change log after uninstalling a plugin
* [#11459](https://github.com/netbox-community/netbox/issues/11459) - Enable evaluating null values in custom validation rules
* [#11473](https://github.com/netbox-community/netbox/issues/11473) - GraphQL requests specifying an invalid filter should return an empty queryset
* [#11582](https://github.com/netbox-community/netbox/issues/11582) - Ensure form validation errors are displayed when adding virtual chassis members
* [#11601](https://github.com/netbox-community/netbox/issues/11601) - Fix partial matching of start/end addresses for IP range search
* [#11683](https://github.com/netbox-community/netbox/issues/11683) - Fix CSV header attribute detection when auto-detecting import format
* [#11711](https://github.com/netbox-community/netbox/issues/11711) - Fix CSV import for multiple-object custom fields
* [#11723](https://github.com/netbox-community/netbox/issues/11723) - Circuit terminations should link to their associated circuits (rather than site or provider network)
* [#11786](https://github.com/netbox-community/netbox/issues/11786) - List only applicable object types in form widget when filtering custom fields
---
## v3.4.4 (2023-02-02)
### Enhancements ### 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
--- ---

View File

@ -4,13 +4,17 @@
### Enhancements ### Enhancements
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI * [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
* [#11584](https://github.com/netbox-community/netbox/issues/11584) - Add a list view for contact assignments
* [#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
* [#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
* [#11693](https://github.com/netbox-community/netbox/issues/11693) - Enable syncing export template content from remote sources
### Other Changes ### Other Changes
* [#10604](https://github.com/netbox-community/netbox/issues/10604) - Remove unused `extra_tabs` block from `object.html` generic template * [#10604](https://github.com/netbox-community/netbox/issues/10604) - Remove unused `extra_tabs` block from `object.html` generic template
* [#10923](https://github.com/netbox-community/netbox/issues/10923) - Remove unused `NetBoxModelCSVForm` class (replaced by `NetBoxModelImportForm`) * [#10923](https://github.com/netbox-community/netbox/issues/10923) - Remove unused `NetBoxModelCSVForm` class (replaced by `NetBoxModelImportForm`)
* [#11611](https://github.com/netbox-community/netbox/issues/11611) - Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet * [#11611](https://github.com/netbox-community/netbox/issues/11611) - Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet
*

View File

@ -74,8 +74,10 @@ nav:
- Contacts: 'features/contacts.md' - Contacts: 'features/contacts.md'
- Search: 'features/search.md' - Search: 'features/search.md'
- Context Data: 'features/context-data.md' - Context Data: 'features/context-data.md'
- Configuration Rendering: 'features/configuration-rendering.md'
- Change Logging: 'features/change-logging.md' - Change Logging: 'features/change-logging.md'
- Journaling: 'features/journaling.md' - Journaling: 'features/journaling.md'
- Background Jobs: 'features/background-jobs.md'
- Auth & Permissions: 'features/authentication-permissions.md' - Auth & Permissions: 'features/authentication-permissions.md'
- API & Integration: 'features/api-integration.md' - API & Integration: 'features/api-integration.md'
- Customization: 'features/customization.md' - Customization: 'features/customization.md'
@ -116,6 +118,7 @@ nav:
- REST API: 'integrations/rest-api.md' - REST API: 'integrations/rest-api.md'
- GraphQL API: 'integrations/graphql-api.md' - GraphQL API: 'integrations/graphql-api.md'
- Webhooks: 'integrations/webhooks.md' - Webhooks: 'integrations/webhooks.md'
- Synchronized Data: 'integrations/synchronized-data.md'
- NAPALM: 'integrations/napalm.md' - NAPALM: 'integrations/napalm.md'
- Prometheus Metrics: 'integrations/prometheus-metrics.md' - Prometheus Metrics: 'integrations/prometheus-metrics.md'
- Plugins: - Plugins:
@ -152,6 +155,9 @@ nav:
- Circuit Type: 'models/circuits/circuittype.md' - Circuit Type: 'models/circuits/circuittype.md'
- Provider: 'models/circuits/provider.md' - Provider: 'models/circuits/provider.md'
- Provider Network: 'models/circuits/providernetwork.md' - Provider Network: 'models/circuits/providernetwork.md'
- Core:
- DataFile: 'models/core/datafile.md'
- DataSource: 'models/core/datasource.md'
- DCIM: - DCIM:
- Cable: 'models/dcim/cable.md' - Cable: 'models/dcim/cable.md'
- ConsolePort: 'models/dcim/consoleport.md' - ConsolePort: 'models/dcim/consoleport.md'
@ -196,10 +202,12 @@ nav:
- Extras: - Extras:
- Branch: 'models/extras/branch.md' - Branch: 'models/extras/branch.md'
- ConfigContext: 'models/extras/configcontext.md' - ConfigContext: 'models/extras/configcontext.md'
- ConfigTemplate: 'models/extras/configtemplate.md'
- CustomField: 'models/extras/customfield.md' - CustomField: 'models/extras/customfield.md'
- CustomLink: 'models/extras/customlink.md' - CustomLink: 'models/extras/customlink.md'
- ExportTemplate: 'models/extras/exporttemplate.md' - ExportTemplate: 'models/extras/exporttemplate.md'
- ImageAttachment: 'models/extras/imageattachment.md' - ImageAttachment: 'models/extras/imageattachment.md'
- JobResult: 'models/extras/jobresult.md'
- JournalEntry: 'models/extras/journalentry.md' - JournalEntry: 'models/extras/journalentry.md'
- SavedFilter: 'models/extras/savedfilter.md' - SavedFilter: 'models/extras/savedfilter.md'
- StagedChange: 'models/extras/stagedchange.md' - StagedChange: 'models/extras/stagedchange.md'

View File

@ -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')
) )

View File

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

View File

@ -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(),
} }

View File

@ -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
): ):
@ -196,12 +193,10 @@ class CircuitTermination(
) )
def __str__(self): def __str__(self):
return f'Termination {self.term_side}: {self.site or self.provider_network}' return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self): def get_absolute_url(self):
if self.site: return self.circuit.get_absolute_url()
return self.site.get_absolute_url()
return self.provider_network.get_absolute_url()
def clean(self): def clean(self):
super().clean() super().clean()

0
netbox/core/__init__.py Normal file
View File

View File

View 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']

View 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
View 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
View 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
View 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
View 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'),
)

View 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()

View File

@ -0,0 +1,2 @@
class SyncError(Exception):
pass

64
netbox/core/filtersets.py Normal file
View 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)
)

View File

@ -0,0 +1,4 @@
from .bulk_edit import *
from .bulk_import import *
from .filtersets import *
from .model_forms import *

View 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',
)

View 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',
)

View 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')
)

View File

@ -0,0 +1,76 @@
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, get_field_value
__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 Parameters', self.backend_fields)
)
return fieldsets
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Determine the selected backend type
backend_type = get_field_value(self, 'type')
backend = registry['data_backends'].get(backend_type)
# Add backend-specific form fields
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)

View File

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

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

View File

View 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.")

View File

@ -0,0 +1,66 @@
# 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'),
),
migrations.AddIndex(
model_name='datafile',
index=models.Index(fields=['source', 'path'], name='core_datafile_source_path'),
),
]

View File

View File

@ -0,0 +1 @@
from .data import *

316
netbox/core/models/data.py Normal file
View File

@ -0,0 +1,316 @@
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'
),
)
indexes = [
models.Index(fields=('source', 'path'), name='core_datafile_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
View 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
View 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()

View File

@ -0,0 +1 @@
from .data import *

View 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')

View File

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

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

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

View File

@ -10,6 +10,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.api.nested_serializers import NestedConfigTemplateSerializer
from ipam.api.nested_serializers import ( from ipam.api.nested_serializers import (
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
NestedVRFSerializer, NestedVRFSerializer,
@ -313,6 +314,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,
@ -328,7 +330,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',
] ]
@ -491,7 +493,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description',
'poe_mode', 'poe_type', 'created', 'last_updated', 'poe_mode', 'poe_type', 'created', 'last_updated',
] ]
@ -608,8 +610,8 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
'created', 'last_updated', 'device_count', 'virtualmachine_count', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
@ -622,8 +624,8 @@ class PlatformSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args',
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
@ -654,6 +656,7 @@ class DeviceSerializer(NetBoxModelSerializer):
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
class Meta: class Meta:
model = Device model = Device
@ -661,7 +664,7 @@ class DeviceSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)

View File

@ -361,7 +361,7 @@ class InventoryItemTemplateViewSet(NetBoxModelViewSet):
# #
class DeviceRoleViewSet(NetBoxModelViewSet): class DeviceRoleViewSet(NetBoxModelViewSet):
queryset = DeviceRole.objects.prefetch_related('tags').annotate( queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate(
device_count=count_related(Device, 'device_role'), device_count=count_related(Device, 'device_role'),
virtualmachine_count=count_related(VirtualMachine, 'role') virtualmachine_count=count_related(VirtualMachine, 'role')
) )
@ -374,7 +374,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
# #
class PlatformViewSet(NetBoxModelViewSet): class PlatformViewSet(NetBoxModelViewSet):
queryset = Platform.objects.prefetch_related('tags').annotate( queryset = Platform.objects.prefetch_related('config_template', 'tags').annotate(
device_count=count_related(Device, 'platform'), device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform') virtualmachine_count=count_related(VirtualMachine, 'platform')
) )

View File

@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from ipam.models import ASN, L2VPN, IPAddress, VRF from ipam.models import ASN, L2VPN, IPAddress, VRF
from netbox.filtersets import ( from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@ -436,6 +437,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'
@ -766,6 +777,10 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
class DeviceRoleFilterSet(OrganizationalModelFilterSet): class DeviceRoleFilterSet(OrganizationalModelFilterSet):
config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'),
)
class Meta: class Meta:
model = DeviceRole model = DeviceRole
@ -784,6 +799,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug', to_field_name='slug',
label=_('Manufacturer (slug)'), label=_('Manufacturer (slug)'),
) )
config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'),
)
class Meta: class Meta:
model = Platform model = Platform
@ -926,6 +945,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_virtual_chassis_member', method='_virtual_chassis_member',
label=_('Is a virtual chassis member') label=_('Is a virtual chassis member')
) )
config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'),
)
console_ports = django_filters.BooleanFilter( console_ports = django_filters.BooleanFilter(
method='_console_ports', method='_console_ports',
label=_('Has console ports'), label=_('Has console ports'),

View File

@ -6,12 +6,13 @@ from timezone_field import TimeZoneFormField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import ASN, VLAN, VLANGroup, VRF from ipam.models import ASN, VLAN, VLANGroup, VRF
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, 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 +97,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 +130,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 +165,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 +236,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 +253,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 +278,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 +295,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 +326,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 +337,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -374,6 +366,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 +384,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 +393,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 +427,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'
) )
@ -462,6 +455,10 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
widget=BulkEditNullBooleanSelect, widget=BulkEditNullBooleanSelect,
label=_('VM role') label=_('VM role')
) )
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
)
description = forms.CharField( description = forms.CharField(
max_length=200, max_length=200,
required=False required=False
@ -469,9 +466,9 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
model = DeviceRole model = DeviceRole
fieldsets = ( fieldsets = (
(None, ('color', 'vm_role', 'description')), (None, ('color', 'vm_role', 'config_template', 'description')),
) )
nullable_fields = ('color', 'description') nullable_fields = ('color', 'config_template', 'description')
class PlatformBulkEditForm(NetBoxModelBulkEditForm): class PlatformBulkEditForm(NetBoxModelBulkEditForm):
@ -483,7 +480,10 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
max_length=50, max_length=50,
required=False required=False
) )
# TODO: Bulk edit support for napalm_args config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
)
description = forms.CharField( description = forms.CharField(
max_length=200, max_length=200,
required=False required=False
@ -491,9 +491,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
model = Platform model = Platform
fieldsets = ( fieldsets = (
(None, ('manufacturer', 'napalm_driver', 'description')), (None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
) )
nullable_fields = ('manufacturer', 'napalm_driver', 'description') nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
class DeviceBulkEditForm(NetBoxModelBulkEditForm): class DeviceBulkEditForm(NetBoxModelBulkEditForm):
@ -533,13 +533,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,
@ -550,8 +548,12 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
)
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -560,6 +562,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
('Device', ('device_role', 'status', 'tenant', 'platform', 'description')), ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')),
('Location', ('site', 'location')), ('Location', ('site', 'location')),
('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
('Configuration', ('config_template',)),
) )
nullable_fields = ( nullable_fields = (
'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',
@ -581,8 +584,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,
@ -594,7 +596,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -609,13 +611,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(
@ -636,15 +636,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'
) )
@ -668,7 +667,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -714,7 +713,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -737,26 +736,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
@ -776,7 +771,7 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=forms.Textarea,
label=_('Comments') label=_('Comments')
) )
@ -803,8 +798,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')
@ -821,8 +815,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
@ -842,8 +835,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,
@ -879,8 +871,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(),
@ -888,8 +879,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
@ -920,8 +910,11 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
) )
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(InterfaceTypeChoices), choices=add_blank_choice(InterfaceTypeChoices),
required=False
)
enabled = forms.NullBooleanField(
required=False, required=False,
widget=StaticSelect() widget=BulkEditNullBooleanSelect
) )
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
required=False, required=False,
@ -939,14 +932,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')
) )
@ -964,8 +955,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
@ -988,8 +978,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
@ -1204,14 +1193,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(
@ -1221,8 +1208,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(),
@ -1422,8 +1408,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(),

View File

@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import VRF from ipam.models import VRF
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
@ -281,12 +282,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',
] ]
@ -302,11 +308,17 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class DeviceRoleImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm):
config_template = CSVModelChoiceField(
queryset=ConfigTemplate.objects.all(),
to_field_name='name',
required=False,
help_text=_('Config template')
)
slug = SlugField() slug = SlugField()
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags') fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = { help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')), 'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
} }
@ -320,10 +332,18 @@ class PlatformImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Limit platform assignments to this manufacturer') help_text=_('Limit platform assignments to this manufacturer')
) )
config_template = CSVModelChoiceField(
queryset=ConfigTemplate.objects.all(),
to_field_name='name',
required=False,
help_text=_('Config template')
)
class Meta: class Meta:
model = Platform model = Platform
fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags') fields = (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
)
class BaseDeviceImportForm(NetBoxModelImportForm): class BaseDeviceImportForm(NetBoxModelImportForm):
@ -429,12 +449,18 @@ class DeviceImportForm(BaseDeviceImportForm):
required=False, required=False,
help_text=_('Airflow direction') help_text=_('Airflow direction')
) )
config_template = CSVModelChoiceField(
queryset=ConfigTemplate.objects.all(),
to_field_name='name',
required=False,
help_text=_('Config template')
)
class Meta(BaseDeviceImportForm.Meta): class Meta(BaseDeviceImportForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis', 'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags',
] ]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):

View File

@ -3,6 +3,7 @@ from django.utils.translation import gettext as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from utilities.forms.utils import get_field_value
__all__ = ( __all__ = (
'InterfaceCommonForm', 'InterfaceCommonForm',
@ -23,6 +24,20 @@ class InterfaceCommonForm(forms.Form):
label=_('MTU') label=_('MTU')
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Determine the selected 802.1Q mode
interface_mode = get_field_value(self, 'mode')
# Delete VLAN tagging fields which are not relevant for the selected mode
if interface_mode in (InterfaceModeChoices.MODE_ACCESS, InterfaceModeChoices.MODE_TAGGED_ALL):
del self.fields['tagged_vlans']
elif not interface_mode:
del self.fields['vlan_group']
del self.fields['untagged_vlan']
del self.fields['tagged_vlans']
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -6,12 +6,13 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import LocalConfigContextFilterForm from extras.forms import LocalConfigContextFilterForm
from extras.models import ConfigTemplate
from ipam.models import ASN, L2VPN, VRF 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 +151,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 +209,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 +259,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 +379,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 +392,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 +518,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
) )
) )
@ -563,6 +569,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
class DeviceRoleFilterForm(NetBoxModelFilterSetForm): class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
model = DeviceRole model = DeviceRole
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False,
label=_('Config template')
)
tag = TagFilterField(model) tag = TagFilterField(model)
@ -573,6 +584,11 @@ class PlatformFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Manufacturer') label=_('Manufacturer')
) )
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False,
label=_('Config template')
)
tag = TagFilterField(model) tag = TagFilterField(model)
@ -593,7 +609,7 @@ class DeviceFilterForm(
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)), )),
('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data')) ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -657,11 +673,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
) )
@ -675,59 +691,64 @@ class DeviceFilterForm(
required=False, required=False,
label='MAC address' label='MAC address'
) )
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False,
label=_('Config template')
)
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 +771,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 +806,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 +899,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 +1001,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 +1036,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 +1051,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 +1065,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 +1084,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 +1103,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 +1118,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 +1145,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 +1158,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 +1182,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 +1237,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 +1255,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 +1314,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
) )
) )

View File

@ -7,13 +7,14 @@ from timezone_field import TimeZoneFormField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm 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 +130,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 +149,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 +216,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 +282,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 +328,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 +365,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 +386,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,24 +415,24 @@ 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):
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
)
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Device Role', ( ('Device Role', (
'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
)), )),
) )
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = [ fields = [
'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
] ]
@ -453,13 +441,17 @@ class PlatformForm(NetBoxModelForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
)
slug = SlugField( slug = SlugField(
max_length=64 max_length=64
) )
fieldsets = ( fieldsets = (
('Platform', ( ('Platform', (
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
)), )),
) )
@ -467,10 +459,10 @@ class PlatformForm(NetBoxModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
] ]
widgets = { widgets = {
'napalm_args': SmallTextarea(), 'napalm_args': forms.Textarea(),
} }
@ -582,6 +574,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Priority'), label=_('Priority'),
help_text=_("The priority of the device in the virtual chassis") help_text=_("The priority of the device in the virtual chassis")
) )
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
)
class Meta: class Meta:
model = Device model = Device
@ -589,7 +585,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
'description', 'comments', 'tags', 'local_context_data' 'description', 'config_template', 'comments', 'tags', 'local_context_data'
] ]
help_texts = { help_texts = {
'device_role': _("The function this device serves"), 'device_role': _("The function this device serves"),
@ -597,13 +593,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)
@ -737,11 +726,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)'
@ -856,12 +840,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(),
}
# #
@ -1025,9 +1003,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):
@ -1040,9 +1015,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):
@ -1057,9 +1029,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):
@ -1080,10 +1049,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):
@ -1097,11 +1062,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):
@ -1127,9 +1087,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):
@ -1142,9 +1099,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):
@ -1252,10 +1206,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):
@ -1271,10 +1221,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):
@ -1292,9 +1238,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):
@ -1319,10 +1262,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):
@ -1427,14 +1366,14 @@ 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(), 'mode': forms.Select(
'poe_type': StaticSelect(), attrs={
'duplex': StaticSelect(), 'hx-get': '.',
'mode': StaticSelect(), 'hx-include': '#form_fields input',
'rf_role': StaticSelect(), 'hx-target': '#form_fields',
'rf_channel': StaticSelect(), }
),
} }
labels = { labels = {
'mode': '802.1Q Mode', 'mode': '802.1Q Mode',
@ -1467,9 +1406,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):
@ -1484,9 +1420,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):
@ -1517,8 +1450,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):
@ -1767,8 +1699,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(),
}

View 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'),
),
]

View File

@ -0,0 +1,28 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('extras', '0086_configtemplate'),
('dcim', '0169_devicetype_default_platform'),
]
operations = [
migrations.AddField(
model_name='device',
name='config_template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'),
),
migrations.AddField(
model_name='devicerole',
name='config_template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='device_roles', to='extras.configtemplate'),
),
migrations.AddField(
model_name='platform',
name='config_template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='extras.configtemplate'),
),
]

View File

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

View File

@ -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,
@ -401,6 +410,13 @@ class DeviceRole(OrganizationalModel):
verbose_name='VM Role', verbose_name='VM Role',
help_text=_('Virtual machines may be assigned to this role') help_text=_('Virtual machines may be assigned to this role')
) )
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
related_name='device_roles',
blank=True,
null=True
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:devicerole', args=[self.pk]) return reverse('dcim:devicerole', args=[self.pk])
@ -420,6 +436,13 @@ class Platform(OrganizationalModel):
null=True, null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer') help_text=_('Optionally limit this platform to devices of a certain manufacturer')
) )
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
related_name='platforms',
blank=True,
null=True
)
napalm_driver = models.CharField( napalm_driver = models.CharField(
max_length=50, max_length=50,
blank=True, blank=True,
@ -581,6 +604,13 @@ class Device(PrimaryModel, ConfigContextModel):
null=True, null=True,
validators=[MaxValueValidator(255)] validators=[MaxValueValidator(255)]
) )
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
related_name='devices',
blank=True,
null=True
)
# Generic relations # Generic relations
contacts = GenericRelation( contacts = GenericRelation(
@ -801,6 +831,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
@ -849,6 +883,17 @@ class Device(PrimaryModel, ConfigContextModel):
def interfaces_count(self): def interfaces_count(self):
return self.vc_interfaces().count() return self.vc_interfaces().count()
def get_config_template(self):
"""
Return the appropriate ConfigTemplate (if any) for this Device.
"""
if self.config_template:
return self.config_template
if self.device_role.config_template:
return self.device_role.config_template
if self.platform and self.platform.config_template:
return self.platform.config_template
def get_vc_master(self): def get_vc_master(self):
""" """
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None. If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.

View File

@ -86,6 +86,9 @@ class DeviceRoleTable(NetBoxTable):
) )
color = columns.ColorColumn() color = columns.ColorColumn()
vm_role = columns.BooleanColumn() vm_role = columns.BooleanColumn()
config_template = tables.Column(
linkify=True
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:devicerole_list' url_name='dcim:devicerole_list'
) )
@ -93,8 +96,8 @@ class DeviceRoleTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.DeviceRole model = models.DeviceRole
fields = ( fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
'actions', 'created', 'last_updated', 'slug', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
@ -110,6 +113,9 @@ class PlatformTable(NetBoxTable):
manufacturer = tables.Column( manufacturer = tables.Column(
linkify=True linkify=True
) )
config_template = tables.Column(
linkify=True
)
device_count = columns.LinkedCountColumn( device_count = columns.LinkedCountColumn(
viewname='dcim:device_list', viewname='dcim:device_list',
url_params={'platform_id': 'pk'}, url_params={'platform_id': 'pk'},
@ -127,8 +133,8 @@ class PlatformTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.Platform model = models.Platform
fields = ( fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
'description', 'tags', 'actions', 'created', 'last_updated', 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
@ -203,6 +209,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
vc_priority = tables.Column( vc_priority = tables.Column(
verbose_name='VC Priority' verbose_name='VC Priority'
) )
config_template = tables.Column(
linkify=True
)
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:device_list' url_name='dcim:device_list'
@ -214,7 +223,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -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 = (

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import traceback
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
@ -10,6 +12,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
@ -595,6 +598,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):
@ -1996,6 +2000,39 @@ class DeviceInventoryView(DeviceComponentsView):
) )
@register_model_view(Device, 'render-config')
class DeviceRenderConfigView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/render_config.html'
tab = ViewTab(
label=_('Render Config'),
permission='extras.view_configtemplate',
weight=2000
)
def get_extra_context(self, request, instance):
# Compile context data
context_data = {
'device': instance,
}
context_data.update(**instance.get_config_context())
# Render the config template
rendered_config = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
messages.error(request, f"An error occurred while rendering the template: {e}")
rendered_config = traceback.format_exc()
return {
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
}
@register_model_view(Device, 'configcontext', path='config-context') @register_model_view(Device, 'configcontext', path='config-context')
class DeviceConfigContextView(ObjectConfigContextView): class DeviceConfigContextView(ObjectConfigContextView):
queryset = Device.objects.annotate_config_context_data() queryset = Device.objects.annotate_config_context_data()
@ -2003,7 +2040,7 @@ class DeviceConfigContextView(ObjectConfigContextView):
tab = ViewTab( tab = ViewTab(
label=_('Config Context'), label=_('Config Context'),
permission='extras.view_configcontext', permission='extras.view_configcontext',
weight=2000 weight=2100
) )

View File

@ -7,6 +7,7 @@ from users.api.nested_serializers import NestedUserSerializer
__all__ = [ __all__ = [
'NestedConfigContextSerializer', 'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer',
'NestedCustomFieldSerializer', 'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer', 'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer', 'NestedExportTemplateSerializer',
@ -51,6 +52,14 @@ class NestedConfigContextSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name'] fields = ['id', 'url', 'display', 'name']
class NestedConfigTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
class Meta:
model = models.ConfigTemplate
fields = ['id', 'url', 'display', 'name']
class NestedExportTemplateSerializer(WritableNestedSerializer): class NestedExportTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')

View File

@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
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,
@ -16,6 +17,7 @@ from extras.utils import FeatureQuery
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
from netbox.api.serializers.features import TaggableModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -29,6 +31,7 @@ from .nested_serializers import *
__all__ = ( __all__ = (
'ConfigContextSerializer', 'ConfigContextSerializer',
'ConfigTemplateSerializer',
'ContentTypeSerializer', 'ContentTypeSerializer',
'CustomFieldSerializer', 'CustomFieldSerializer',
'CustomLinkSerializer', 'CustomLinkSerializer',
@ -143,12 +146,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',
] ]
@ -360,13 +370,41 @@ 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',
]
#
# Config templates
#
class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
data_source = NestedDataSourceSerializer(
required=False
)
data_file = NestedDataFileSerializer(
read_only=True
)
class Meta:
model = ConfigTemplate
fields = [
'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
] ]

View File

@ -14,6 +14,7 @@ router.register('tags', views.TagViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet) router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet) router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet) router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-templates', views.ConfigTemplateViewSet)
router.register('reports', views.ReportViewSet, basename='report') router.register('reports', views.ReportViewSet, basename='report')
router.register('scripts', views.ScriptViewSet, basename='script') router.register('scripts', views.ScriptViewSet, basename='script')
router.register('object-changes', views.ObjectChangeViewSet) router.register('object-changes', views.ObjectChangeViewSet)

View File

@ -5,6 +5,7 @@ from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
@ -17,11 +18,14 @@ 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.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, count_related from utilities.utils import copy_safe_request, count_related
from . import serializers from . import serializers
from .nested_serializers import NestedConfigTemplateSerializer
class ExtrasRootView(APIRootView): class ExtrasRootView(APIRootView):
@ -91,9 +95,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,14 +151,44 @@ 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
#
# Config templates
#
class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file')
serializer_class = serializers.ConfigTemplateSerializer
filterset_class = filtersets.ConfigTemplateFilterSet
@action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
def render(self, request, pk):
"""
Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data,
return the raw rendered content, rather than serialized JSON.
"""
configtemplate = self.get_object()
output = configtemplate.render(context=request.data)
# If the client has requested "text/plain", return the raw content.
if request.accepted_renderer.format == 'txt':
return Response(output)
template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request})
return Response({
'configtemplate': template_serializer.data,
'content': output
})
# #
# Reports # Reports
# #

View File

@ -44,7 +44,8 @@ class Condition:
bool: (EQ, CONTAINS), bool: (EQ, CONTAINS),
int: (EQ, GT, GTE, LT, LTE, CONTAINS), int: (EQ, GT, GTE, LT, LTE, CONTAINS),
float: (EQ, GT, GTE, LT, LTE, CONTAINS), float: (EQ, GT, GTE, LT, LTE, CONTAINS),
list: (EQ, IN, CONTAINS) list: (EQ, IN, CONTAINS),
type(None): (EQ,)
} }
def __init__(self, attr, value, op=EQ, negate=False): def __init__(self, attr, value, op=EQ, negate=False):

View File

@ -1,13 +1,2 @@
# Webhook content types # Webhook content types
HTTP_CONTENT_TYPE_JSON = 'application/json' HTTP_CONTENT_TYPE_JSON = 'application/json'
# Registerable extras features
EXTRAS_FEATURES = [
'custom_fields',
'custom_links',
'export_templates',
'job_results',
'journaling',
'tags',
'webhooks'
]

8
netbox/extras/fields.py Normal file
View File

@ -0,0 +1,8 @@
from django.db.models import TextField
class CachedValueField(TextField):
"""
Currently a dummy field to prevent custom lookups being applied globally to TextField.
"""
pass

View File

@ -4,17 +4,19 @@ 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 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
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import * from .choices import *
from .filters import TagFilter
from .models import * from .models import *
__all__ = ( __all__ = (
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ConfigTemplateFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldFilterSet', 'CustomFieldFilterSet',
'CustomLinkFilterSet', 'CustomLinkFilterSet',
@ -126,10 +128,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 +432,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():
@ -437,6 +455,34 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
) )
class ConfigTemplateFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
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)'),
)
tag = TagFilter()
class Meta:
model = ConfigTemplate
fields = ['id', 'name', 'description', 'data_synced']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
# #
# Filter for Local Config Context Data # Filter for Local Config Context Data
# #

View File

@ -4,11 +4,12 @@ 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__ = (
'ConfigContextBulkEditForm', 'ConfigContextBulkEditForm',
'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm', 'CustomFieldBulkEditForm',
'CustomLinkBulkEditForm', 'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm', 'ExportTemplateBulkEditForm',
@ -41,8 +42,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 +66,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()
) )
@ -203,6 +202,19 @@ class ConfigContextBulkEditForm(BulkEditForm):
nullable_fields = ('description',) nullable_fields = ('description',)
class ConfigTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
max_length=200,
required=False
)
nullable_fields = ('description',)
class JournalEntryBulkEditForm(BulkEditForm): class JournalEntryBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(), queryset=JournalEntry.objects.all(),

View File

@ -10,6 +10,7 @@ from extras.utils import FeatureQuery
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
__all__ = ( __all__ = (
'ConfigTemplateImportForm',
'CustomFieldImportForm', 'CustomFieldImportForm',
'CustomLinkImportForm', 'CustomLinkImportForm',
'ExportTemplateImportForm', 'ExportTemplateImportForm',
@ -83,6 +84,15 @@ class ExportTemplateImportForm(CSVModelForm):
) )
class ConfigTemplateImportForm(CSVModelForm):
class Meta:
model = ConfigTemplate
fields = (
'name', 'description', 'environment_params', 'template_code', 'tags',
)
class SavedFilterImportForm(CSVModelForm): class SavedFilterImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),

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