mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 09:16:10 -06:00
9608 update merge
This commit is contained in:
commit
b6795f7cf8
@ -1,5 +1,7 @@
|
||||
# NAPALM Parameters
|
||||
|
||||
!!! **Note:** As of NetBox v3.5, NAPALM integration has been moved to a plugin and these configuration parameters are now deprecated.
|
||||
|
||||
## NAPALM_USERNAME
|
||||
|
||||
## NAPALM_PASSWORD
|
||||
|
@ -16,6 +16,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
|
||||
* Decimal: A fixed-precision decimal number (4 decimal places)
|
||||
* Boolean: True or false
|
||||
* Date: A date in ISO 8601 format (YYYY-MM-DD)
|
||||
* Date & time: A date and time in ISO 8601 format (YYYY-MM-DD HH:MM:SS)
|
||||
* URL: This will be presented as a link in the web UI
|
||||
* JSON: Arbitrary data stored in JSON format
|
||||
* Selection: A selection of one of several pre-defined custom choices
|
||||
|
@ -36,6 +36,8 @@ To learn more about this feature, check out the [webhooks documentation](../inte
|
||||
|
||||
To learn more about this feature, check out the [NAPALM documentation](../integrations/napalm.md).
|
||||
|
||||
As of NetBox v3.5, NAPALM integration has been moved to a plugin. Please see the [netbox_napalm_plugin](https://github.com/netbox-community/netbox-napalm) for installation instructions.
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
NetBox includes a special `/metrics` view which exposes metrics for a [Prometheus](https://prometheus.io/) scraper, powered by the open source [django-prometheus](https://github.com/korfuri/django-prometheus) library. To learn more about this feature, check out the [Prometheus metrics documentation](../integrations/prometheus-metrics.md).
|
||||
|
@ -199,14 +199,6 @@ When you have finished modifying the configuration, remember to save the file.
|
||||
|
||||
All Python packages required by NetBox are listed in `requirements.txt` and will be installed automatically. NetBox also supports some optional packages. If desired, these packages must be listed in `local_requirements.txt` within the NetBox root directory.
|
||||
|
||||
### NAPALM
|
||||
|
||||
Integration with the [NAPALM automation](../integrations/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
|
||||
|
||||
```no-highlight
|
||||
sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt"
|
||||
```
|
||||
|
||||
### Remote File Storage
|
||||
|
||||
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`.
|
||||
|
@ -1,74 +1,3 @@
|
||||
# NAPALM
|
||||
|
||||
NetBox supports integration with the [NAPALM automation](https://github.com/napalm-automation/napalm) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
|
||||
|
||||
The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met:
|
||||
|
||||
* Device status is "Active"
|
||||
* A primary IP has been assigned to the device
|
||||
* A platform with a NAPALM driver has been assigned
|
||||
* The authenticated user has the `dcim.napalm_read_device` permission
|
||||
|
||||
!!! note
|
||||
To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.
|
||||
|
||||
Below is an example REST API request and response:
|
||||
|
||||
```no-highlight
|
||||
GET /api/dcim/devices/1/napalm/?method=get_environment
|
||||
|
||||
{
|
||||
"get_environment": {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
!!! note
|
||||
To make NAPALM requests via the NetBox REST API, a NetBox user must have assigned a permission granting the `napalm_read` action for the device object type.
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, the [`NAPALM_USERNAME`](../configuration/napalm.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/napalm.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
|
||||
|
||||
```
|
||||
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-H "X-NAPALM-Username: foo" \
|
||||
-H "X-NAPALM-Password: bar"
|
||||
```
|
||||
|
||||
## Method Support
|
||||
|
||||
The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. Because there is no granular mechanism in place for limiting potentially disruptive requests, NetBox supports only read-only [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods.
|
||||
|
||||
## Multiple Methods
|
||||
|
||||
It is possible to request the output of multiple NAPALM methods in a single API request by passing multiple `method` parameters. For example:
|
||||
|
||||
```no-highlight
|
||||
GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers
|
||||
|
||||
{
|
||||
"get_ntp_servers": {
|
||||
...
|
||||
},
|
||||
"get_ntp_peers": {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Optional Arguments
|
||||
|
||||
The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`. For example, the SSH port is changed to 2222 in this API call:
|
||||
|
||||
```
|
||||
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-H "X-NAPALM-port: 2222"
|
||||
```
|
||||
As of NetBox v3.5, NAPALM integration has been moved to a plugin. Please see the [netbox_napalm_plugin](https://github.com/netbox-community/netbox-napalm) for installation instructions. **Note:** All previously entered NAPALM configuration data will be saved and automatically imported by the new plugin.
|
||||
|
@ -22,11 +22,13 @@ If not selected, the webhook will be inactive.
|
||||
|
||||
The events which will trigger the webhook. At least one event type must be selected.
|
||||
|
||||
| Name | Description |
|
||||
|-----------|--------------------------------------|
|
||||
| Creations | A new object has been created |
|
||||
| Updates | An existing object has been modified |
|
||||
| Deletions | An object has been deleted |
|
||||
| Name | Description |
|
||||
|------------|--------------------------------------|
|
||||
| Creations | A new object has been created |
|
||||
| Updates | An existing object has been modified |
|
||||
| Deletions | An object has been deleted |
|
||||
| Job starts | A job for an object starts |
|
||||
| Job ends | A job for an object terminates |
|
||||
|
||||
### URL
|
||||
|
||||
@ -58,6 +60,10 @@ Jinja2 template for a custom request body, if desired. If not defined, NetBox wi
|
||||
|
||||
A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key.
|
||||
|
||||
### Conditions
|
||||
|
||||
A set of [prescribed conditions](../../reference/conditions.md) against which the triggering object will be evaluated. If the conditions are defined but not met by the object, the webhook will not be sent. A webhook that does not define any conditions will _always_ trigger.
|
||||
|
||||
### SSL Verification
|
||||
|
||||
Controls whether validation of the receiver's SSL certificate is enforced when HTTPS is used.
|
||||
|
@ -1,8 +1,8 @@
|
||||
# ASN
|
||||
# ASNs
|
||||
|
||||
An Autonomous System Number (ASN) is a numeric identifier used in the BGP protocol to identify which [autonomous system](https://en.wikipedia.org/wiki/Autonomous_system_%28Internet%29) a particular prefix is originating and transiting through. NetBox support both 32- and 64- ASNs.
|
||||
|
||||
ASNs must be globally unique within NetBox, must each may be assigned to multiple [sites](../dcim/site.md).
|
||||
ASNs must be globally unique within NetBox, and may be allocated from within a [defined range](./asnrange.md). Each ASN may be assigned to multiple [sites](../dcim/site.md).
|
||||
|
||||
## Fields
|
||||
|
||||
|
21
docs/models/ipam/asnrange.md
Normal file
21
docs/models/ipam/asnrange.md
Normal file
@ -0,0 +1,21 @@
|
||||
# ASN Ranges
|
||||
|
||||
Ranges can be defined to group [AS numbers](./asn.md) numerically and to facilitate their automatic provisioning. Each range must be assigned to a [RIR](./rir.md).
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A unique human-friendly name for the range.
|
||||
|
||||
### Slug
|
||||
|
||||
A unique URL-friendly identifier. (This value can be used for filtering.)
|
||||
|
||||
### RIR
|
||||
|
||||
The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of AS numbers within this range.
|
||||
|
||||
### Start & End
|
||||
|
||||
The starting and ending numeric boundaries of the range (inclusive).
|
@ -28,3 +28,7 @@ The IP range's operational status. Note that the status of a range does _not_ ha
|
||||
|
||||
!!! tip
|
||||
Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### Mark Utilized
|
||||
|
||||
If enabled, the IP range will be considered 100% utilized regardless of how many IP addresses are defined within it. This is useful for documenting DHCP ranges, for example.
|
||||
|
@ -6,11 +6,11 @@ A tenant represents a discrete grouping of resources used for administrative pur
|
||||
|
||||
### Name
|
||||
|
||||
A unique human-friendly name.
|
||||
A human-friendly name, unique to the assigned group.
|
||||
|
||||
### Slug
|
||||
|
||||
A unique URL-friendly identifier. (This value can be used for filtering.)
|
||||
A URL-friendly identifier, unique to the assigned group. (This value can be used for filtering.)
|
||||
|
||||
### Group
|
||||
|
||||
|
@ -2,9 +2,37 @@
|
||||
|
||||
## v3.5.0 (FUTURE)
|
||||
|
||||
### New Features
|
||||
|
||||
#### Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416))
|
||||
|
||||
The static home view has been replaced with a fully customizable dashboard. Users can construct and rearrange their own personal dashboard to convey the information most pertinent to them. Supported widgets include object statistics, change log records, notes, and more, and we expect to continue adding new widgets over time. Plugins can also register their own custom widgets.
|
||||
|
||||
#### Remote Data Sources ([#11558](https://github.com/netbox-community/netbox/issues/11558))
|
||||
|
||||
NetBox now has the ability to synchronize arbitrary data from external sources through the new [DataSource](../models/core/datasource.md) and [DataFile](../models/core/datafile.md) models. Synchronized files are stored in the PostgreSQL database, and may be referenced and consumed by other NetBox models, such as export templates and config contexts. Currently, replication from local filesystem paths and from git repositories is supported, and we expect to add support for additional backends in the near future.
|
||||
|
||||
#### Configuration Template Rendering ([#11559](https://github.com/netbox-community/netbox/issues/11559))
|
||||
|
||||
This release introduces the ability to render device configurations from Jinja2 templates natively within NetBox, via both the UI and REST API. The new [ConfigTemplate](../models/extras/configtemplate.md) model stores template code (which may be defined locally or sourced from remote data files). The rendering engine passes data gleaned from both config contexts and request parameters to generate complete configurations suitable for direct application to network devices.
|
||||
|
||||
#### NAPALM Plugin ([#10520](https://github.com/netbox-community/netbox/issues/10520))
|
||||
|
||||
The NAPALM integration feature found in previous NetBox releases has been moved from the core application to a dedicated plugin. This allows greater control over the feature's configuration and will unlock additional potential as a separate project.
|
||||
|
||||
#### ASN Ranges ([#8550](https://github.com/netbox-community/netbox/issues/8550))
|
||||
|
||||
A new ASN range model has been introduced to facilitate the provisioning of new autonomous system numbers from within a prescribed range. For example, an administrator might define an ASN range of 65000-65099 to be used for internal site identification. This includes a REST API endpoint suitable for automatic provisioning, very similar to the allocation of available prefixes and IP addresses.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7947](https://github.com/netbox-community/netbox/issues/7947) - Enable marking IP ranges as fully utilized
|
||||
* [#8272](https://github.com/netbox-community/netbox/issues/8272) - Support bridge relationships among device type interfaces
|
||||
* [#8958](https://github.com/netbox-community/netbox/issues/8958) - Changes in background job status can trigger webhooks
|
||||
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
|
||||
* [#9653](https://github.com/netbox-community/netbox/issues/9653) - Enable setting a default platform for device types
|
||||
* [#10374](https://github.com/netbox-community/netbox/issues/10374) - Require unique tenant names & slugs per group (not globally)
|
||||
* [#10729](https://github.com/netbox-community/netbox/issues/10729) - Add date & time custom field type
|
||||
* [#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
|
||||
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI
|
||||
@ -17,4 +45,7 @@
|
||||
* [#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`)
|
||||
* [#11611](https://github.com/netbox-community/netbox/issues/11611) - Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet
|
||||
* [#11694](https://github.com/netbox-community/netbox/issues/11694) - Remove obsolete `SmallTextarea` form widget
|
||||
* [#11737](https://github.com/netbox-community/netbox/issues/11737) - `ChangeLoggedModel` now inherits `WebhooksMixin`
|
||||
* [#11765](https://github.com/netbox-community/netbox/issues/11765) - Retire the `StaticSelect` and `StaticSelectMultiple` form widgets
|
||||
*
|
||||
|
@ -215,6 +215,7 @@ nav:
|
||||
- Webhook: 'models/extras/webhook.md'
|
||||
- IPAM:
|
||||
- ASN: 'models/ipam/asn.md'
|
||||
- ASNRange: 'models/ipam/asnrange.md'
|
||||
- Aggregate: 'models/ipam/aggregate.md'
|
||||
- FHRPGroup: 'models/ipam/fhrpgroup.md'
|
||||
- FHRPGroupAssignment: 'models/ipam/fhrpgroupassignment.md'
|
||||
|
@ -47,9 +47,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ('name', 'slug', 'description', 'tags')
|
||||
help_texts = {
|
||||
'name': _('Name of circuit type'),
|
||||
}
|
||||
|
||||
|
||||
class CircuitImportForm(NetBoxModelImportForm):
|
||||
|
@ -37,9 +37,6 @@ class ProviderForm(NetBoxModelForm):
|
||||
fields = [
|
||||
'name', 'slug', 'account', 'asns', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'name': _("Full name of the provider"),
|
||||
}
|
||||
|
||||
|
||||
class ProviderNetworkForm(NetBoxModelForm):
|
||||
@ -96,10 +93,6 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
||||
'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||
'tenant_group', 'tenant', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'cid': _("Unique circuit ID"),
|
||||
'commit_rate': _("Committed rate"),
|
||||
}
|
||||
widgets = {
|
||||
'install_date': DatePicker(),
|
||||
'termination_date': DatePicker(),
|
||||
@ -166,11 +159,6 @@ class CircuitTerminationForm(NetBoxModelForm):
|
||||
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': _("Physical circuit speed"),
|
||||
'xconnect_id': _("ID of the local cross-connect"),
|
||||
'pp_info': _("Patch panel ID and port number(s)")
|
||||
}
|
||||
widgets = {
|
||||
'port_speed': SelectSpeedWidget(),
|
||||
'upstream_speed': SelectSpeedWidget(),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import dcim.fields
|
||||
import ipam.fields
|
||||
from utilities.json import CustomFieldJSONEncoder
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
@ -77,7 +77,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
('asn', dcim.fields.ASNField(blank=True, null=True)),
|
||||
('asn', ipam.fields.ASNField(blank=True, null=True)),
|
||||
('account', models.CharField(blank=True, max_length=30)),
|
||||
('portal_url', models.URLField(blank=True)),
|
||||
('noc_contact', models.TextField(blank=True)),
|
||||
|
@ -34,7 +34,8 @@ class Circuit(PrimaryModel):
|
||||
"""
|
||||
cid = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name='Circuit ID'
|
||||
verbose_name='Circuit ID',
|
||||
help_text=_("Unique circuit ID")
|
||||
)
|
||||
provider = models.ForeignKey(
|
||||
to='circuits.Provider',
|
||||
@ -71,7 +72,9 @@ class Circuit(PrimaryModel):
|
||||
commit_rate = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Commit rate (Kbps)')
|
||||
verbose_name='Commit rate (Kbps)',
|
||||
help_text=_("Committed rate")
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
@ -160,7 +163,8 @@ class CircuitTermination(
|
||||
port_speed = models.PositiveIntegerField(
|
||||
verbose_name='Port speed (Kbps)',
|
||||
blank=True,
|
||||
null=True
|
||||
null=True,
|
||||
help_text=_("Physical circuit speed")
|
||||
)
|
||||
upstream_speed = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
@ -171,12 +175,14 @@ class CircuitTermination(
|
||||
xconnect_id = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='Cross-connect ID'
|
||||
verbose_name='Cross-connect ID',
|
||||
help_text=_("ID of the local cross-connect")
|
||||
)
|
||||
pp_info = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
verbose_name='Patch panel/port(s)'
|
||||
verbose_name='Patch panel/port(s)',
|
||||
help_text=_("Patch panel ID and port number(s)")
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.models import PrimaryModel
|
||||
|
||||
@ -17,7 +18,8 @@ class Provider(PrimaryModel):
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
unique=True,
|
||||
help_text=_("Full name of the provider")
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
|
@ -51,7 +51,6 @@ class DataSourceForm(NetBoxModelForm):
|
||||
|
||||
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)
|
||||
|
@ -22,8 +22,9 @@ def sync_datasource(job_result, *args, **kwargs):
|
||||
# Update the search cache for DataFiles belonging to this source
|
||||
search_backend.cache(datasource.datafiles.iterator())
|
||||
|
||||
job_result.terminate()
|
||||
|
||||
except SyncError as e:
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
job_result.save()
|
||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
logging.error(e)
|
||||
|
@ -482,6 +482,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
bridge = NestedInterfaceTemplateSerializer(required=False, allow_null=True)
|
||||
poe_mode = ChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
@ -498,7 +499,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'bridge', 'enabled', 'mgmt_only', 'description',
|
||||
'poe_mode', 'poe_type', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
@ -414,113 +414,6 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
||||
@action(detail=True, url_path='napalm')
|
||||
def napalm(self, request, pk):
|
||||
"""
|
||||
Execute a NAPALM method on a Device
|
||||
"""
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
if not device.primary_ip:
|
||||
raise ServiceUnavailable("This device does not have a primary IP address configured.")
|
||||
if device.platform is None:
|
||||
raise ServiceUnavailable("No platform is configured for this device.")
|
||||
if not device.platform.napalm_driver:
|
||||
raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.")
|
||||
|
||||
# Check for primary IP address from NetBox object
|
||||
if device.primary_ip:
|
||||
host = str(device.primary_ip.address.ip)
|
||||
else:
|
||||
# Raise exception for no IP address and no Name if device.name does not exist
|
||||
if not device.name:
|
||||
raise ServiceUnavailable(
|
||||
"This device does not have a primary IP address or device name to lookup configured."
|
||||
)
|
||||
try:
|
||||
# Attempt to complete a DNS name resolution if no primary_ip is set
|
||||
host = socket.gethostbyname(device.name)
|
||||
except socket.gaierror:
|
||||
# Name lookup failure
|
||||
raise ServiceUnavailable(
|
||||
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or "
|
||||
f"setup name resolution.")
|
||||
|
||||
# Check that NAPALM is installed
|
||||
try:
|
||||
import napalm
|
||||
from napalm.base.exceptions import ModuleImportError
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'napalm':
|
||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||
raise e
|
||||
|
||||
# Validate the configured driver
|
||||
try:
|
||||
driver = napalm.get_network_driver(device.platform.napalm_driver)
|
||||
except ModuleImportError:
|
||||
raise ServiceUnavailable("NAPALM driver for platform {} not found: {}.".format(
|
||||
device.platform, device.platform.napalm_driver
|
||||
))
|
||||
|
||||
# Verify user permission
|
||||
if not request.user.has_perm('dcim.napalm_read_device'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
response = {m: None for m in napalm_methods}
|
||||
|
||||
config = get_config()
|
||||
username = config.NAPALM_USERNAME
|
||||
password = config.NAPALM_PASSWORD
|
||||
timeout = config.NAPALM_TIMEOUT
|
||||
optional_args = config.NAPALM_ARGS.copy()
|
||||
if device.platform.napalm_args is not None:
|
||||
optional_args.update(device.platform.napalm_args)
|
||||
|
||||
# Update NAPALM parameters according to the request headers
|
||||
for header in request.headers:
|
||||
if header[:9].lower() != 'x-napalm-':
|
||||
continue
|
||||
|
||||
key = header[9:]
|
||||
if key.lower() == 'username':
|
||||
username = request.headers[header]
|
||||
elif key.lower() == 'password':
|
||||
password = request.headers[header]
|
||||
elif key:
|
||||
optional_args[key.lower()] = request.headers[header]
|
||||
|
||||
# Connect to the device
|
||||
d = driver(
|
||||
hostname=host,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=timeout,
|
||||
optional_args=optional_args
|
||||
)
|
||||
try:
|
||||
d.open()
|
||||
except Exception as e:
|
||||
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(host, e))
|
||||
|
||||
# Validate and execute each specified NAPALM method
|
||||
for method in napalm_methods:
|
||||
if not hasattr(driver, method):
|
||||
response[method] = {'error': 'Unknown NAPALM method'}
|
||||
continue
|
||||
if not method.startswith('get_'):
|
||||
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
|
||||
continue
|
||||
try:
|
||||
response[method] = getattr(d, method)()
|
||||
except NotImplementedError:
|
||||
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
|
||||
except Exception as e:
|
||||
response[method] = {'error': 'Method {} failed: {}'.format(method, e)}
|
||||
d.close()
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualDeviceContext.objects.prefetch_related(
|
||||
|
@ -1,10 +1,8 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
|
||||
|
||||
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
|
||||
from .lookups import PathContains
|
||||
|
||||
__all__ = (
|
||||
@ -27,22 +25,6 @@ class eui64_unix_expanded_uppercase(eui64_unix_expanded):
|
||||
# Fields
|
||||
#
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
description = "32-bit ASN field"
|
||||
default_validators = [
|
||||
MinValueValidator(BGP_ASN_MIN),
|
||||
MaxValueValidator(BGP_ASN_MAX),
|
||||
]
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {
|
||||
'min_value': BGP_ASN_MIN,
|
||||
'max_value': BGP_ASN_MAX,
|
||||
}
|
||||
defaults.update(**kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
|
||||
class MACAddressField(models.Field):
|
||||
description = "PostgreSQL MAC Address field"
|
||||
|
||||
|
@ -806,7 +806,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
|
@ -476,10 +476,6 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
)
|
||||
napalm_driver = forms.CharField(
|
||||
max_length=50,
|
||||
required=False
|
||||
)
|
||||
config_template = DynamicModelChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
required=False
|
||||
@ -491,9 +487,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Platform
|
||||
fieldsets = (
|
||||
(None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
|
||||
(None, ('manufacturer', 'config_template', 'description')),
|
||||
)
|
||||
nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
|
||||
nullable_fields = ('manufacturer', 'config_template', 'description')
|
||||
|
||||
|
||||
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
@ -342,7 +342,7 @@ class PlatformImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = (
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
@ -394,10 +394,6 @@ class BaseDeviceImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
fields = []
|
||||
model = Device
|
||||
help_texts = {
|
||||
'vc_position': 'Virtual chassis position',
|
||||
'vc_priority': 'Virtual chassis priority',
|
||||
}
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
@ -775,9 +771,6 @@ class FrontPortImportForm(NetBoxModelImportForm):
|
||||
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
|
||||
'description', 'tags'
|
||||
)
|
||||
help_texts = {
|
||||
'rear_port_position': _('Mapped position on corresponding rear port'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -815,9 +808,6 @@ class RearPortImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'tags')
|
||||
help_texts = {
|
||||
'positions': _('Number of front ports which may be mapped')
|
||||
}
|
||||
|
||||
|
||||
class ModuleBayImportForm(NetBoxModelImportForm):
|
||||
@ -1204,4 +1194,3 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
|
||||
'name', 'device', 'status', 'tenant', 'identifier', 'comments',
|
||||
]
|
||||
model = VirtualDeviceContext
|
||||
help_texts = {}
|
||||
|
@ -66,12 +66,6 @@ __all__ = (
|
||||
'VirtualDeviceContextForm'
|
||||
)
|
||||
|
||||
INTERFACE_MODE_HELP_TEXT = """
|
||||
Access: One untagged VLAN<br />
|
||||
Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
|
||||
Tagged (All): Implies all VLANs are available (w/optional untagged VLAN)
|
||||
"""
|
||||
|
||||
|
||||
class RegionForm(NetBoxModelForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
@ -160,16 +154,6 @@ class SiteForm(TenancyForm, NetBoxModelForm):
|
||||
}
|
||||
),
|
||||
}
|
||||
help_texts = {
|
||||
'name': _("Full name of the site"),
|
||||
'facility': _("Data center provider and facility (e.g. Equinix NY7)"),
|
||||
'time_zone': _("Local time zone"),
|
||||
'description': _("Short description (will appear in sites list)"),
|
||||
'physical_address': _("Physical location of the building (e.g. for GPS)"),
|
||||
'shipping_address': _("If different from the physical address"),
|
||||
'latitude': _("Latitude in decimal format (xx.yyyyyy)"),
|
||||
'longitude': _("Longitude in decimal format (xx.yyyyyy)")
|
||||
}
|
||||
|
||||
|
||||
class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
@ -276,12 +260,6 @@ class RackForm(TenancyForm, NetBoxModelForm):
|
||||
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'site': _("The site at which the rack exists"),
|
||||
'name': _("Organizational rack name"),
|
||||
'facility_id': _("The unique rack ID assigned by the facility"),
|
||||
'u_height': _("Height in rack units"),
|
||||
}
|
||||
|
||||
|
||||
class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
@ -451,19 +429,15 @@ class PlatformForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Platform', (
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'napalm_args': forms.Textarea(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
@ -587,12 +561,6 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||
'description', 'config_template', 'comments', 'tags', 'local_context_data'
|
||||
]
|
||||
help_texts = {
|
||||
'device_role': _("The function this device serves"),
|
||||
'serial': _("Chassis serial number"),
|
||||
'local_context_data': _("Local config context data overwrites all source contexts in the final rendered "
|
||||
"config context"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -1052,15 +1020,24 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
|
||||
|
||||
|
||||
class InterfaceTemplateForm(ModularComponentTemplateForm):
|
||||
bridge = DynamicModelChoiceField(
|
||||
queryset=InterfaceTemplate.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'devicetype_id': '$device_type',
|
||||
'moduletype_id': '$module_type',
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')),
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')),
|
||||
('PoE', ('poe_mode', 'poe_type'))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
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', 'bridge',
|
||||
]
|
||||
|
||||
|
||||
@ -1378,11 +1355,6 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
labels = {
|
||||
'mode': '802.1Q Mode',
|
||||
}
|
||||
help_texts = {
|
||||
'mode': INTERFACE_MODE_HELP_TEXT,
|
||||
'rf_channel_frequency': _("Populated by selected channel (if set)"),
|
||||
'rf_channel_width': _("Populated by selected channel (if set)"),
|
||||
}
|
||||
|
||||
|
||||
class FrontPortForm(ModularDeviceComponentForm):
|
||||
|
@ -1,4 +1,5 @@
|
||||
import dcim.fields
|
||||
import ipam.fields
|
||||
import django.contrib.postgres.fields
|
||||
from utilities.json import CustomFieldJSONEncoder
|
||||
import django.core.validators
|
||||
@ -609,7 +610,7 @@ class Migration(migrations.Migration):
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
('status', models.CharField(default='active', max_length=50)),
|
||||
('facility', models.CharField(blank=True, max_length=50)),
|
||||
('asn', dcim.fields.ASNField(blank=True, null=True)),
|
||||
('asn', ipam.fields.ASNField(blank=True, null=True)),
|
||||
('time_zone', timezone_field.fields.TimeZoneField(blank=True)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('physical_address', models.CharField(blank=True, max_length=200)),
|
||||
|
19
netbox/dcim/migrations/0171_devicetype_add_bridge.py
Normal file
19
netbox/dcim/migrations/0171_devicetype_add_bridge.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.1.6 on 2023-03-01 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0170_configtemplate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='bridge',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interfacetemplate'),
|
||||
),
|
||||
]
|
@ -350,6 +350,14 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
default=False,
|
||||
verbose_name='Management only'
|
||||
)
|
||||
bridge = models.ForeignKey(
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='bridge_interfaces',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Bridge interface'
|
||||
)
|
||||
poe_mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoEModeChoices,
|
||||
@ -365,6 +373,19 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = Interface
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.bridge:
|
||||
if self.device_type and self.device_type != self.bridge.device_type:
|
||||
raise ValidationError({
|
||||
'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type"
|
||||
})
|
||||
if self.module_type and self.module_type != self.bridge.module_type:
|
||||
raise ValidationError({
|
||||
'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type"
|
||||
})
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@ -385,6 +406,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
'mgmt_only': self.mgmt_only,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
'bridge': self.bridge.name if self.bridge else None,
|
||||
'poe_mode': self.poe_mode,
|
||||
'poe_type': self.poe_type,
|
||||
}
|
||||
|
@ -478,7 +478,8 @@ class BaseInterface(models.Model):
|
||||
mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfaceModeChoices,
|
||||
blank=True
|
||||
blank=True,
|
||||
help_text=_("IEEE 802.1Q tagging strategy")
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
to='self',
|
||||
@ -587,14 +588,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Channel frequency (MHz)'
|
||||
verbose_name='Channel frequency (MHz)',
|
||||
help_text=_("Populated by selected channel (if set)")
|
||||
)
|
||||
rf_channel_width = models.DecimalField(
|
||||
max_digits=7,
|
||||
decimal_places=3,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Channel width (MHz)'
|
||||
verbose_name='Channel width (MHz)',
|
||||
help_text=_("Populated by selected channel (if set)")
|
||||
)
|
||||
tx_power = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
@ -885,7 +888,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
],
|
||||
help_text=_('Mapped position on corresponding rear port')
|
||||
)
|
||||
|
||||
clone_fields = ('device', 'type', 'color')
|
||||
@ -940,7 +944,8 @@ class RearPort(ModularComponentModel, CabledObjectModel):
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
],
|
||||
help_text=_('Number of front ports which may be mapped')
|
||||
)
|
||||
clone_fields = ('device', 'type', 'color', 'positions')
|
||||
|
||||
|
@ -460,6 +460,20 @@ class Platform(OrganizationalModel):
|
||||
return reverse('dcim:platform', args=[self.pk])
|
||||
|
||||
|
||||
def update_interface_bridges(device, interface_templates, module=None):
|
||||
"""
|
||||
Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
|
||||
and applies it to the actual interfaces.
|
||||
"""
|
||||
for interface_template in interface_templates.exclude(bridge=None):
|
||||
interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
|
||||
|
||||
if interface_template.bridge:
|
||||
interface.bridge = Interface.objects.get(device=device, name=interface_template.bridge.resolve_name(module=module))
|
||||
interface.full_clean()
|
||||
interface.save()
|
||||
|
||||
|
||||
class Device(PrimaryModel, ConfigContextModel):
|
||||
"""
|
||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
||||
@ -480,7 +494,8 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
device_role = models.ForeignKey(
|
||||
to='dcim.DeviceRole',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='devices'
|
||||
related_name='devices',
|
||||
help_text=_("The function this device serves")
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
@ -510,7 +525,8 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
serial = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='Serial number'
|
||||
verbose_name='Serial number',
|
||||
help_text=_("Chassis serial number, assigned by the manufacturer")
|
||||
)
|
||||
asset_tag = models.CharField(
|
||||
max_length=50,
|
||||
@ -597,12 +613,14 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
vc_position = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MaxValueValidator(255)]
|
||||
validators=[MaxValueValidator(255)],
|
||||
help_text=_('Virtual chassis position')
|
||||
)
|
||||
vc_priority = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MaxValueValidator(255)]
|
||||
validators=[MaxValueValidator(255)],
|
||||
help_text=_('Virtual chassis master election priority')
|
||||
)
|
||||
config_template = models.ForeignKey(
|
||||
to='extras.ConfigTemplate',
|
||||
@ -850,6 +868,8 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
self._instantiate_components(self.device_type.devicebaytemplates.all())
|
||||
# Disable bulk_create to accommodate MPTT
|
||||
self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
|
||||
# Interface bridges have to be set after interface instantiation
|
||||
update_interface_bridges(self, self.device_type.interfacetemplates.all())
|
||||
|
||||
# Update Site and Rack assignment for any child Devices
|
||||
devices = Device.objects.filter(parent_bay__device=self)
|
||||
@ -1086,6 +1106,9 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
update_fields=update_fields
|
||||
)
|
||||
|
||||
# Interface bridges have to be set after interface instantiation
|
||||
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
|
@ -64,7 +64,7 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Facility ID',
|
||||
help_text=_('Locally-assigned identifier')
|
||||
help_text=_("Locally-assigned identifier")
|
||||
)
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
|
@ -139,7 +139,8 @@ class Site(PrimaryModel):
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
unique=True,
|
||||
help_text=_("Full name of the site")
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
@ -179,7 +180,7 @@ class Site(PrimaryModel):
|
||||
facility = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text=_('Local facility ID or description')
|
||||
help_text=_("Local facility ID or description")
|
||||
)
|
||||
asns = models.ManyToManyField(
|
||||
to='ipam.ASN',
|
||||
@ -191,25 +192,27 @@ class Site(PrimaryModel):
|
||||
)
|
||||
physical_address = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
blank=True,
|
||||
help_text=_("Physical location of the building")
|
||||
)
|
||||
shipping_address = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
blank=True,
|
||||
help_text=_("If different from the physical address")
|
||||
)
|
||||
latitude = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('GPS coordinate (latitude)')
|
||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||
)
|
||||
longitude = models.DecimalField(
|
||||
max_digits=9,
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('GPS coordinate (longitude)')
|
||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
|
@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('napalm_driver', 300),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
@ -133,11 +133,11 @@ class PlatformTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Platform
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
|
||||
'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
|
||||
'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
|
||||
)
|
||||
|
||||
|
||||
|
@ -187,7 +187,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = models.InterfaceTemplate
|
||||
fields = ('pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions')
|
||||
fields = ('pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
|
@ -1469,9 +1469,9 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
platforms = (
|
||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'),
|
||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='A'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='B'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='C'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
@ -1487,10 +1487,6 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_napalm_driver(self):
|
||||
params = {'napalm_driver': ['driver-1', 'driver-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_manufacturer(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
|
||||
|
@ -1591,8 +1591,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'name': 'Platform X',
|
||||
'slug': 'platform-x',
|
||||
'manufacturer': manufacturer.pk,
|
||||
'napalm_driver': 'junos',
|
||||
'napalm_args': None,
|
||||
'description': 'A new platform',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
@ -1612,7 +1610,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'napalm_driver': 'ios',
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
|
@ -2080,71 +2080,6 @@ class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
#
|
||||
# Device NAPALM views
|
||||
#
|
||||
|
||||
class NAPALMViewTab(ViewTab):
|
||||
|
||||
def render(self, instance):
|
||||
# Display NAPALM tabs only for devices which meet certain requirements
|
||||
if not (
|
||||
instance.status == 'active' and
|
||||
instance.primary_ip and
|
||||
instance.platform and
|
||||
instance.platform.napalm_driver
|
||||
):
|
||||
return None
|
||||
return super().render(instance)
|
||||
|
||||
|
||||
@register_model_view(Device, 'status')
|
||||
class DeviceStatusView(generic.ObjectView):
|
||||
additional_permissions = ['dcim.napalm_read_device']
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/status.html'
|
||||
tab = NAPALMViewTab(
|
||||
label=_('Status'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3000
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Device, 'lldp_neighbors', path='lldp-neighbors')
|
||||
class DeviceLLDPNeighborsView(generic.ObjectView):
|
||||
additional_permissions = ['dcim.napalm_read_device']
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/lldp_neighbors.html'
|
||||
tab = NAPALMViewTab(
|
||||
label=_('LLDP Neighbors'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3100
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
|
||||
'_path'
|
||||
).exclude(
|
||||
type__in=NONCONNECTABLE_IFACE_TYPES
|
||||
)
|
||||
|
||||
return {
|
||||
'interfaces': interfaces,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Device, 'config')
|
||||
class DeviceConfigView(generic.ObjectView):
|
||||
additional_permissions = ['dcim.napalm_read_device']
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/config.html'
|
||||
tab = NAPALMViewTab(
|
||||
label=_('Config'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3200
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
@ -35,10 +35,6 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
'fields': ('CUSTOM_VALIDATORS',),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('NAPALM', {
|
||||
'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('User Preferences', {
|
||||
'fields': ('DEFAULT_USER_PREFERENCES',),
|
||||
}),
|
||||
|
@ -35,6 +35,7 @@ __all__ = (
|
||||
'ContentTypeSerializer',
|
||||
'CustomFieldSerializer',
|
||||
'CustomLinkSerializer',
|
||||
'DashboardSerializer',
|
||||
'ExportTemplateSerializer',
|
||||
'ImageAttachmentSerializer',
|
||||
'JobResultSerializer',
|
||||
@ -68,9 +69,10 @@ class WebhookSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
|
||||
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete',
|
||||
'type_job_start', 'type_job_end', 'payload_url', 'enabled', 'http_method', 'http_content_type',
|
||||
'additional_headers', 'body_template', 'secret', 'conditions', 'ssl_verification', 'ca_file_path',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@ -565,3 +567,13 @@ class ContentTypeSerializer(BaseModelSerializer):
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ['id', 'url', 'display', 'app_label', 'model']
|
||||
|
||||
|
||||
#
|
||||
# User dashboard
|
||||
#
|
||||
|
||||
class DashboardSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Dashboard
|
||||
fields = ('layout', 'config')
|
||||
|
@ -1,3 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
from . import views
|
||||
|
||||
@ -22,4 +24,7 @@ router.register('job-results', views.JobResultViewSet)
|
||||
router.register('content-types', views.ContentTypeViewSet)
|
||||
|
||||
app_name = 'extras-api'
|
||||
urlpatterns = router.urls
|
||||
urlpatterns = [
|
||||
path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.generics import RetrieveUpdateDestroyAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
@ -423,3 +424,15 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
|
||||
queryset = ContentType.objects.order_by('app_label', 'model')
|
||||
serializer_class = serializers.ContentTypeSerializer
|
||||
filterset_class = filtersets.ContentTypeFilterSet
|
||||
|
||||
|
||||
#
|
||||
# User dashboard
|
||||
#
|
||||
|
||||
class DashboardView(RetrieveUpdateDestroyAPIView):
|
||||
queryset = Dashboard.objects.all()
|
||||
serializer_class = serializers.DashboardSerializer
|
||||
|
||||
def get_object(self):
|
||||
return Dashboard.objects.filter(user=self.request.user).first()
|
||||
|
@ -5,4 +5,4 @@ class ExtrasConfig(AppConfig):
|
||||
name = "extras"
|
||||
|
||||
def ready(self):
|
||||
from . import lookups, search, signals
|
||||
from . import dashboard, lookups, search, signals
|
||||
|
@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet):
|
||||
TYPE_DECIMAL = 'decimal'
|
||||
TYPE_BOOLEAN = 'boolean'
|
||||
TYPE_DATE = 'date'
|
||||
TYPE_DATETIME = 'datetime'
|
||||
TYPE_URL = 'url'
|
||||
TYPE_JSON = 'json'
|
||||
TYPE_SELECT = 'select'
|
||||
@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet):
|
||||
(TYPE_DECIMAL, 'Decimal'),
|
||||
(TYPE_BOOLEAN, 'Boolean (true/false)'),
|
||||
(TYPE_DATE, 'Date'),
|
||||
(TYPE_DATETIME, 'Date & time'),
|
||||
(TYPE_URL, 'URL'),
|
||||
(TYPE_JSON, 'JSON'),
|
||||
(TYPE_SELECT, 'Selection'),
|
||||
|
@ -1,2 +1,55 @@
|
||||
# Webhook content types
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
# Webhooks
|
||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||
|
||||
WEBHOOK_EVENT_TYPES = {
|
||||
'create': 'created',
|
||||
'update': 'updated',
|
||||
'delete': 'deleted',
|
||||
'job_start': 'job_started',
|
||||
'job_end': 'job_ended',
|
||||
}
|
||||
|
||||
# Dashboard
|
||||
DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'config': {
|
||||
'models': [
|
||||
'ipam.aggregate',
|
||||
'ipam.prefix',
|
||||
'ipam.ipaddress',
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'DCIM',
|
||||
'config': {
|
||||
'models': [
|
||||
'dcim.site',
|
||||
'dcim.rack',
|
||||
'dcim.device',
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.NoteWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'config': {
|
||||
'content': 'Welcome to **NetBox**!'
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ChangeLogWidget',
|
||||
'width': 12,
|
||||
'height': 6,
|
||||
},
|
||||
]
|
||||
|
2
netbox/extras/dashboard/__init__.py
Normal file
2
netbox/extras/dashboard/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .utils import *
|
||||
from .widgets import *
|
38
netbox/extras/dashboard/forms.py
Normal file
38
netbox/extras/dashboard/forms.py
Normal file
@ -0,0 +1,38 @@
|
||||
from django import forms
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from netbox.registry import registry
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
__all__ = (
|
||||
'DashboardWidgetAddForm',
|
||||
'DashboardWidgetForm',
|
||||
)
|
||||
|
||||
|
||||
def get_widget_choices():
|
||||
return registry['widgets'].items()
|
||||
|
||||
|
||||
class DashboardWidgetForm(BootstrapMixin, forms.Form):
|
||||
title = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
color = forms.ChoiceField(
|
||||
choices=add_blank_choice(ButtonColorChoices),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
class DashboardWidgetAddForm(DashboardWidgetForm):
|
||||
widget_class = forms.ChoiceField(
|
||||
choices=get_widget_choices,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
'hx-get': reverse_lazy('extras:dashboardwidget_add'),
|
||||
'hx-target': '#widget_add_form',
|
||||
}
|
||||
)
|
||||
)
|
||||
field_order = ('widget_class', 'title', 'color')
|
76
netbox/extras/dashboard/utils.py
Normal file
76
netbox/extras/dashboard/utils.py
Normal file
@ -0,0 +1,76 @@
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from netbox.registry import registry
|
||||
from extras.constants import DEFAULT_DASHBOARD
|
||||
|
||||
__all__ = (
|
||||
'get_dashboard',
|
||||
'get_default_dashboard',
|
||||
'get_widget_class',
|
||||
'register_widget',
|
||||
)
|
||||
|
||||
|
||||
def register_widget(cls):
|
||||
"""
|
||||
Decorator for registering a DashboardWidget class.
|
||||
"""
|
||||
app_label = cls.__module__.split('.', maxsplit=1)[0]
|
||||
label = f'{app_label}.{cls.__name__}'
|
||||
registry['widgets'][label] = cls
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
def get_widget_class(name):
|
||||
"""
|
||||
Return a registered DashboardWidget class identified by its name.
|
||||
"""
|
||||
try:
|
||||
return registry['widgets'][name]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unregistered widget class: {name}")
|
||||
|
||||
|
||||
def get_dashboard(user):
|
||||
"""
|
||||
Return the Dashboard for a given User if one exists, or generate a default dashboard.
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
dashboard = get_default_dashboard()
|
||||
else:
|
||||
try:
|
||||
dashboard = user.dashboard
|
||||
except ObjectDoesNotExist:
|
||||
# Create a dashboard for this user
|
||||
dashboard = get_default_dashboard()
|
||||
dashboard.user = user
|
||||
dashboard.save()
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def get_default_dashboard():
|
||||
from extras.models import Dashboard
|
||||
dashboard = Dashboard(
|
||||
layout=[],
|
||||
config={}
|
||||
)
|
||||
for widget in DEFAULT_DASHBOARD:
|
||||
id = str(uuid.uuid4())
|
||||
dashboard.layout.append({
|
||||
'id': id,
|
||||
'w': widget['width'],
|
||||
'h': widget['height'],
|
||||
'x': widget.get('x'),
|
||||
'y': widget.get('y'),
|
||||
})
|
||||
dashboard.config[id] = {
|
||||
'class': widget['widget'],
|
||||
'title': widget.get('title'),
|
||||
'config': widget.get('config', {}),
|
||||
}
|
||||
|
||||
return dashboard
|
119
netbox/extras/dashboard/widgets.py
Normal file
119
netbox/extras/dashboard/widgets.py
Normal file
@ -0,0 +1,119 @@
|
||||
import uuid
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import content_type_identifier, content_type_name
|
||||
from .utils import register_widget
|
||||
|
||||
__all__ = (
|
||||
'ChangeLogWidget',
|
||||
'DashboardWidget',
|
||||
'NoteWidget',
|
||||
'ObjectCountsWidget',
|
||||
)
|
||||
|
||||
|
||||
def get_content_type_labels():
|
||||
return [
|
||||
(content_type_identifier(ct), content_type_name(ct))
|
||||
for ct in ContentType.objects.order_by('app_label', 'model')
|
||||
]
|
||||
|
||||
|
||||
class DashboardWidget:
|
||||
default_title = None
|
||||
description = None
|
||||
width = 4
|
||||
height = 3
|
||||
|
||||
class ConfigForm(forms.Form):
|
||||
pass
|
||||
|
||||
def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
|
||||
self.id = id or str(uuid.uuid4())
|
||||
self.config = config or {}
|
||||
self.title = title or self.default_title
|
||||
self.color = color
|
||||
if width:
|
||||
self.width = width
|
||||
if height:
|
||||
self.height = height
|
||||
self.x, self.y = x, y
|
||||
|
||||
def __str__(self):
|
||||
return self.title or self.__class__.__name__
|
||||
|
||||
def set_layout(self, grid_item):
|
||||
self.width = grid_item['w']
|
||||
self.height = grid_item['h']
|
||||
self.x = grid_item.get('x')
|
||||
self.y = grid_item.get('y')
|
||||
|
||||
def render(self, request):
|
||||
raise NotImplementedError(f"{self.__class__} must define a render() method.")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
|
||||
|
||||
@property
|
||||
def form_data(self):
|
||||
return {
|
||||
'title': self.title,
|
||||
'color': self.color,
|
||||
'config': self.config,
|
||||
}
|
||||
|
||||
|
||||
@register_widget
|
||||
class NoteWidget(DashboardWidget):
|
||||
description = _('Display some arbitrary custom content. Markdown is supported.')
|
||||
|
||||
class ConfigForm(BootstrapMixin, forms.Form):
|
||||
content = forms.CharField(
|
||||
widget=forms.Textarea()
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
return render_markdown(self.config.get('content'))
|
||||
|
||||
|
||||
@register_widget
|
||||
class ObjectCountsWidget(DashboardWidget):
|
||||
default_title = _('Objects')
|
||||
description = _('Display a set of NetBox models and the number of objects created for each type.')
|
||||
template_name = 'extras/dashboard/widgets/objectcounts.html'
|
||||
|
||||
class ConfigForm(BootstrapMixin, forms.Form):
|
||||
models = forms.MultipleChoiceField(
|
||||
choices=get_content_type_labels
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
counts = []
|
||||
for content_type_id in self.config['models']:
|
||||
app_label, model_name = content_type_id.split('.')
|
||||
model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class()
|
||||
object_count = model.objects.restrict(request.user, 'view').count
|
||||
counts.append((model, object_count))
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'counts': counts,
|
||||
})
|
||||
|
||||
|
||||
@register_widget
|
||||
class ChangeLogWidget(DashboardWidget):
|
||||
default_title = _('Change Log')
|
||||
description = _('Display the most recent records from the global change log.')
|
||||
template_name = 'extras/dashboard/widgets/changelog.html'
|
||||
width = 12
|
||||
height = 4
|
||||
|
||||
def render(self, request):
|
||||
return render_to_string(self.template_name, {})
|
@ -48,8 +48,8 @@ class WebhookFilterSet(BaseFilterSet):
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = [
|
||||
'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method',
|
||||
'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||
'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'payload_url',
|
||||
'enabled', 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
|
@ -140,6 +140,14 @@ class WebhookBulkEditForm(BulkEditForm):
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
type_job_start = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
type_job_end = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
http_method = forms.ChoiceField(
|
||||
choices=add_blank_choice(WebhookHttpMethodChoices),
|
||||
required=False,
|
||||
|
@ -116,9 +116,9 @@ class WebhookImportForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = (
|
||||
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
|
||||
'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
|
||||
'ca_file_path'
|
||||
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'type_job_start',
|
||||
'type_job_end', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template',
|
||||
'secret', 'ssl_verification', 'ca_file_path'
|
||||
)
|
||||
|
||||
|
||||
|
@ -222,7 +222,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
('Attributes', ('content_type_id', 'http_method', 'enabled')),
|
||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||
('Events', ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
||||
@ -244,19 +244,36 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
),
|
||||
label=_('Object creations')
|
||||
)
|
||||
type_update = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
),
|
||||
label=_('Object updates')
|
||||
)
|
||||
type_delete = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
),
|
||||
label=_('Object deletions')
|
||||
)
|
||||
type_job_start = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
),
|
||||
label=_('Job starts')
|
||||
)
|
||||
type_job_end = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
),
|
||||
label=_('Job terminations')
|
||||
)
|
||||
|
||||
|
||||
|
@ -56,8 +56,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
model = CustomField
|
||||
fields = '__all__'
|
||||
help_texts = {
|
||||
'type': _("The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||
"type below.")
|
||||
'type': _(
|
||||
"The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||
"type below."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -80,9 +82,11 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
help_texts = {
|
||||
'link_text': _('Jinja2 template code for the link text. Reference the object as <code>{{ object }}</code>. '
|
||||
'Links which render as empty text will not be displayed.'),
|
||||
'link_url': _('Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>.'),
|
||||
'link_text': _(
|
||||
"Jinja2 template code for the link text. Reference the object as <code>{{ object }}</code>. Links "
|
||||
"which render as empty text will not be displayed."
|
||||
),
|
||||
'link_url': _("Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>."),
|
||||
}
|
||||
|
||||
|
||||
@ -150,7 +154,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Webhook', ('name', 'content_types', 'enabled')),
|
||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||
('Events', ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
||||
('HTTP Request', (
|
||||
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
)),
|
||||
@ -165,6 +169,8 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
'type_create': 'Creations',
|
||||
'type_update': 'Updates',
|
||||
'type_delete': 'Deletions',
|
||||
'type_job_start': 'Job executions',
|
||||
'type_job_end': 'Job terminations',
|
||||
}
|
||||
widgets = {
|
||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
|
@ -41,16 +41,16 @@ class Command(BaseCommand):
|
||||
the change_logging context manager (which is bypassed if commit == False).
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data=data, commit=commit)
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
||||
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data=data, commit=commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.terminate()
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@ -58,11 +58,9 @@ class Command(BaseCommand):
|
||||
)
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
|
||||
|
25
netbox/extras/migrations/0087_dashboard.py
Normal file
25
netbox/extras/migrations/0087_dashboard.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-24 00:56
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('extras', '0086_configtemplate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Dashboard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('layout', models.JSONField()),
|
||||
('config', models.JSONField()),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
23
netbox/extras/migrations/0088_jobresult_webhooks.py
Normal file
23
netbox/extras/migrations/0088_jobresult_webhooks.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-28 19:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0087_dashboard'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='type_job_end',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='type_job_start',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -1,6 +1,7 @@
|
||||
from .change_logging import ObjectChange
|
||||
from .configs import *
|
||||
from .customfields import CustomField
|
||||
from .dashboard import *
|
||||
from .models import *
|
||||
from .search import *
|
||||
from .staging import *
|
||||
@ -15,6 +16,7 @@ __all__ = (
|
||||
'ConfigTemplate',
|
||||
'CustomField',
|
||||
'CustomLink',
|
||||
'Dashboard',
|
||||
'ExportTemplate',
|
||||
'ImageAttachment',
|
||||
'JobResult',
|
||||
|
@ -152,6 +152,9 @@ class ConfigContextModel(models.Model):
|
||||
local_context_data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_(
|
||||
"Local config context data takes precedence over source contexts in the final rendered config context"
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -25,7 +25,7 @@ from utilities.forms.fields import (
|
||||
DynamicModelMultipleChoiceField, JSONField, LaxURLField,
|
||||
)
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.forms.widgets import DatePicker
|
||||
from utilities.forms.widgets import DatePicker, DateTimePicker
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
@ -306,8 +306,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
|
||||
return value.isoformat()
|
||||
if self.type in (CustomFieldTypeChoices.TYPE_DATE, CustomFieldTypeChoices.TYPE_DATETIME):
|
||||
if type(value) in (date, datetime):
|
||||
return value.isoformat()
|
||||
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
return value.pk
|
||||
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
@ -325,6 +326,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = self.object_type.model_class()
|
||||
return model.objects.filter(pk=value).first()
|
||||
@ -380,6 +386,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
|
||||
|
||||
# Date & time
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
||||
field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker())
|
||||
|
||||
# Select
|
||||
elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
|
||||
choices = [(c, c) for c in self.choices]
|
||||
@ -490,6 +500,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
filter_class = filters.MultiValueDateFilter
|
||||
|
||||
# Date & time
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
||||
filter_class = filters.MultiValueDateTimeFilter
|
||||
|
||||
# Select
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
filter_class = filters.MultiValueCharFilter
|
||||
@ -558,9 +572,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
if type(value) is not date:
|
||||
try:
|
||||
datetime.strptime(value, '%Y-%m-%d')
|
||||
date.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
|
||||
raise ValidationError("Date values must be in ISO 8601 format (YYYY-MM-DD).")
|
||||
|
||||
# Validate date & time
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
||||
if type(value) is not datetime:
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValidationError("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
|
||||
|
||||
# Validate selected choice
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
|
70
netbox/extras/models/dashboard.py
Normal file
70
netbox/extras/models/dashboard.py
Normal file
@ -0,0 +1,70 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
|
||||
__all__ = (
|
||||
'Dashboard',
|
||||
)
|
||||
|
||||
|
||||
class Dashboard(models.Model):
|
||||
user = models.OneToOneField(
|
||||
to=get_user_model(),
|
||||
on_delete=models.CASCADE,
|
||||
related_name='dashboard'
|
||||
)
|
||||
layout = models.JSONField()
|
||||
config = models.JSONField()
|
||||
|
||||
class Meta:
|
||||
pass
|
||||
|
||||
def get_widget(self, id):
|
||||
"""
|
||||
Instantiate and return a widget by its ID
|
||||
"""
|
||||
id = str(id)
|
||||
config = dict(self.config[id]) # Copy to avoid mutating instance data
|
||||
widget_class = get_widget_class(config.pop('class'))
|
||||
return widget_class(id=id, **config)
|
||||
|
||||
def get_layout(self):
|
||||
"""
|
||||
Return the dashboard's configured layout, suitable for rendering with gridstack.js.
|
||||
"""
|
||||
widgets = []
|
||||
for grid_item in self.layout:
|
||||
widget = self.get_widget(grid_item['id'])
|
||||
widget.set_layout(grid_item)
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def add_widget(self, widget, x=None, y=None):
|
||||
"""
|
||||
Add a widget to the dashboard, optionally specifying its X & Y coordinates.
|
||||
"""
|
||||
id = str(widget.id)
|
||||
self.config[id] = {
|
||||
'class': widget.name,
|
||||
'title': widget.title,
|
||||
'color': widget.color,
|
||||
'config': widget.config,
|
||||
}
|
||||
self.layout.append({
|
||||
'id': id,
|
||||
'h': widget.height,
|
||||
'w': widget.width,
|
||||
'x': x,
|
||||
'y': y,
|
||||
})
|
||||
|
||||
def delete_widget(self, id):
|
||||
"""
|
||||
Delete a widget from the dashboard.
|
||||
"""
|
||||
id = str(id)
|
||||
del self.config[id]
|
||||
self.layout = [
|
||||
item for item in self.layout if item['id'] != id
|
||||
]
|
@ -5,7 +5,6 @@ from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import MinValueValidator, ValidationError
|
||||
from django.db import models
|
||||
@ -27,7 +26,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import (
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin,
|
||||
TagsMixin,
|
||||
TagsMixin, WebhooksMixin,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import render_jinja2
|
||||
@ -65,15 +64,23 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
|
||||
)
|
||||
type_create = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Call this webhook when a matching object is created.")
|
||||
help_text=_("Triggers when a matching object is created.")
|
||||
)
|
||||
type_update = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Call this webhook when a matching object is updated.")
|
||||
help_text=_("Triggers when a matching object is updated.")
|
||||
)
|
||||
type_delete = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Call this webhook when a matching object is deleted.")
|
||||
help_text=_("Triggers when a matching object is deleted.")
|
||||
)
|
||||
type_job_start = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Triggers when a job for a matching object is started.")
|
||||
)
|
||||
type_job_end = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Triggers when a job for a matching object terminates.")
|
||||
)
|
||||
payload_url = models.CharField(
|
||||
max_length=500,
|
||||
@ -159,8 +166,12 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
|
||||
super().clean()
|
||||
|
||||
# At least one action type must be selected
|
||||
if not self.type_create and not self.type_delete and not self.type_update:
|
||||
raise ValidationError("At least one type must be selected: create, update, and/or delete.")
|
||||
if not any([
|
||||
self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end
|
||||
]):
|
||||
raise ValidationError(
|
||||
"At least one event type must be selected: create, update, delete, job_start, and/or job_end."
|
||||
)
|
||||
|
||||
if self.conditions:
|
||||
try:
|
||||
@ -678,19 +689,32 @@ class JobResult(models.Model):
|
||||
"""
|
||||
Record the job's start time and update its status to "running."
|
||||
"""
|
||||
if self.started is None:
|
||||
self.started = timezone.now()
|
||||
self.status = JobResultStatusChoices.STATUS_RUNNING
|
||||
JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status)
|
||||
if self.started is not None:
|
||||
return
|
||||
|
||||
def set_status(self, status):
|
||||
# Start the job
|
||||
self.started = timezone.now()
|
||||
self.status = JobResultStatusChoices.STATUS_RUNNING
|
||||
JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status)
|
||||
|
||||
# Handle webhooks
|
||||
self.trigger_webhooks(event='job_start')
|
||||
|
||||
def terminate(self, status=JobResultStatusChoices.STATUS_COMPLETED):
|
||||
"""
|
||||
Helper method to change the status of the job result. If the target status is terminal, the completion
|
||||
time is also set.
|
||||
Mark the job as completed, optionally specifying a particular termination status.
|
||||
"""
|
||||
valid_statuses = JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
if status not in valid_statuses:
|
||||
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
|
||||
|
||||
# Mark the job as completed
|
||||
self.status = status
|
||||
if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
self.completed = timezone.now()
|
||||
self.completed = timezone.now()
|
||||
JobResult.objects.filter(pk=self.pk).update(status=self.status, completed=self.completed)
|
||||
|
||||
# Handle webhooks
|
||||
self.trigger_webhooks(event='job_end')
|
||||
|
||||
@classmethod
|
||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
|
||||
@ -725,6 +749,28 @@ class JobResult(models.Model):
|
||||
|
||||
return job_result
|
||||
|
||||
def trigger_webhooks(self, event):
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
|
||||
rq_queue = django_rq.get_queue(rq_queue_name, is_async=False)
|
||||
|
||||
# Fetch any webhooks matching this object type and action
|
||||
webhooks = Webhook.objects.filter(
|
||||
**{f'type_{event}': True},
|
||||
content_types=self.obj_type,
|
||||
enabled=True
|
||||
)
|
||||
|
||||
for webhook in webhooks:
|
||||
rq_queue.enqueue(
|
||||
"extras.webhooks_worker.process_webhook",
|
||||
webhook=webhook,
|
||||
model_name=self.obj_type.model,
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
username=self.user.username
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevision(models.Model):
|
||||
"""
|
||||
@ -767,7 +813,7 @@ class ConfigRevision(models.Model):
|
||||
# Custom scripts & reports
|
||||
#
|
||||
|
||||
class Script(JobResultsMixin, models.Model):
|
||||
class Script(JobResultsMixin, WebhooksMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
@ -779,7 +825,7 @@ class Script(JobResultsMixin, models.Model):
|
||||
# Reports
|
||||
#
|
||||
|
||||
class Report(JobResultsMixin, models.Model):
|
||||
class Report(JobResultsMixin, WebhooksMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||
"""
|
||||
|
@ -85,8 +85,7 @@ def run_report(job_result, *args, **kwargs):
|
||||
job_result.start()
|
||||
report.run(job_result)
|
||||
except Exception:
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
job_result.save()
|
||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||
logging.error(f"Error during execution of report {job_result.name}")
|
||||
finally:
|
||||
# Schedule the next job if an interval has been set
|
||||
@ -241,28 +240,23 @@ class Report(object):
|
||||
self.pre_run()
|
||||
|
||||
try:
|
||||
|
||||
for method_name in self.test_methods:
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job_result.status = JobResultStatusChoices.STATUS_FAILED
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
|
||||
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||
logger.error(f"Exception raised during report execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
job_result.data = self._results
|
||||
job_result.completed = timezone.now()
|
||||
job_result.save()
|
||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||
finally:
|
||||
job_result.terminate()
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.post_run()
|
||||
|
@ -460,36 +460,28 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
the change_logging context manager (which is bypassed if commit == False).
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data=data, commit=commit)
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
||||
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
except AbortScript as e:
|
||||
script.log_failure(
|
||||
f"Script aborted with error: {e}"
|
||||
)
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Script aborted with error: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
|
||||
)
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data=data, commit=commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
job_result.terminate()
|
||||
except Exception as e:
|
||||
if type(e) is AbortScript:
|
||||
script.log_failure(f"Script aborted with error: {e}")
|
||||
logger.error(f"Script aborted with error: {e}")
|
||||
else:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||
clear_webhooks.send(request)
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
|
||||
|
@ -146,6 +146,12 @@ class WebhookTable(NetBoxTable):
|
||||
type_delete = columns.BooleanColumn(
|
||||
verbose_name='Delete'
|
||||
)
|
||||
type_job_start = columns.BooleanColumn(
|
||||
verbose_name='Job start'
|
||||
)
|
||||
type_job_end = columns.BooleanColumn(
|
||||
verbose_name='Job end'
|
||||
)
|
||||
ssl_validation = columns.BooleanColumn(
|
||||
verbose_name='SSL Validation'
|
||||
)
|
||||
@ -153,12 +159,13 @@ class WebhookTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Webhook
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete',
|
||||
'type_job_start', 'type_job_end', 'http_method', 'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'payload_url',
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'type_job_start',
|
||||
'type_job_end', 'http_method', 'payload_url',
|
||||
)
|
||||
|
||||
|
||||
|
11
netbox/extras/templatetags/dashboard.py
Normal file
11
netbox/extras/templatetags/dashboard.py
Normal file
@ -0,0 +1,11 @@
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def render_widget(context, widget):
|
||||
request = context['request']
|
||||
|
||||
return widget.render(request)
|
@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@ -157,12 +158,12 @@ class CustomFieldTest(TestCase):
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_date_field(self):
|
||||
value = '2016-06-23'
|
||||
value = datetime.date(2016, 6, 23)
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='date_field',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
type=CustomFieldTypeChoices.TYPE_DATE,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
@ -170,10 +171,35 @@ class CustomFieldTest(TestCase):
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.custom_field_data[cf.name] = cf.serialize(value)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
self.assertEqual(instance.cf[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_datetime_field(self):
|
||||
value = datetime.datetime(2016, 6, 23, 9, 45, 0)
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='date_field',
|
||||
type=CustomFieldTypeChoices.TYPE_DATETIME,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = cf.serialize(value)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.cf[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
@ -408,6 +434,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_DATETIME, name='datetime_field', default='2020-01-01T01:23:45'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
|
||||
CustomField(
|
||||
@ -459,12 +486,13 @@ class CustomFieldAPITest(APITestCase):
|
||||
custom_fields[3].name: Decimal('456.78'),
|
||||
custom_fields[4].name: True,
|
||||
custom_fields[5].name: '2020-01-02',
|
||||
custom_fields[6].name: 'http://example.com/2',
|
||||
custom_fields[7].name: '{"foo": 1, "bar": 2}',
|
||||
custom_fields[8].name: 'Bar',
|
||||
custom_fields[9].name: ['Bar', 'Baz'],
|
||||
custom_fields[10].name: vlans[1].pk,
|
||||
custom_fields[11].name: [vlans[2].pk, vlans[3].pk],
|
||||
custom_fields[6].name: '2020-01-02 12:00:00',
|
||||
custom_fields[7].name: 'http://example.com/2',
|
||||
custom_fields[8].name: '{"foo": 1, "bar": 2}',
|
||||
custom_fields[9].name: 'Bar',
|
||||
custom_fields[10].name: ['Bar', 'Baz'],
|
||||
custom_fields[11].name: vlans[1].pk,
|
||||
custom_fields[12].name: [vlans[2].pk, vlans[3].pk],
|
||||
}
|
||||
sites[1].save()
|
||||
|
||||
@ -476,6 +504,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
|
||||
CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
|
||||
CustomFieldTypeChoices.TYPE_DATE: 'string',
|
||||
CustomFieldTypeChoices.TYPE_DATETIME: 'string',
|
||||
CustomFieldTypeChoices.TYPE_URL: 'string',
|
||||
CustomFieldTypeChoices.TYPE_JSON: 'object',
|
||||
CustomFieldTypeChoices.TYPE_SELECT: 'string',
|
||||
@ -511,6 +540,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
'decimal_field': None,
|
||||
'boolean_field': None,
|
||||
'date_field': None,
|
||||
'datetime_field': None,
|
||||
'url_field': None,
|
||||
'json_field': None,
|
||||
'select_field': None,
|
||||
@ -536,6 +566,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
|
||||
self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
|
||||
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
|
||||
self.assertEqual(response.data['custom_fields']['datetime_field'], site2_cfvs['datetime_field'])
|
||||
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
|
||||
self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
|
||||
self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field'])
|
||||
@ -571,6 +602,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
|
||||
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
|
||||
self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
|
||||
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
|
||||
@ -588,7 +620,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
|
||||
self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
|
||||
self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
|
||||
self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
|
||||
@ -609,7 +642,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
'integer_field': 456,
|
||||
'decimal_field': 456.78,
|
||||
'boolean_field': True,
|
||||
'date_field': '2020-01-02',
|
||||
'date_field': datetime.date(2020, 1, 2),
|
||||
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
|
||||
'url_field': 'http://example.com/2',
|
||||
'json_field': '{"foo": 1, "bar": 2}',
|
||||
'select_field': 'Bar',
|
||||
@ -632,7 +666,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
|
||||
self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
|
||||
self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
|
||||
self.assertEqual(response_cf['date_field'].isoformat(), data_cf['date_field'])
|
||||
self.assertEqual(response_cf['date_field'], data_cf['date_field'])
|
||||
self.assertEqual(response_cf['datetime_field'], data_cf['datetime_field'])
|
||||
self.assertEqual(response_cf['url_field'], data_cf['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], data_cf['json_field'])
|
||||
self.assertEqual(response_cf['select_field'], data_cf['select_field'])
|
||||
@ -650,7 +685,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
|
||||
self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
|
||||
self.assertEqual(site.cf['date_field'], data_cf['date_field'])
|
||||
self.assertEqual(site.cf['datetime_field'], data_cf['datetime_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
|
||||
self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field'])
|
||||
@ -697,6 +733,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
|
||||
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
|
||||
self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
|
||||
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
|
||||
@ -714,7 +751,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
|
||||
self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
|
||||
self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
|
||||
self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
|
||||
@ -732,7 +770,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
'integer_field': 456,
|
||||
'decimal_field': 456.78,
|
||||
'boolean_field': True,
|
||||
'date_field': '2020-01-02',
|
||||
'date_field': datetime.date(2020, 1, 2),
|
||||
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
|
||||
'url_field': 'http://example.com/2',
|
||||
'json_field': '{"foo": 1, "bar": 2}',
|
||||
'select_field': 'Bar',
|
||||
@ -773,7 +812,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
|
||||
self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
|
||||
self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
|
||||
self.assertEqual(response_cf['date_field'].isoformat(), custom_field_data['date_field'])
|
||||
self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
|
||||
self.assertEqual(response_cf['datetime_field'], custom_field_data['datetime_field'])
|
||||
self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
|
||||
self.assertEqual(response_cf['select_field'], custom_field_data['select_field'])
|
||||
@ -791,7 +831,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
|
||||
self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
|
||||
self.assertEqual(site.cf['date_field'], custom_field_data['date_field'])
|
||||
self.assertEqual(site.cf['datetime_field'], custom_field_data['datetime_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
|
||||
self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field'])
|
||||
@ -826,6 +867,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
|
||||
self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
|
||||
self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
|
||||
self.assertEqual(response_cf['datetime_field'], original_cfvs['datetime_field'])
|
||||
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
|
||||
self.assertEqual(response_cf['select_field'], original_cfvs['select_field'])
|
||||
@ -844,6 +886,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site2.cf['decimal_field'], original_cfvs['decimal_field'])
|
||||
self.assertEqual(site2.cf['boolean_field'], original_cfvs['boolean_field'])
|
||||
self.assertEqual(site2.cf['date_field'], original_cfvs['date_field'])
|
||||
self.assertEqual(site2.cf['datetime_field'], original_cfvs['datetime_field'])
|
||||
self.assertEqual(site2.cf['url_field'], original_cfvs['url_field'])
|
||||
self.assertEqual(site2.cf['json_field'], original_cfvs['json_field'])
|
||||
self.assertEqual(site2.cf['select_field'], original_cfvs['select_field'])
|
||||
@ -977,6 +1020,7 @@ class CustomFieldImportTest(TestCase):
|
||||
CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
|
||||
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
|
||||
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
|
||||
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
|
||||
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
|
||||
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
|
||||
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
|
||||
@ -995,10 +1039,10 @@ class CustomFieldImportTest(TestCase):
|
||||
Import a Site in CSV format, including a value for each CustomField.
|
||||
"""
|
||||
data = (
|
||||
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
|
||||
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
|
||||
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
|
||||
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''),
|
||||
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
|
||||
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
|
||||
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
|
||||
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
|
||||
)
|
||||
csv_data = '\n'.join(','.join(row) for row in data)
|
||||
|
||||
@ -1008,13 +1052,14 @@ class CustomFieldImportTest(TestCase):
|
||||
|
||||
# Validate data for site 1
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
self.assertEqual(len(site1.custom_field_data), 10)
|
||||
self.assertEqual(len(site1.custom_field_data), 11)
|
||||
self.assertEqual(site1.custom_field_data['text'], 'ABC')
|
||||
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
|
||||
self.assertEqual(site1.custom_field_data['integer'], 123)
|
||||
self.assertEqual(site1.custom_field_data['decimal'], 123.45)
|
||||
self.assertEqual(site1.custom_field_data['boolean'], True)
|
||||
self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
|
||||
self.assertEqual(site1.cf['date'].isoformat(), '2020-01-01')
|
||||
self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
|
||||
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
|
||||
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
|
||||
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
|
||||
@ -1022,13 +1067,14 @@ class CustomFieldImportTest(TestCase):
|
||||
|
||||
# Validate data for site 2
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
self.assertEqual(len(site2.custom_field_data), 10)
|
||||
self.assertEqual(len(site2.custom_field_data), 11)
|
||||
self.assertEqual(site2.custom_field_data['text'], 'DEF')
|
||||
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
|
||||
self.assertEqual(site2.custom_field_data['integer'], 456)
|
||||
self.assertEqual(site2.custom_field_data['decimal'], 456.78)
|
||||
self.assertEqual(site2.custom_field_data['boolean'], False)
|
||||
self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
|
||||
self.assertEqual(site2.cf['date'].isoformat(), '2020-01-02')
|
||||
self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
|
||||
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
|
||||
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
|
||||
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
|
||||
|
@ -89,12 +89,16 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
||||
content_types = ContentType.objects.filter(model__in=['region', 'site', 'rack', 'location', 'device'])
|
||||
|
||||
webhooks = (
|
||||
Webhook(
|
||||
name='Webhook 1',
|
||||
type_create=True,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?1',
|
||||
enabled=True,
|
||||
http_method='GET',
|
||||
@ -102,7 +106,11 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 2',
|
||||
type_create=False,
|
||||
type_update=True,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?2',
|
||||
enabled=True,
|
||||
http_method='POST',
|
||||
@ -110,26 +118,56 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 3',
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=True,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?3',
|
||||
enabled=False,
|
||||
http_method='PATCH',
|
||||
ssl_verification=False,
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 4',
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=True,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?4',
|
||||
enabled=False,
|
||||
http_method='PATCH',
|
||||
ssl_verification=False,
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 5',
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=True,
|
||||
payload_url='http://example.com/?5',
|
||||
enabled=False,
|
||||
http_method='PATCH',
|
||||
ssl_verification=False,
|
||||
),
|
||||
)
|
||||
Webhook.objects.bulk_create(webhooks)
|
||||
webhooks[0].content_types.add(content_types[0])
|
||||
webhooks[1].content_types.add(content_types[1])
|
||||
webhooks[2].content_types.add(content_types[2])
|
||||
webhooks[3].content_types.add(content_types[3])
|
||||
webhooks[4].content_types.add(content_types[4])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Webhook 1', 'Webhook 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_types(self):
|
||||
params = {'content_types': 'dcim.site'}
|
||||
params = {'content_types': 'dcim.region'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
|
||||
params = {'content_type_id': [ContentType.objects.get_for_model(Region).pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_type_create(self):
|
||||
@ -144,6 +182,14 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'type_delete': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_type_job_start(self):
|
||||
params = {'type_job_start': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_type_job_end(self):
|
||||
params = {'type_job_end': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
@ -32,6 +32,9 @@ class CustomFieldModelFormTest(TestCase):
|
||||
cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
|
||||
cf_date.content_types.set([obj_type])
|
||||
|
||||
cf_datetime = CustomField.objects.create(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME)
|
||||
cf_datetime.content_types.set([obj_type])
|
||||
|
||||
cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
|
||||
cf_url.content_types.set([obj_type])
|
||||
|
||||
|
@ -87,6 +87,11 @@ urlpatterns = [
|
||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
||||
|
||||
# User dashboard
|
||||
path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
|
||||
path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
|
||||
path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
|
||||
|
||||
# Reports
|
||||
path('reports/', views.ReportListView.as_view(), name='report_list'),
|
||||
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
||||
|
@ -1,14 +1,18 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.http import Http404, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
from django_rq.queues import get_connection
|
||||
from rq import Worker
|
||||
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
@ -664,6 +668,130 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.JournalEntryTable
|
||||
|
||||
|
||||
#
|
||||
# Dashboard widgets
|
||||
#
|
||||
|
||||
class DashboardWidgetAddView(LoginRequiredMixin, View):
|
||||
template_name = 'extras/dashboard/widget_add.html'
|
||||
|
||||
def get(self, request):
|
||||
if not is_htmx(request):
|
||||
return redirect('home')
|
||||
|
||||
initial = request.GET or {
|
||||
'widget_class': 'extras.NoteWidget',
|
||||
}
|
||||
widget_form = DashboardWidgetAddForm(initial=initial)
|
||||
widget_name = get_field_value(widget_form, 'widget_class')
|
||||
widget_class = get_widget_class(widget_name)
|
||||
config_form = widget_class.ConfigForm(prefix='config')
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_class': widget_class,
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
widget_form = DashboardWidgetAddForm(request.POST)
|
||||
config_form = None
|
||||
widget_class = None
|
||||
|
||||
if widget_form.is_valid():
|
||||
widget_class = get_widget_class(widget_form.cleaned_data['widget_class'])
|
||||
config_form = widget_class.ConfigForm(request.POST, prefix='config')
|
||||
|
||||
if config_form.is_valid():
|
||||
data = widget_form.cleaned_data
|
||||
data.pop('widget_class')
|
||||
data['config'] = config_form.cleaned_data
|
||||
widget = widget_class(**data)
|
||||
request.user.dashboard.add_widget(widget)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Added widget {widget.id}')
|
||||
|
||||
return HttpResponse(headers={
|
||||
'HX-Redirect': reverse('home'),
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_class': widget_class,
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
})
|
||||
|
||||
|
||||
class DashboardWidgetConfigView(LoginRequiredMixin, View):
|
||||
template_name = 'extras/dashboard/widget_config.html'
|
||||
|
||||
def get(self, request, id):
|
||||
if not is_htmx(request):
|
||||
return redirect('home')
|
||||
|
||||
widget = request.user.dashboard.get_widget(id)
|
||||
widget_form = DashboardWidgetForm(initial=widget.form_data)
|
||||
config_form = widget.ConfigForm(initial=widget.form_data.get('config'), prefix='config')
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
|
||||
})
|
||||
|
||||
def post(self, request, id):
|
||||
widget = request.user.dashboard.get_widget(id)
|
||||
widget_form = DashboardWidgetForm(request.POST)
|
||||
config_form = widget.ConfigForm(request.POST, prefix='config')
|
||||
|
||||
if widget_form.is_valid() and config_form.is_valid():
|
||||
data = widget_form.cleaned_data
|
||||
data['config'] = config_form.cleaned_data
|
||||
request.user.dashboard.config[str(id)].update(data)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Updated widget {widget.id}')
|
||||
|
||||
return HttpResponse(headers={
|
||||
'HX-Redirect': reverse('home'),
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
|
||||
})
|
||||
|
||||
|
||||
class DashboardWidgetDeleteView(LoginRequiredMixin, View):
|
||||
template_name = 'generic/object_delete.html'
|
||||
|
||||
def get(self, request, id):
|
||||
if not is_htmx(request):
|
||||
return redirect('home')
|
||||
|
||||
widget = request.user.dashboard.get_widget(id)
|
||||
form = ConfirmationForm(initial=request.GET)
|
||||
|
||||
return render(request, 'htmx/delete_form.html', {
|
||||
'object_type': widget.__class__.__name__,
|
||||
'object': widget,
|
||||
'form': form,
|
||||
'form_url': reverse('extras:dashboardwidget_delete', kwargs={'id': id})
|
||||
})
|
||||
|
||||
def post(self, request, id):
|
||||
form = ConfirmationForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
request.user.dashboard.delete_widget(id)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Deleted widget {id}')
|
||||
else:
|
||||
messages.error(request, f'Error deleting widget: {form.errors[0]}')
|
||||
|
||||
return redirect(reverse('home'))
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
@ -5,8 +5,8 @@ from django.conf import settings
|
||||
from django_rq import job
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .conditions import ConditionSet
|
||||
from .constants import WEBHOOK_EVENT_TYPES
|
||||
from .webhooks import generate_signature
|
||||
|
||||
logger = logging.getLogger('netbox.webhooks_worker')
|
||||
@ -28,7 +28,7 @@ def eval_conditions(webhook, data):
|
||||
|
||||
|
||||
@job('default')
|
||||
def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id):
|
||||
def process_webhook(webhook, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
|
||||
"""
|
||||
Make a POST request to the defined Webhook
|
||||
"""
|
||||
@ -38,14 +38,17 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user
|
||||
|
||||
# Prepare context data for headers & body templates
|
||||
context = {
|
||||
'event': dict(ObjectChangeActionChoices)[event].lower(),
|
||||
'event': WEBHOOK_EVENT_TYPES[event],
|
||||
'timestamp': timestamp,
|
||||
'model': model_name,
|
||||
'username': username,
|
||||
'request_id': request_id,
|
||||
'data': data,
|
||||
'snapshots': snapshots,
|
||||
}
|
||||
if snapshots:
|
||||
context.update({
|
||||
'snapshots': snapshots
|
||||
})
|
||||
|
||||
# Build the headers for the HTTP request
|
||||
headers = {
|
||||
|
@ -8,6 +8,7 @@ from netbox.api.serializers import WritableNestedSerializer
|
||||
__all__ = [
|
||||
'NestedAggregateSerializer',
|
||||
'NestedASNSerializer',
|
||||
'NestedASNRangeSerializer',
|
||||
'NestedFHRPGroupSerializer',
|
||||
'NestedFHRPGroupAssignmentSerializer',
|
||||
'NestedIPAddressSerializer',
|
||||
@ -26,6 +27,18 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# ASN ranges
|
||||
#
|
||||
|
||||
class NestedASNRangeSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.ASNRange
|
||||
fields = ['id', 'url', 'display', 'name']
|
||||
|
||||
|
||||
#
|
||||
# ASNs
|
||||
#
|
||||
|
@ -15,15 +15,31 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
#
|
||||
# ASN ranges
|
||||
#
|
||||
|
||||
class ASNRangeSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail')
|
||||
rir = NestedRIRSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
asn_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ASNRange
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'asn_count',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# ASNs
|
||||
#
|
||||
from .nested_serializers import NestedL2VPNSerializer
|
||||
from ..models.l2vpn import L2VPNTermination, L2VPN
|
||||
|
||||
|
||||
class ASNSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
|
||||
rir = NestedRIRSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
provider_count = serializers.IntegerField(read_only=True)
|
||||
@ -36,6 +52,22 @@ class ASNSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class AvailableASNSerializer(serializers.Serializer):
|
||||
"""
|
||||
Representation of an ASN which does not exist in the database.
|
||||
"""
|
||||
asn = serializers.IntegerField(read_only=True)
|
||||
|
||||
def to_representation(self, asn):
|
||||
rir = NestedRIRSerializer(self.context['range'].rir, context={
|
||||
'request': self.context['request']
|
||||
}).data
|
||||
return {
|
||||
'rir': rir,
|
||||
'asn': asn,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
@ -347,6 +379,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
|
||||
|
@ -7,50 +7,33 @@ from . import views
|
||||
router = NetBoxRouter()
|
||||
router.APIRootView = views.IPAMRootView
|
||||
|
||||
# ASNs
|
||||
router.register('asns', views.ASNViewSet)
|
||||
|
||||
# VRFs
|
||||
router.register('asn-ranges', views.ASNRangeViewSet)
|
||||
router.register('vrfs', views.VRFViewSet)
|
||||
|
||||
# Route targets
|
||||
router.register('route-targets', views.RouteTargetViewSet)
|
||||
|
||||
# RIRs
|
||||
router.register('rirs', views.RIRViewSet)
|
||||
|
||||
# Aggregates
|
||||
router.register('aggregates', views.AggregateViewSet)
|
||||
|
||||
# Prefixes
|
||||
router.register('roles', views.RoleViewSet)
|
||||
router.register('prefixes', views.PrefixViewSet)
|
||||
|
||||
# IP ranges
|
||||
router.register('ip-ranges', views.IPRangeViewSet)
|
||||
|
||||
# IP addresses
|
||||
router.register('ip-addresses', views.IPAddressViewSet)
|
||||
|
||||
# FHRP groups
|
||||
router.register('fhrp-groups', views.FHRPGroupViewSet)
|
||||
router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet)
|
||||
|
||||
# VLANs
|
||||
router.register('vlan-groups', views.VLANGroupViewSet)
|
||||
router.register('vlans', views.VLANViewSet)
|
||||
|
||||
# Services
|
||||
router.register('service-templates', views.ServiceTemplateViewSet)
|
||||
router.register('services', views.ServiceViewSet)
|
||||
|
||||
# L2VPN
|
||||
router.register('l2vpns', views.L2VPNViewSet)
|
||||
router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
|
||||
|
||||
app_name = 'ipam-api'
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
'asn-ranges/<int:pk>/available-asns/',
|
||||
views.AvailableASNsView.as_view(),
|
||||
name='asnrange-available-asns'
|
||||
),
|
||||
path(
|
||||
'ip-ranges/<int:pk>/available-ips/',
|
||||
views.IPRangeAvailableIPAddressesView.as_view(),
|
||||
|
@ -33,6 +33,12 @@ class IPAMRootView(APIRootView):
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ASNRangeViewSet(NetBoxModelViewSet):
|
||||
queryset = ASNRange.objects.prefetch_related('tenant', 'rir').all()
|
||||
serializer_class = serializers.ASNRangeSerializer
|
||||
filterset_class = filtersets.ASNRangeFilterSet
|
||||
|
||||
|
||||
class ASNViewSet(NetBoxModelViewSet):
|
||||
queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(
|
||||
site_count=count_related(Site, 'asns'),
|
||||
@ -201,6 +207,69 @@ def get_results_limit(request):
|
||||
return limit
|
||||
|
||||
|
||||
class AvailableASNsView(ObjectValidationMixin, APIView):
|
||||
queryset = ASN.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
|
||||
limit = get_results_limit(request)
|
||||
|
||||
available_asns = asnrange.get_available_asns()[:limit]
|
||||
|
||||
serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={
|
||||
'request': request,
|
||||
'range': asnrange,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-asns'])
|
||||
def post(self, request, pk):
|
||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||
asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
|
||||
|
||||
# Normalize to a list of objects
|
||||
requested_asns = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
# Determine if the requested number of IPs is available
|
||||
available_asns = asnrange.get_available_asns()
|
||||
if len(available_asns) < len(requested_asns):
|
||||
return Response(
|
||||
{
|
||||
"detail": f"An insufficient number of ASNs are available within {asnrange} "
|
||||
f"({len(requested_asns)} requested, {len(available_asns)} available)"
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
# Assign ASNs from the list of available IPs and copy VRF assignment from the parent
|
||||
for i, requested_asn in enumerate(requested_asns):
|
||||
requested_asn.update({
|
||||
'rir': asnrange.rir.pk,
|
||||
'range': asnrange.pk,
|
||||
'asn': available_asns[i],
|
||||
})
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
context = {'request': request}
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context)
|
||||
else:
|
||||
serializer = serializers.ASNSerializer(data=requested_asns[0], context=context)
|
||||
|
||||
# Create the new IP address(es)
|
||||
if serializer.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class AvailablePrefixesView(ObjectValidationMixin, APIView):
|
||||
queryset = Prefix.objects.all()
|
||||
serializer_class = serializers.PrefixSerializer # for drf-spectacular
|
||||
|
@ -2,10 +2,6 @@ from django.db.models import Q
|
||||
|
||||
from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices
|
||||
|
||||
# BGP ASN bounds
|
||||
BGP_ASN_MIN = 1
|
||||
BGP_ASN_MAX = 2**32 - 1
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
|
@ -1,10 +1,21 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from netaddr import AddrFormatError, IPNetwork
|
||||
|
||||
from . import lookups, validators
|
||||
from .formfields import IPNetworkFormField
|
||||
|
||||
__all__ = (
|
||||
'ASNField',
|
||||
'IPAddressField',
|
||||
'IPNetworkField',
|
||||
)
|
||||
|
||||
# BGP ASN bounds
|
||||
BGP_ASN_MIN = 1
|
||||
BGP_ASN_MAX = 2**32 - 1
|
||||
|
||||
|
||||
class BaseIPField(models.Field):
|
||||
|
||||
@ -93,3 +104,19 @@ IPAddressField.register_lookup(lookups.NetIn)
|
||||
IPAddressField.register_lookup(lookups.NetHostContained)
|
||||
IPAddressField.register_lookup(lookups.NetFamily)
|
||||
IPAddressField.register_lookup(lookups.NetMaskLength)
|
||||
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
description = "32-bit ASN field"
|
||||
default_validators = [
|
||||
MinValueValidator(BGP_ASN_MIN),
|
||||
MaxValueValidator(BGP_ASN_MAX),
|
||||
]
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {
|
||||
'min_value': BGP_ASN_MIN,
|
||||
'max_value': BGP_ASN_MAX,
|
||||
}
|
||||
defaults.update(**kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
@ -22,6 +22,7 @@ from .models import *
|
||||
__all__ = (
|
||||
'AggregateFilterSet',
|
||||
'ASNFilterSet',
|
||||
'ASNRangeFilterSet',
|
||||
'FHRPGroupAssignmentFilterSet',
|
||||
'FHRPGroupFilterSet',
|
||||
'IPAddressFilterSet',
|
||||
@ -169,6 +170,29 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
rir_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RIR.objects.all(),
|
||||
label=_('RIR (ID)'),
|
||||
)
|
||||
rir = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rir__slug',
|
||||
queryset=RIR.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('RIR (slug)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ASNRange
|
||||
fields = ['id', 'name', 'start', 'end', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
rir_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RIR.objects.all(),
|
||||
@ -446,7 +470,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = IPRange
|
||||
fields = ['id', 'description']
|
||||
fields = ['id', 'mark_utilized', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
@ -16,6 +16,7 @@ from utilities.forms import (
|
||||
__all__ = (
|
||||
'AggregateBulkEditForm',
|
||||
'ASNBulkEditForm',
|
||||
'ASNRangeBulkEditForm',
|
||||
'FHRPGroupBulkEditForm',
|
||||
'IPAddressBulkEditForm',
|
||||
'IPRangeBulkEditForm',
|
||||
@ -97,6 +98,28 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm):
|
||||
nullable_fields = ('is_private', 'description')
|
||||
|
||||
|
||||
class ASNRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
rir = DynamicModelChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
required=False,
|
||||
label=_('RIR')
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
|
||||
model = ASNRange
|
||||
fieldsets = (
|
||||
(None, ('rir', 'tenant', 'description')),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
sites = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@ -124,7 +147,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
fieldsets = (
|
||||
(None, ('sites', 'rir', 'tenant', 'description')),
|
||||
)
|
||||
nullable_fields = ('date_added', 'description', 'comments')
|
||||
nullable_fields = ('tenant', 'description', 'comments')
|
||||
|
||||
|
||||
class AggregateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
@ -259,6 +282,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=Role.objects.all(),
|
||||
required=False
|
||||
)
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Treat as 100% utilized')
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=200,
|
||||
required=False
|
||||
@ -270,7 +298,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = IPRange
|
||||
fieldsets = (
|
||||
(None, ('status', 'role', 'vrf', 'tenant', 'description')),
|
||||
(None, ('status', 'role', 'vrf', 'tenant', 'mark_utilized', 'description')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'vrf', 'tenant', 'role', 'description', 'comments',
|
||||
|
@ -15,6 +15,7 @@ from virtualization.models import VirtualMachine, VMInterface
|
||||
__all__ = (
|
||||
'AggregateImportForm',
|
||||
'ASNImportForm',
|
||||
'ASNRangeImportForm',
|
||||
'FHRPGroupImportForm',
|
||||
'IPAddressImportForm',
|
||||
'IPRangeImportForm',
|
||||
@ -64,9 +65,6 @@ class RIRImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ('name', 'slug', 'is_private', 'description', 'tags')
|
||||
help_texts = {
|
||||
'name': _('RIR name'),
|
||||
}
|
||||
|
||||
|
||||
class AggregateImportForm(NetBoxModelImportForm):
|
||||
@ -87,6 +85,24 @@ class AggregateImportForm(NetBoxModelImportForm):
|
||||
fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags')
|
||||
|
||||
|
||||
class ASNRangeImportForm(NetBoxModelImportForm):
|
||||
rir = CSVModelChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned RIR')
|
||||
)
|
||||
tenant = CSVModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned tenant')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ASNRange
|
||||
fields = ('name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags')
|
||||
|
||||
|
||||
class ASNImportForm(NetBoxModelImportForm):
|
||||
rir = CSVModelChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
@ -204,7 +220,8 @@ class IPRangeImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = IPRange
|
||||
fields = (
|
||||
'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', 'tags',
|
||||
'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_utilized', 'description',
|
||||
'comments', 'tags',
|
||||
)
|
||||
|
||||
|
||||
@ -390,10 +407,6 @@ class VLANImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags')
|
||||
help_texts = {
|
||||
'vid': 'Numeric VLAN ID (1-4094)',
|
||||
'name': 'VLAN name',
|
||||
}
|
||||
|
||||
|
||||
class ServiceTemplateImportForm(NetBoxModelImportForm):
|
||||
|
@ -17,6 +17,7 @@ from virtualization.models import VirtualMachine
|
||||
__all__ = (
|
||||
'AggregateFilterForm',
|
||||
'ASNFilterForm',
|
||||
'ASNRangeFilterForm',
|
||||
'FHRPGroupFilterForm',
|
||||
'IPAddressFilterForm',
|
||||
'IPRangeFilterForm',
|
||||
@ -114,6 +115,27 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ASNRange
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Range', ('rir_id', 'start', 'end')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
required=False,
|
||||
label=_('RIR')
|
||||
)
|
||||
start = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
end = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ASN
|
||||
fieldsets = (
|
||||
@ -231,7 +253,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPRange
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')),
|
||||
('Attriubtes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
@ -255,6 +277,13 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
null_option='None',
|
||||
label=_('Role')
|
||||
)
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Marked as 100% utilized'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInter
|
||||
__all__ = (
|
||||
'AggregateForm',
|
||||
'ASNForm',
|
||||
'ASNRangeForm',
|
||||
'FHRPGroupForm',
|
||||
'FHRPGroupAssignmentForm',
|
||||
'IPAddressAssignForm',
|
||||
@ -67,9 +68,6 @@ class VRFForm(TenancyForm, NetBoxModelForm):
|
||||
labels = {
|
||||
'rd': "RD",
|
||||
}
|
||||
help_texts = {
|
||||
'rd': _("Route distinguisher in any format"),
|
||||
}
|
||||
|
||||
|
||||
class RouteTargetForm(TenancyForm, NetBoxModelForm):
|
||||
@ -119,15 +117,29 @@ class AggregateForm(TenancyForm, NetBoxModelForm):
|
||||
fields = [
|
||||
'prefix', 'rir', 'date_added', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'prefix': _("IPv4 or IPv6 network"),
|
||||
'rir': _("Regional Internet Registry responsible for this prefix"),
|
||||
}
|
||||
widgets = {
|
||||
'date_added': DatePicker(),
|
||||
}
|
||||
|
||||
|
||||
class ASNRangeForm(TenancyForm, NetBoxModelForm):
|
||||
rir = DynamicModelChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
label=_('RIR'),
|
||||
)
|
||||
slug = SlugField()
|
||||
fieldsets = (
|
||||
('ASN Range', ('name', 'slug', 'rir', 'start', 'end', 'description', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ASNRange
|
||||
fields = [
|
||||
'name', 'slug', 'rir', 'start', 'end', 'tenant_group', 'tenant', 'description', 'tags'
|
||||
]
|
||||
|
||||
|
||||
class ASNForm(TenancyForm, NetBoxModelForm):
|
||||
rir = DynamicModelChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
@ -150,10 +162,6 @@ class ASNForm(TenancyForm, NetBoxModelForm):
|
||||
fields = [
|
||||
'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'comments', 'tags'
|
||||
]
|
||||
help_texts = {
|
||||
'asn': _("AS number"),
|
||||
'rir': _("Regional Internet Registry responsible for this prefix"),
|
||||
}
|
||||
widgets = {
|
||||
'date_added': DatePicker(),
|
||||
}
|
||||
@ -269,15 +277,15 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')),
|
||||
('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'mark_utilized', 'description', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPRange
|
||||
fields = [
|
||||
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description',
|
||||
'comments', 'tags',
|
||||
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_utilized',
|
||||
'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@ -769,14 +777,6 @@ class VLANForm(TenancyForm, NetBoxModelForm):
|
||||
'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments',
|
||||
'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'site': _("Leave blank if this VLAN spans multiple sites"),
|
||||
'group': _("VLAN group (optional)"),
|
||||
'vid': _("Configured VLAN ID"),
|
||||
'name': _("Configured VLAN name"),
|
||||
'status': _("Operational status of this VLAN"),
|
||||
'role': _("The primary function of this VLAN"),
|
||||
}
|
||||
|
||||
|
||||
class ServiceTemplateForm(NetBoxModelForm):
|
||||
@ -832,10 +832,6 @@ class ServiceForm(NetBoxModelForm):
|
||||
fields = [
|
||||
'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'ipaddresses': _("IP address assignment is optional. If no IPs are selected, the service is assumed to be "
|
||||
"reachable via all IPs assigned to the device."),
|
||||
}
|
||||
|
||||
|
||||
class ServiceCreateForm(ServiceForm):
|
||||
|
@ -8,6 +8,9 @@ class IPAMQuery(graphene.ObjectType):
|
||||
asn = ObjectField(ASNType)
|
||||
asn_list = ObjectListField(ASNType)
|
||||
|
||||
asn_range = ObjectField(ASNRangeType)
|
||||
asn_range_list = ObjectListField(ASNRangeType)
|
||||
|
||||
aggregate = ObjectField(AggregateType)
|
||||
aggregate_list = ObjectListField(AggregateType)
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import graphene
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from extras.graphql.mixins import ContactsMixin
|
||||
from ipam import filtersets, models
|
||||
from netbox.graphql.scalars import BigInt
|
||||
@ -8,6 +7,7 @@ from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBo
|
||||
|
||||
__all__ = (
|
||||
'ASNType',
|
||||
'ASNRangeType',
|
||||
'AggregateType',
|
||||
'FHRPGroupType',
|
||||
'FHRPGroupAssignmentType',
|
||||
@ -36,6 +36,14 @@ class ASNType(NetBoxObjectType):
|
||||
filterset_class = filtersets.ASNFilterSet
|
||||
|
||||
|
||||
class ASNRangeType(NetBoxObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ASNRange
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.ASNRangeFilterSet
|
||||
|
||||
|
||||
class AggregateType(NetBoxObjectType):
|
||||
|
||||
class Meta:
|
||||
|
@ -1,6 +1,4 @@
|
||||
# Generated by Django 3.2.8 on 2021-11-02 16:16
|
||||
|
||||
import dcim.fields
|
||||
import ipam.fields
|
||||
from utilities.json import CustomFieldJSONEncoder
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
@ -23,7 +21,7 @@ class Migration(migrations.Migration):
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('asn', dcim.fields.ASNField(unique=True)),
|
||||
('asn', ipam.fields.ASNField(unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
|
41
netbox/ipam/migrations/0064_asnrange.py
Normal file
41
netbox/ipam/migrations/0064_asnrange.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-26 19:33
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import ipam.fields
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0009_standardize_description_comments'),
|
||||
('extras', '0087_dashboard'),
|
||||
('ipam', '0063_standardize_description_comments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ASNRange',
|
||||
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)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
('start', ipam.fields.ASNField()),
|
||||
('end', ipam.fields.ASNField()),
|
||||
('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asn_ranges', to='ipam.rir')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='asn_ranges', to='tenancy.tenant')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ASN range',
|
||||
'verbose_name_plural': 'ASN ranges',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
),
|
||||
]
|
18
netbox/ipam/migrations/0065_iprange_mark_utilized.py
Normal file
18
netbox/ipam/migrations/0065_iprange_mark_utilized.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-28 14:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0064_asnrange'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='iprange',
|
||||
name='mark_utilized',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -1,4 +1,5 @@
|
||||
# Ensure that VRFs are imported before IPs/prefixes so dumpdata & loaddata work correctly
|
||||
from .asns import *
|
||||
from .fhrp import *
|
||||
from .vrfs import *
|
||||
from .ip import *
|
||||
@ -8,6 +9,7 @@ from .vlans import *
|
||||
|
||||
__all__ = (
|
||||
'ASN',
|
||||
'ASNRange',
|
||||
'Aggregate',
|
||||
'IPAddress',
|
||||
'IPRange',
|
||||
|
138
netbox/ipam/models/asns.py
Normal file
138
netbox/ipam/models/asns.py
Normal file
@ -0,0 +1,138 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from ipam.fields import ASNField
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
|
||||
__all__ = (
|
||||
'ASN',
|
||||
'ASNRange',
|
||||
)
|
||||
|
||||
|
||||
class ASNRange(OrganizationalModel):
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
rir = models.ForeignKey(
|
||||
to='ipam.RIR',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='asn_ranges',
|
||||
verbose_name='RIR'
|
||||
)
|
||||
start = ASNField()
|
||||
end = ASNField()
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='asn_ranges',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = 'ASN range'
|
||||
verbose_name_plural = 'ASN ranges'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.range_as_string()})'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:asnrange', args=[self.pk])
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
return range(self.start, self.end + 1)
|
||||
|
||||
def range_as_string(self):
|
||||
return f'{self.start}-{self.end}'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.end <= self.start:
|
||||
raise ValidationError(f"Starting ASN ({self.start}) must be lower than ending ASN ({self.end}).")
|
||||
|
||||
def get_child_asns(self):
|
||||
return ASN.objects.filter(
|
||||
asn__gte=self.start,
|
||||
asn__lte=self.end
|
||||
)
|
||||
|
||||
def get_available_asns(self):
|
||||
"""
|
||||
Return all available ASNs within this range.
|
||||
"""
|
||||
range = set(self.range)
|
||||
existing_asns = set(self.get_child_asns().values_list('asn', flat=True))
|
||||
available_asns = sorted(range - existing_asns)
|
||||
|
||||
return available_asns
|
||||
|
||||
|
||||
class ASN(PrimaryModel):
|
||||
"""
|
||||
An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have
|
||||
one or more ASNs assigned to it.
|
||||
"""
|
||||
rir = models.ForeignKey(
|
||||
to='ipam.RIR',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='asns',
|
||||
verbose_name='RIR',
|
||||
help_text=_("Regional Internet Registry responsible for this AS number space")
|
||||
)
|
||||
asn = ASNField(
|
||||
unique=True,
|
||||
verbose_name='ASN',
|
||||
help_text=_('16- or 32-bit autonomous system number')
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='asns',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
prerequisite_models = (
|
||||
'ipam.RIR',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['asn']
|
||||
verbose_name = 'ASN'
|
||||
verbose_name_plural = 'ASNs'
|
||||
|
||||
def __str__(self):
|
||||
return f'AS{self.asn_with_asdot}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:asn', args=[self.pk])
|
||||
|
||||
@property
|
||||
def asn_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn // 65536}.{self.asn % 65536}'
|
||||
return self.asn
|
||||
|
||||
@property
|
||||
def asn_with_asdot(self):
|
||||
"""
|
||||
Return both plain and ASDOT notation, where applicable.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
|
||||
else:
|
||||
return self.asn
|
@ -8,7 +8,6 @@ from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.fields import IPNetworkField, IPAddressField
|
||||
@ -20,7 +19,6 @@ from netbox.models import OrganizationalModel, PrimaryModel
|
||||
|
||||
__all__ = (
|
||||
'Aggregate',
|
||||
'ASN',
|
||||
'IPAddress',
|
||||
'IPRange',
|
||||
'Prefix',
|
||||
@ -74,76 +72,20 @@ class RIR(OrganizationalModel):
|
||||
return reverse('ipam:rir', args=[self.pk])
|
||||
|
||||
|
||||
class ASN(PrimaryModel):
|
||||
"""
|
||||
An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have
|
||||
one or more ASNs assigned to it.
|
||||
"""
|
||||
asn = ASNField(
|
||||
unique=True,
|
||||
verbose_name='ASN',
|
||||
help_text=_('32-bit autonomous system number')
|
||||
)
|
||||
rir = models.ForeignKey(
|
||||
to='ipam.RIR',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='asns',
|
||||
verbose_name='RIR'
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='asns',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
prerequisite_models = (
|
||||
'ipam.RIR',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['asn']
|
||||
verbose_name = 'ASN'
|
||||
verbose_name_plural = 'ASNs'
|
||||
|
||||
def __str__(self):
|
||||
return f'AS{self.asn_with_asdot}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:asn', args=[self.pk])
|
||||
|
||||
@property
|
||||
def asn_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn // 65536}.{self.asn % 65536}'
|
||||
return self.asn
|
||||
|
||||
@property
|
||||
def asn_with_asdot(self):
|
||||
"""
|
||||
Return both plain and ASDOT notation, where applicable.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
|
||||
else:
|
||||
return self.asn
|
||||
|
||||
|
||||
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
||||
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
|
||||
"""
|
||||
prefix = IPNetworkField()
|
||||
prefix = IPNetworkField(
|
||||
help_text=_("IPv4 or IPv6 network")
|
||||
)
|
||||
rir = models.ForeignKey(
|
||||
to='ipam.RIR',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='aggregates',
|
||||
verbose_name='RIR'
|
||||
verbose_name='RIR',
|
||||
help_text=_("Regional Internet Registry responsible for this IP space")
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
@ -572,6 +514,10 @@ class IPRange(PrimaryModel):
|
||||
null=True,
|
||||
help_text=_('The primary function of this range')
|
||||
)
|
||||
mark_utilized = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Treat as 100% utilized")
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'vrf', 'tenant', 'status', 'role', 'description',
|
||||
@ -713,6 +659,9 @@ class IPRange(PrimaryModel):
|
||||
"""
|
||||
Determine the utilization of the range and return it as a percentage.
|
||||
"""
|
||||
if self.mark_utilized:
|
||||
return 100
|
||||
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_count = netaddr.IPSet([
|
||||
ip.address.ip for ip in self.get_child_ips()
|
||||
|
@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
@ -85,7 +86,8 @@ class Service(ServiceBase, PrimaryModel):
|
||||
to='ipam.IPAddress',
|
||||
related_name='services',
|
||||
blank=True,
|
||||
verbose_name='IP addresses'
|
||||
verbose_name='IP addresses',
|
||||
help_text=_("The specific IP addresses (if any) to which this service is bound")
|
||||
)
|
||||
|
||||
clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
|
||||
|
@ -129,21 +129,24 @@ class VLAN(PrimaryModel):
|
||||
on_delete=models.PROTECT,
|
||||
related_name='vlans',
|
||||
blank=True,
|
||||
null=True
|
||||
null=True,
|
||||
help_text=_("The specific site to which this VLAN is assigned (if any)")
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
to='ipam.VLANGroup',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='vlans',
|
||||
blank=True,
|
||||
null=True
|
||||
null=True,
|
||||
help_text=_("VLAN group (optional)")
|
||||
)
|
||||
vid = models.PositiveSmallIntegerField(
|
||||
verbose_name='ID',
|
||||
validators=(
|
||||
MinValueValidator(VLAN_VID_MIN),
|
||||
MaxValueValidator(VLAN_VID_MAX)
|
||||
)
|
||||
),
|
||||
help_text=_("Numeric VLAN ID (1-4094)")
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
@ -158,14 +161,16 @@ class VLAN(PrimaryModel):
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=VLANStatusChoices,
|
||||
default=VLANStatusChoices.STATUS_ACTIVE
|
||||
default=VLANStatusChoices.STATUS_ACTIVE,
|
||||
help_text=_("Operational status of this VLAN")
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
to='ipam.Role',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='vlans',
|
||||
blank=True,
|
||||
null=True
|
||||
null=True,
|
||||
help_text=_("The primary function of this VLAN")
|
||||
)
|
||||
|
||||
l2vpn_terminations = GenericRelation(
|
||||
|
@ -22,6 +22,14 @@ class ASNIndex(SearchIndex):
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class ASNRangeIndex(SearchIndex):
|
||||
model = models.ASNRange
|
||||
fields = (
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class FHRPGroupIndex(SearchIndex):
|
||||
model = models.FHRPGroup
|
||||
|
@ -1,3 +1,4 @@
|
||||
from .asn import *
|
||||
from .fhrp import *
|
||||
from .ip import *
|
||||
from .l2vpn import *
|
||||
|
77
netbox/ipam/tables/asn.py
Normal file
77
netbox/ipam/tables/asn.py
Normal file
@ -0,0 +1,77 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from ipam.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
__all__ = (
|
||||
'ASNTable',
|
||||
'ASNRangeTable',
|
||||
)
|
||||
|
||||
|
||||
class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rir = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:asnrange_list'
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ASNRange
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'rir', 'start', 'end', 'asn_count', 'tenant', 'tenant_group', 'description', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rir', 'start', 'end', 'tenant', 'asn_count', 'description')
|
||||
|
||||
|
||||
class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
asn = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rir = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
asn_asdot = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True,
|
||||
verbose_name=_('ASDOT')
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name=_('Site Count')
|
||||
)
|
||||
provider_count = columns.LinkedCountColumn(
|
||||
viewname='circuits:provider_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name=_('Provider Count')
|
||||
)
|
||||
sites = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:asn_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ASN
|
||||
fields = (
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description',
|
||||
'comments', 'sites', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant',
|
||||
)
|
@ -8,7 +8,6 @@ from tenancy.tables import TenancyColumnsMixin, TenantColumn
|
||||
|
||||
__all__ = (
|
||||
'AggregateTable',
|
||||
'ASNTable',
|
||||
'AssignedIPAddressesTable',
|
||||
'IPAddressAssignTable',
|
||||
'IPAddressTable',
|
||||
@ -93,47 +92,6 @@ class RIRTable(NetBoxTable):
|
||||
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description')
|
||||
|
||||
|
||||
#
|
||||
# ASNs
|
||||
#
|
||||
|
||||
class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
asn = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
asn_asdot = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True,
|
||||
verbose_name='ASDOT'
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name='Site Count'
|
||||
)
|
||||
provider_count = columns.LinkedCountColumn(
|
||||
viewname='circuits:provider_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name='Provider Count'
|
||||
)
|
||||
sites = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='Sites'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:asn_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ASN
|
||||
fields = (
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description',
|
||||
'comments', 'sites', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant')
|
||||
|
||||
|
||||
#
|
||||
# Aggregates
|
||||
#
|
||||
@ -317,6 +275,9 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
mark_utilized = columns.BooleanColumn(
|
||||
verbose_name='Marked Utilized'
|
||||
)
|
||||
utilization = columns.UtilizationColumn(
|
||||
accessor='utilization',
|
||||
orderable=False
|
||||
@ -330,7 +291,7 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
model = IPRange
|
||||
fields = (
|
||||
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group',
|
||||
'utilization', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
|
@ -21,6 +21,118 @@ class AppTest(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ASNRangeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ASNRange
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
rirs = (
|
||||
RIR(name='RIR 1', slug='rir-1', is_private=True),
|
||||
RIR(name='RIR 2', slug='rir-2', is_private=True),
|
||||
)
|
||||
RIR.objects.bulk_create(rirs)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
asn_ranges = (
|
||||
ASNRange(name='ASN Range 1', slug='asn-range-1', rir=rirs[0], tenant=tenants[0], start=100, end=199),
|
||||
ASNRange(name='ASN Range 2', slug='asn-range-2', rir=rirs[0], tenant=tenants[0], start=200, end=299),
|
||||
ASNRange(name='ASN Range 3', slug='asn-range-3', rir=rirs[0], tenant=tenants[0], start=300, end=399),
|
||||
)
|
||||
ASNRange.objects.bulk_create(asn_ranges)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'ASN Range 4',
|
||||
'slug': 'asn-range-4',
|
||||
'rir': rirs[1].pk,
|
||||
'start': 400,
|
||||
'end': 499,
|
||||
'tenant': tenants[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'ASN Range 5',
|
||||
'slug': 'asn-range-5',
|
||||
'rir': rirs[1].pk,
|
||||
'start': 500,
|
||||
'end': 599,
|
||||
'tenant': tenants[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'ASN Range 6',
|
||||
'slug': 'asn-range-6',
|
||||
'rir': rirs[1].pk,
|
||||
'start': 600,
|
||||
'end': 699,
|
||||
'tenant': tenants[1].pk,
|
||||
},
|
||||
]
|
||||
|
||||
def test_list_available_asns(self):
|
||||
"""
|
||||
Test retrieval of all available ASNs within a parent range.
|
||||
"""
|
||||
rir = RIR.objects.first()
|
||||
asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
|
||||
url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
|
||||
self.add_permissions('ipam.view_asnrange', 'ipam.view_asn')
|
||||
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 10)
|
||||
|
||||
def test_create_single_available_asn(self):
|
||||
"""
|
||||
Test creation of the first available ASN within a range.
|
||||
"""
|
||||
rir = RIR.objects.first()
|
||||
asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
|
||||
url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
|
||||
self.add_permissions('ipam.view_asnrange', 'ipam.add_asn')
|
||||
|
||||
data = {
|
||||
'description': 'New ASN'
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['rir']['id'], asnrange.rir.pk)
|
||||
self.assertEqual(response.data['description'], data['description'])
|
||||
|
||||
def test_create_multiple_available_asns(self):
|
||||
"""
|
||||
Test the creation of several available ASNs within a parent range.
|
||||
"""
|
||||
rir = RIR.objects.first()
|
||||
asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
|
||||
url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
|
||||
self.add_permissions('ipam.view_asnrange', 'ipam.add_asn')
|
||||
|
||||
# Try to create eleven ASNs (only ten are available)
|
||||
data = [
|
||||
{'description': f'New ASN {i}'}
|
||||
for i in range(1, 12)
|
||||
]
|
||||
assert len(data) == 11
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Create all ten available ASNs in a single request
|
||||
data.pop()
|
||||
assert len(data) == 10
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), 10)
|
||||
|
||||
|
||||
class ASNTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ASN
|
||||
brief_fields = ['asn', 'display', 'id', 'url']
|
||||
@ -30,25 +142,29 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
rirs = (
|
||||
RIR(name='RIR 1', slug='rir-1', is_private=True),
|
||||
RIR(name='RIR 2', slug='rir-2', is_private=True),
|
||||
)
|
||||
RIR.objects.bulk_create(rirs)
|
||||
|
||||
rirs = [
|
||||
RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True),
|
||||
RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True),
|
||||
]
|
||||
sites = [
|
||||
Site.objects.create(name='Site 1', slug='site-1'),
|
||||
Site.objects.create(name='Site 2', slug='site-2')
|
||||
]
|
||||
tenants = [
|
||||
Tenant.objects.create(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant.objects.create(name='Tenant 2', slug='tenant-2'),
|
||||
]
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2')
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
asns = (
|
||||
ASN(asn=64513, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=65534, rir=rirs[0], tenant=tenants[1]),
|
||||
ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]),
|
||||
ASN(asn=65000, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=65001, rir=rirs[0], tenant=tenants[1]),
|
||||
ASN(asn=4200000000, rir=rirs[1], tenant=tenants[0]),
|
||||
ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]),
|
||||
)
|
||||
ASN.objects.bulk_create(asns)
|
||||
|
||||
@ -63,12 +179,12 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
|
||||
'rir': rirs[0].pk,
|
||||
},
|
||||
{
|
||||
'asn': 65543,
|
||||
'asn': 65002,
|
||||
'rir': rirs[0].pk,
|
||||
},
|
||||
{
|
||||
'asn': 4294967294,
|
||||
'rir': rirs[0].pk,
|
||||
'asn': 4200000002,
|
||||
'rir': rirs[1].pk,
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -12,84 +12,160 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
|
||||
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ASNRange.objects.all()
|
||||
filterset = ASNRangeFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
rirs = [
|
||||
RIR(name='RIR 1', slug='rir-1'),
|
||||
RIR(name='RIR 2', slug='rir-2'),
|
||||
RIR(name='RIR 3', slug='rir-3'),
|
||||
]
|
||||
RIR.objects.bulk_create(rirs)
|
||||
|
||||
tenants = [
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
]
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
asn_ranges = (
|
||||
ASNRange(
|
||||
name='ASN Range 1',
|
||||
slug='asn-range-1',
|
||||
rir=rirs[0],
|
||||
tenant=None,
|
||||
start=65000,
|
||||
end=65009,
|
||||
description='aaa'
|
||||
),
|
||||
ASNRange(
|
||||
name='ASN Range 2',
|
||||
slug='asn-range-2',
|
||||
rir=rirs[1],
|
||||
tenant=tenants[0],
|
||||
start=65010,
|
||||
end=65019,
|
||||
description='bbb'
|
||||
),
|
||||
ASNRange(
|
||||
name='ASN Range 3',
|
||||
slug='asn-range-3',
|
||||
rir=rirs[2],
|
||||
tenant=tenants[1],
|
||||
start=65020,
|
||||
end=65029,
|
||||
description='ccc'
|
||||
),
|
||||
)
|
||||
ASNRange.objects.bulk_create(asn_ranges)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['ASN Range 1', 'ASN Range 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rir(self):
|
||||
rirs = RIR.objects.all()[:2]
|
||||
params = {'rir_id': [rirs[0].pk, rirs[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'rir': [rirs[0].slug, rirs[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_start(self):
|
||||
params = {'start': [65000, 65010]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_end(self):
|
||||
params = {'end': [65009, 65019]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['aaa', 'bbb']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ASN.objects.all()
|
||||
filterset = ASNFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
rirs = [
|
||||
RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True),
|
||||
RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True),
|
||||
RIR(name='RIR 1', slug='rir-1', is_private=True),
|
||||
RIR(name='RIR 2', slug='rir-2', is_private=True),
|
||||
RIR(name='RIR 3', slug='rir-3', is_private=True),
|
||||
]
|
||||
RIR.objects.bulk_create(rirs)
|
||||
|
||||
sites = [
|
||||
Site.objects.create(name='Site 1', slug='site-1'),
|
||||
Site.objects.create(name='Site 2', slug='site-2'),
|
||||
Site.objects.create(name='Site 3', slug='site-3')
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3')
|
||||
]
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
tenants = [
|
||||
Tenant.objects.create(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant.objects.create(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant.objects.create(name='Tenant 3', slug='tenant-3'),
|
||||
Tenant.objects.create(name='Tenant 4', slug='tenant-4'),
|
||||
Tenant.objects.create(name='Tenant 5', slug='tenant-5'),
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
Tenant(name='Tenant 4', slug='tenant-4'),
|
||||
Tenant(name='Tenant 5', slug='tenant-5'),
|
||||
]
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
asns = (
|
||||
ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'),
|
||||
ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'),
|
||||
ASN(asn=64514, rir=rirs[0], tenant=tenants[1]),
|
||||
ASN(asn=64515, rir=rirs[0], tenant=tenants[2]),
|
||||
ASN(asn=64516, rir=rirs[0], tenant=tenants[3]),
|
||||
ASN(asn=65535, rir=rirs[1], tenant=tenants[4]),
|
||||
ASN(asn=65001, rir=rirs[0], tenant=tenants[0], description='aaa'),
|
||||
ASN(asn=65002, rir=rirs[1], tenant=tenants[1], description='bbb'),
|
||||
ASN(asn=65003, rir=rirs[2], tenant=tenants[2], description='ccc'),
|
||||
ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=4200000001, rir=rirs[0], tenant=tenants[1]),
|
||||
ASN(asn=4200000002, rir=rirs[0], tenant=tenants[2]),
|
||||
ASN(asn=4200000003, rir=rirs[0], tenant=tenants[3]),
|
||||
ASN(asn=4200002301, rir=rirs[1], tenant=tenants[4]),
|
||||
ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]),
|
||||
ASN(asn=4200000002, rir=rirs[2], tenant=tenants[2]),
|
||||
)
|
||||
ASN.objects.bulk_create(asns)
|
||||
|
||||
asns[0].sites.set([sites[0]])
|
||||
asns[1].sites.set([sites[0]])
|
||||
asns[2].sites.set([sites[1]])
|
||||
asns[3].sites.set([sites[2]])
|
||||
asns[4].sites.set([sites[0]])
|
||||
asns[5].sites.set([sites[1]])
|
||||
asns[6].sites.set([sites[0]])
|
||||
asns[7].sites.set([sites[1]])
|
||||
asns[8].sites.set([sites[2]])
|
||||
asns[9].sites.set([sites[0]])
|
||||
asns[10].sites.set([sites[1]])
|
||||
asns[1].sites.set([sites[1]])
|
||||
asns[2].sites.set([sites[2]])
|
||||
asns[3].sites.set([sites[0]])
|
||||
asns[4].sites.set([sites[1]])
|
||||
asns[5].sites.set([sites[2]])
|
||||
|
||||
def test_asn(self):
|
||||
params = {'asn': ['64512', '65535']}
|
||||
params = {'asn': [65001, 4200000000]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_rir(self):
|
||||
rirs = RIR.objects.all()[:1]
|
||||
params = {'rir_id': [rirs[0].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
|
||||
params = {'rir': [rirs[0].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
|
||||
rirs = RIR.objects.all()[:2]
|
||||
params = {'rir_id': [rirs[0].pk, rirs[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'rir': [rirs[0].slug, rirs[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
params = {'description': ['aaa', 'bbb']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user