9608 update merge

This commit is contained in:
Arthur 2023-03-10 11:05:41 -08:00
commit b6795f7cf8
154 changed files with 2542 additions and 1835 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
fields = (
('name', 100),
('slug', 110),
('napalm_driver', 300),
('description', 500),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,4 +5,4 @@ class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
from . import lookups, search, signals
from . import dashboard, lookups, search, signals

View File

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

View File

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

View File

@ -0,0 +1,2 @@
from .utils import *
from .widgets import *

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

View 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

View 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, {})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
from .asn import *
from .fhrp import *
from .ip import *
from .l2vpn import *

77
netbox/ipam/tables/asn.py Normal file
View 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',
)

View File

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

View File

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

View File

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