diff --git a/docs/configuration/napalm.md b/docs/configuration/napalm.md
index 253bea297..e9fc91b72 100644
--- a/docs/configuration/napalm.md
+++ b/docs/configuration/napalm.md
@@ -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
diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md
index 7dc82e179..612faefed 100644
--- a/docs/customization/custom-fields.md
+++ b/docs/customization/custom-fields.md
@@ -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
diff --git a/docs/features/api-integration.md b/docs/features/api-integration.md
index 50c31ec4f..c3b78de47 100644
--- a/docs/features/api-integration.md
+++ b/docs/features/api-integration.md
@@ -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).
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 26a2bf917..dc6c38977 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -199,14 +199,6 @@ When you have finished modifying the configuration, remember to save the file.
All Python packages required by NetBox are listed in `requirements.txt` and will be installed automatically. NetBox also supports some optional packages. If desired, these packages must be listed in `local_requirements.txt` within the NetBox root directory.
-### NAPALM
-
-Integration with the [NAPALM automation](../integrations/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
-
-```no-highlight
-sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt"
-```
-
### Remote File Storage
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`.
diff --git a/docs/integrations/napalm.md b/docs/integrations/napalm.md
index 60d8014e2..e7e0f108c 100644
--- a/docs/integrations/napalm.md
+++ b/docs/integrations/napalm.md
@@ -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.
diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md
index 1ca6ec191..05ade8666 100644
--- a/docs/models/extras/webhook.md
+++ b/docs/models/extras/webhook.md
@@ -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.
diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md
index e34790406..8de3cfd93 100644
--- a/docs/models/ipam/asn.md
+++ b/docs/models/ipam/asn.md
@@ -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
diff --git a/docs/models/ipam/asnrange.md b/docs/models/ipam/asnrange.md
new file mode 100644
index 000000000..30d2f49c3
--- /dev/null
+++ b/docs/models/ipam/asnrange.md
@@ -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).
diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md
index 53abf10b8..71f0884d9 100644
--- a/docs/models/ipam/iprange.md
+++ b/docs/models/ipam/iprange.md
@@ -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.
diff --git a/docs/models/tenancy/tenant.md b/docs/models/tenancy/tenant.md
index 7df6992d1..45a09c787 100644
--- a/docs/models/tenancy/tenant.md
+++ b/docs/models/tenancy/tenant.md
@@ -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
diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md
index 6ca403ed9..6a22dc78e 100644
--- a/docs/release-notes/version-3.5.md
+++ b/docs/release-notes/version-3.5.md
@@ -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
*
diff --git a/mkdocs.yml b/mkdocs.yml
index 2487176d3..2c24d2e00 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -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'
diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py
index b61fb1bc7..c1a0056c4 100644
--- a/netbox/circuits/forms/bulk_import.py
+++ b/netbox/circuits/forms/bulk_import.py
@@ -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):
diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py
index be0d39835..d06f0bd9d 100644
--- a/netbox/circuits/forms/model_forms.py
+++ b/netbox/circuits/forms/model_forms.py
@@ -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(),
diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py
index 656eb35a1..96fa3c086 100644
--- a/netbox/circuits/migrations/0001_squashed.py
+++ b/netbox/circuits/migrations/0001_squashed.py
@@ -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)),
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index a04d78d9f..1c9f9682e 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -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,
diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py
index 18a81dcef..abd5cc7a1 100644
--- a/netbox/circuits/models/providers.py
+++ b/netbox/circuits/models/providers.py
@@ -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,
diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py
index 464c3eb47..a3a478be5 100644
--- a/netbox/core/forms/model_forms.py
+++ b/netbox/core/forms/model_forms.py
@@ -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)
diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py
index ee285fa7c..8ef8c4e72 100644
--- a/netbox/core/jobs.py
+++ b/netbox/core/jobs.py
@@ -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)
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 6684f587e..b668e0d38 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -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',
]
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 09614bb8b..fd7f26fc0 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -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(
diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py
index 4a2755be9..cef3283bb 100644
--- a/netbox/dcim/fields.py
+++ b/netbox/dcim/fields.py
@@ -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"
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index fd3f9425e..7a27ef110 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -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):
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index 0bd5764a0..e54cc71ab 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -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):
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index e495ec34d..929f44762 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -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 = {}
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 34f91bbe8..f1f392c99 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -66,12 +66,6 @@ __all__ = (
'VirtualDeviceContextForm'
)
-INTERFACE_MODE_HELP_TEXT = """
-Access: One untagged VLAN
-Tagged: One untagged VLAN and/or one or more tagged VLANs
-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):
diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py
index 3d7156e17..cf0ef4816 100644
--- a/netbox/dcim/migrations/0001_squashed.py
+++ b/netbox/dcim/migrations/0001_squashed.py
@@ -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)),
diff --git a/netbox/dcim/migrations/0171_devicetype_add_bridge.py b/netbox/dcim/migrations/0171_devicetype_add_bridge.py
new file mode 100644
index 000000000..3e0700a7f
--- /dev/null
+++ b/netbox/dcim/migrations/0171_devicetype_add_bridge.py
@@ -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'),
+ ),
+ ]
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index be17627fb..e2d1cb50d 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -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,
}
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 26a6ade98..a6b8be57a 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -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')
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 7ce1a2388..de6d1bc83 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -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
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 03be2fdb3..e6a9b02d4 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -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',
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index c035fc1db..3bd434648 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -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
diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py
index bae4f030f..f70c729f4 100644
--- a/netbox/dcim/search.py
+++ b/netbox/dcim/search.py
@@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
fields = (
('name', 100),
('slug', 110),
- ('napalm_driver', 300),
('description', 500),
)
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index f68960965..bed32251c 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -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',
)
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index 91a37fab3..0536e8940 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -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"
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index c78b592d3..01ef4a87b 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -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]}
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index bba91412d..eef78c6c6 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -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',
}
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 093cb90c5..bc30c45af 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -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
#
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index 837a8f2d3..18cc860b1 100644
--- a/netbox/extras/admin.py
+++ b/netbox/extras/admin.py
@@ -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',),
}),
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 1cbd8333a..ce37eb957 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -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')
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
index f01cdcd00..e796f0fdb 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -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)),
+]
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 5d2138123..214ac019b 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -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()
diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py
index 965eb033e..f23e62dd2 100644
--- a/netbox/extras/apps.py
+++ b/netbox/extras/apps.py
@@ -5,4 +5,4 @@ class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
- from . import lookups, search, signals
+ from . import dashboard, lookups, search, signals
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index 92d09e2ad..878d9df6a 100644
--- a/netbox/extras/choices.py
+++ b/netbox/extras/choices.py
@@ -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'),
diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py
index d65fb9612..d64f02d6b 100644
--- a/netbox/extras/constants.py
+++ b/netbox/extras/constants.py
@@ -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,
+ },
+]
diff --git a/netbox/extras/dashboard/__init__.py b/netbox/extras/dashboard/__init__.py
new file mode 100644
index 000000000..2539f0cbe
--- /dev/null
+++ b/netbox/extras/dashboard/__init__.py
@@ -0,0 +1,2 @@
+from .utils import *
+from .widgets import *
diff --git a/netbox/extras/dashboard/forms.py b/netbox/extras/dashboard/forms.py
new file mode 100644
index 000000000..ba07be4b1
--- /dev/null
+++ b/netbox/extras/dashboard/forms.py
@@ -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')
diff --git a/netbox/extras/dashboard/utils.py b/netbox/extras/dashboard/utils.py
new file mode 100644
index 000000000..8281cc522
--- /dev/null
+++ b/netbox/extras/dashboard/utils.py
@@ -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
diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py
new file mode 100644
index 000000000..cee8f5f67
--- /dev/null
+++ b/netbox/extras/dashboard/widgets.py
@@ -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, {})
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index 816406647..4d9947147 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -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):
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index bba585591..156e2e9b0 100644
--- a/netbox/extras/forms/bulk_edit.py
+++ b/netbox/extras/forms/bulk_edit.py
@@ -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,
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index b035c2579..15ed01ac4 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -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'
)
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 114eb1a59..6e0cf7fc7 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -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')
)
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index 4ce81c01b..f4b491c85 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -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 {{ object }}
. '
- 'Links which render as empty text will not be displayed.'),
- 'link_url': _('Jinja2 template code for the link URL. Reference the object as {{ object }}
.'),
+ 'link_text': _(
+ "Jinja2 template code for the link text. Reference the object as {{ object }}
. Links "
+ "which render as empty text will not be displayed."
+ ),
+ 'link_url': _("Jinja2 template code for the link URL. Reference the object as {{ object }}
."),
}
@@ -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'}),
diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py
index ae49d53be..b10a4644d 100644
--- a/netbox/extras/management/commands/runscript.py
+++ b/netbox/extras/management/commands/runscript.py
@@ -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}")
diff --git a/netbox/extras/migrations/0087_dashboard.py b/netbox/extras/migrations/0087_dashboard.py
new file mode 100644
index 000000000..e64843e0e
--- /dev/null
+++ b/netbox/extras/migrations/0087_dashboard.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/netbox/extras/migrations/0088_jobresult_webhooks.py b/netbox/extras/migrations/0088_jobresult_webhooks.py
new file mode 100644
index 000000000..112bcca8c
--- /dev/null
+++ b/netbox/extras/migrations/0088_jobresult_webhooks.py
@@ -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),
+ ),
+ ]
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
index 33936cc4f..14e23366f 100644
--- a/netbox/extras/models/__init__.py
+++ b/netbox/extras/models/__init__.py
@@ -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',
diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py
index f2b50f161..8c6b3273a 100644
--- a/netbox/extras/models/configs.py
+++ b/netbox/extras/models/configs.py
@@ -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:
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 8141ca76d..f5ec3ce94 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -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:
diff --git a/netbox/extras/models/dashboard.py b/netbox/extras/models/dashboard.py
new file mode 100644
index 000000000..cdbf85b60
--- /dev/null
+++ b/netbox/extras/models/dashboard.py
@@ -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
+ ]
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index 1360904dc..d1ca74822 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -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.
"""
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 37c78dd18..0a944a0d2 100644
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -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}
{stacktrace}
")
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()
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 313058d57..9b9167e17 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -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}")
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 5991203f2..31b6f02c3 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -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',
)
diff --git a/netbox/extras/templatetags/dashboard.py b/netbox/extras/templatetags/dashboard.py
new file mode 100644
index 000000000..4ac31abcf
--- /dev/null
+++ b/netbox/extras/templatetags/dashboard.py
@@ -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)
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index d890e3ebe..f29a11e0e 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -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')
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index 3c8899b5e..0c161cfae 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -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)
diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py
index 35402bda3..722f32f0a 100644
--- a/netbox/extras/tests/test_forms.py
+++ b/netbox/extras/tests/test_forms.py
@@ -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])
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index dfbaa1bc6..e127e164a 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -87,6 +87,11 @@ urlpatterns = [
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog//', include(get_model_urls('extras', 'objectchange'))),
+ # User dashboard
+ path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
+ path('dashboard/widgets//configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
+ path('dashboard/widgets//delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
+
# Reports
path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/results//', views.ReportResultView.as_view(), name='report_result'),
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 3edb70cf1..62cb8db36 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -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
#
diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py
index 7e8965182..438231b7e 100644
--- a/netbox/extras/webhooks_worker.py
+++ b/netbox/extras/webhooks_worker.py
@@ -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 = {
diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py
index 06e413427..57bb58b5e 100644
--- a/netbox/ipam/api/nested_serializers.py
+++ b/netbox/ipam/api/nested_serializers.py
@@ -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
#
diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py
index ffaa252a7..10c27b252 100644
--- a/netbox/ipam/api/serializers.py
+++ b/netbox/ipam/api/serializers.py
@@ -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']
diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py
index 1e077c087..442fd2240 100644
--- a/netbox/ipam/api/urls.py
+++ b/netbox/ipam/api/urls.py
@@ -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//available-asns/',
+ views.AvailableASNsView.as_view(),
+ name='asnrange-available-asns'
+ ),
path(
'ip-ranges//available-ips/',
views.IPRangeAvailableIPAddressesView.as_view(),
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
index e501ddec1..43ae7e2c6 100644
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -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
diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py
index cb121515d..f26fce2b5 100644
--- a/netbox/ipam/constants.py
+++ b/netbox/ipam/constants.py
@@ -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
diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py
index 7d28127a4..2d55deae4 100644
--- a/netbox/ipam/fields.py
+++ b/netbox/ipam/fields.py
@@ -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)
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index a1362803a..ac5024e42 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -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():
diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py
index e63b34d75..fe4f770a8 100644
--- a/netbox/ipam/forms/bulk_edit.py
+++ b/netbox/ipam/forms/bulk_edit.py
@@ -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',
diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py
index 972b98db2..0f9a53ee7 100644
--- a/netbox/ipam/forms/bulk_import.py
+++ b/netbox/ipam/forms/bulk_import.py
@@ -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):
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index 1d505a168..83fe84cd2 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/netbox/ipam/forms/filtersets.py
@@ -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)
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index 4e50c4949..bc4c560ce 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -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):
diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py
index 5cd5e030e..3f77de749 100644
--- a/netbox/ipam/graphql/schema.py
+++ b/netbox/ipam/graphql/schema.py
@@ -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)
diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py
index b8f6221bc..a3405126f 100644
--- a/netbox/ipam/graphql/types.py
+++ b/netbox/ipam/graphql/types.py
@@ -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:
diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py
index 3b074634c..99bde12e6 100644
--- a/netbox/ipam/migrations/0053_asn_model.py
+++ b/netbox/ipam/migrations/0053_asn_model.py
@@ -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')),
diff --git a/netbox/ipam/migrations/0064_asnrange.py b/netbox/ipam/migrations/0064_asnrange.py
new file mode 100644
index 000000000..6f5cfe6b8
--- /dev/null
+++ b/netbox/ipam/migrations/0064_asnrange.py
@@ -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',),
+ },
+ ),
+ ]
diff --git a/netbox/ipam/migrations/0065_iprange_mark_utilized.py b/netbox/ipam/migrations/0065_iprange_mark_utilized.py
new file mode 100644
index 000000000..5c2cb5858
--- /dev/null
+++ b/netbox/ipam/migrations/0065_iprange_mark_utilized.py
@@ -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),
+ ),
+ ]
diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py
index d13ee9076..a00919ee0 100644
--- a/netbox/ipam/models/__init__.py
+++ b/netbox/ipam/models/__init__.py
@@ -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',
diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py
new file mode 100644
index 000000000..a07cbb789
--- /dev/null
+++ b/netbox/ipam/models/asns.py
@@ -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
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index e8bf13375..9dff944bb 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -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()
diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py
index 690abf045..47ba3b7dc 100644
--- a/netbox/ipam/models/services.py
+++ b/netbox/ipam/models/services.py
@@ -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', ]
diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index bf6c6a52e..7d4777da9 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -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(
diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py
index ad4403321..4d97bf5f0 100644
--- a/netbox/ipam/search.py
+++ b/netbox/ipam/search.py
@@ -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
diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py
index 3bde78af0..7d04a5fea 100644
--- a/netbox/ipam/tables/__init__.py
+++ b/netbox/ipam/tables/__init__.py
@@ -1,3 +1,4 @@
+from .asn import *
from .fhrp import *
from .ip import *
from .l2vpn import *
diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py
new file mode 100644
index 000000000..511e914ec
--- /dev/null
+++ b/netbox/ipam/tables/asn.py
@@ -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',
+ )
diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py
index f83831d2d..86d1a3775 100644
--- a/netbox/ipam/tables/ip.py
+++ b/netbox/ipam/tables/ip.py
@@ -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',
diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py
index ea6441650..3908c8f3d 100644
--- a/netbox/ipam/tests/test_api.py
+++ b/netbox/ipam/tests/test_api.py
@@ -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,
},
]
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index 13b3ae163..fef4722c4 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -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)
diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py
index 8bf19ebfa..fee308642 100644
--- a/netbox/ipam/tests/test_views.py
+++ b/netbox/ipam/tests/test_views.py
@@ -11,30 +11,91 @@ from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_test_device, create_tags
+class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = ASNRange
+
+ @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)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'ASN Range X',
+ 'slug': 'asn-range-x',
+ 'rir': rirs[1].pk,
+ 'tenant': tenants[1].pk,
+ 'start': 1000,
+ 'end': 1099,
+ 'description': 'A new ASN range',
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ f"name,slug,rir,tenant,start,end,description",
+ f"ASN Range 4,asn-range-4,{rirs[1].name},{tenants[1].name},400,499,Fourth range",
+ f"ASN Range 5,asn-range-5,{rirs[1].name},{tenants[1].name},500,599,Fifth range",
+ f"ASN Range 6,asn-range-6,{rirs[1].name},{tenants[1].name},600,699,Sixth range",
+ )
+
+ cls.csv_update_data = (
+ "id,description",
+ f"{asn_ranges[0].pk},New description 1",
+ f"{asn_ranges[1].pk},New description 2",
+ f"{asn_ranges[2].pk},New description 3",
+ )
+
+ cls.bulk_edit_data = {
+ 'rir': rirs[1].pk,
+ 'description': 'Next description',
+ }
+
+
class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ASN
@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),
- ]
- 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'),
+ 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)
+
+ 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=65535, rir=rirs[1], tenant=tenants[1]),
- ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]),
- ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]),
+ ASN(asn=65001, rir=rirs[0], tenant=tenants[0]),
+ ASN(asn=65002, rir=rirs[1], tenant=tenants[1]),
+ ASN(asn=4200000001, rir=rirs[0], tenant=tenants[0]),
+ ASN(asn=4200000002, rir=rirs[1], tenant=tenants[1]),
)
ASN.objects.bulk_create(asns)
@@ -46,18 +107,20 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
- 'asn': 64512,
+ 'asn': 65000,
'rir': rirs[0].pk,
'tenant': tenants[0].pk,
'site': sites[0].pk,
'description': 'A new ASN',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
"asn,rir",
- "64533,RFC 6996",
- "64523,RFC 6996",
- "4200000002,RFC 6996",
+ "65003,RIR 1",
+ "65004,RIR 2",
+ "4200000003,RIR 1",
+ "4200000004,RIR 2",
)
cls.csv_update_data = (
diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py
index 032ddf498..3bfe34b7b 100644
--- a/netbox/ipam/urls.py
+++ b/netbox/ipam/urls.py
@@ -6,6 +6,14 @@ from . import views
app_name = 'ipam'
urlpatterns = [
+ # ASN ranges
+ path('asn-ranges/', views.ASNRangeListView.as_view(), name='asnrange_list'),
+ path('asn-ranges/add/', views.ASNRangeEditView.as_view(), name='asnrange_add'),
+ path('asn-ranges/import/', views.ASNRangeBulkImportView.as_view(), name='asnrange_import'),
+ path('asn-ranges/edit/', views.ASNRangeBulkEditView.as_view(), name='asnrange_bulk_edit'),
+ path('asn-ranges/delete/', views.ASNRangeBulkDeleteView.as_view(), name='asnrange_bulk_delete'),
+ path('asn-ranges//', include(get_model_urls('ipam', 'asnrange'))),
+
# ASNs
path('asns/', views.ASNListView.as_view(), name='asn_list'),
path('asns/add/', views.ASNEditView.as_view(), name='asn_add'),
diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py
index 2903d1e74..93a40e5a0 100644
--- a/netbox/ipam/utils.py
+++ b/netbox/ipam/utils.py
@@ -1,7 +1,7 @@
import netaddr
from .constants import *
-from .models import Prefix, VLAN
+from .models import ASN, Prefix, VLAN
def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True):
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index c80ca7d74..5e9d98324 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -7,16 +7,15 @@ from django.utils.translation import gettext as _
from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
-from dcim.models import Interface, Site, Device
+from dcim.models import Interface, Site
from netbox.views import generic
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
-from virtualization.models import VMInterface, VirtualMachine
+from virtualization.models import VMInterface
from . import filtersets, forms, tables
from .constants import *
from .models import *
-from .models import ASN
from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
@@ -195,6 +194,77 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
table = tables.RIRTable
+#
+# ASN ranges
+#
+
+class ASNRangeListView(generic.ObjectListView):
+ queryset = ASNRange.objects.all()
+ filterset = filtersets.ASNRangeFilterSet
+ filterset_form = forms.ASNRangeFilterForm
+ table = tables.ASNRangeTable
+
+
+@register_model_view(ASNRange)
+class ASNRangeView(generic.ObjectView):
+ queryset = ASNRange.objects.all()
+
+
+@register_model_view(ASNRange, 'asns')
+class ASNRangeASNsView(generic.ObjectChildrenView):
+ queryset = ASNRange.objects.all()
+ child_model = ASN
+ table = tables.ASNTable
+ filterset = filtersets.ASNFilterSet
+ template_name = 'ipam/asnrange/asns.html'
+ tab = ViewTab(
+ label=_('ASNs'),
+ badge=lambda x: x.get_child_asns().count(),
+ permission='ipam.view_asns',
+ weight=500
+ )
+
+ def get_children(self, request, parent):
+ return parent.get_child_asns().restrict(request.user, 'view').annotate(
+ site_count=count_related(Site, 'asns'),
+ provider_count=count_related(Provider, 'asns')
+ )
+
+
+@register_model_view(ASNRange, 'edit')
+class ASNRangeEditView(generic.ObjectEditView):
+ queryset = ASNRange.objects.all()
+ form = forms.ASNRangeForm
+
+
+@register_model_view(ASNRange, 'delete')
+class ASNRangeDeleteView(generic.ObjectDeleteView):
+ queryset = ASNRange.objects.all()
+
+
+class ASNRangeBulkImportView(generic.BulkImportView):
+ queryset = ASNRange.objects.all()
+ model_form = forms.ASNRangeImportForm
+ table = tables.ASNRangeTable
+
+
+class ASNRangeBulkEditView(generic.BulkEditView):
+ queryset = ASNRange.objects.annotate(
+ site_count=count_related(Site, 'asns')
+ )
+ filterset = filtersets.ASNRangeFilterSet
+ table = tables.ASNRangeTable
+ form = forms.ASNRangeBulkEditForm
+
+
+class ASNRangeBulkDeleteView(generic.BulkDeleteView):
+ queryset = ASNRange.objects.annotate(
+ site_count=count_related(Site, 'asns')
+ )
+ filterset = filtersets.ASNRangeFilterSet
+ table = tables.ASNRangeTable
+
+
#
# ASNs
#
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 03c361002..35be8cf55 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -158,6 +158,7 @@ IPAM_MENU = Menu(
MenuGroup(
label=_('ASNs'),
items=(
+ get_model_item('ipam', 'asnrange', _('ASN Ranges')),
get_model_item('ipam', 'asn', _('ASNs')),
),
),
diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py
index e37ee0d0c..23b9ad4cb 100644
--- a/netbox/netbox/registry.py
+++ b/netbox/netbox/registry.py
@@ -27,4 +27,5 @@ registry = Registry({
'plugins': dict(),
'search': dict(),
'views': collections.defaultdict(dict),
+ 'widgets': dict(),
})
diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py
index 3c8c93f84..c7255916c 100644
--- a/netbox/netbox/views/misc.py
+++ b/netbox/netbox/views/misc.py
@@ -5,27 +5,17 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.shortcuts import redirect, render
-from django.utils.translation import gettext as _
from django.views.generic import View
from django_tables2 import RequestConfig
from packaging import version
-from circuits.models import Circuit, Provider
-from dcim.models import (
- Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
-)
-from extras.models import ObjectChange
-from extras.tables import ObjectChangeTable
-from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
+from extras.dashboard.utils import get_dashboard
from netbox.forms import SearchForm
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
from netbox.tables import SearchTable
-from tenancy.models import Contact, Tenant
from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count
-from virtualization.models import Cluster, VirtualMachine
-from wireless.models import WirelessLAN, WirelessLink
__all__ = (
'HomeView',
@@ -42,79 +32,8 @@ class HomeView(View):
if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
return redirect('login')
- console_connections = ConsolePort.objects.restrict(request.user, 'view')\
- .prefetch_related('_path').filter(_path__is_complete=True).count
- power_connections = PowerPort.objects.restrict(request.user, 'view')\
- .prefetch_related('_path').filter(_path__is_complete=True).count
- interface_connections = Interface.objects.restrict(request.user, 'view')\
- .prefetch_related('_path').filter(_path__is_complete=True).count
-
- def get_count_queryset(model):
- return model.objects.restrict(request.user, 'view').count
-
- def build_stats():
- org = (
- Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)),
- Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)),
- Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)),
- )
- dcim = (
- Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)),
- Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)),
- Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)),
- )
- ipam = (
- Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)),
- Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)),
- Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)),
- Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)),
- Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)),
- Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)),
- )
- circuits = (
- Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)),
- Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit))
- )
- virtualization = (
- Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster',
- get_count_queryset(Cluster)),
- Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine',
- get_count_queryset(VirtualMachine)),
- )
- connections = (
- Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)),
- Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections),
- Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections),
- Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections),
- )
- power = (
- Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)),
- Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)),
- )
- wireless = (
- Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan',
- get_count_queryset(WirelessLAN)),
- Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink',
- get_count_queryset(WirelessLink)),
- )
- stats = (
- (_('Organization'), org, 'domain'),
- (_('IPAM'), ipam, 'counter'),
- (_('Virtualization'), virtualization, 'monitor'),
- (_('Inventory'), dcim, 'server'),
- (_('Circuits'), circuits, 'transit-connection-variant'),
- (_('Connections'), connections, 'cable-data'),
- (_('Power'), power, 'flash'),
- (_('Wireless'), wireless, 'wifi'),
- )
-
- return stats
-
- # Compile changelog table
- changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
- 'user', 'changed_object_type'
- )[:10]
- changelog_table = ObjectChangeTable(changelog, user=request.user)
+ # Construct the user's custom dashboard layout
+ dashboard = get_dashboard(request.user).get_layout()
# Check whether a new release is available. (Only for staff/superusers.)
new_release = None
@@ -129,9 +48,7 @@ class HomeView(View):
}
return render(request, self.template_name, {
- 'search_form': SearchForm(),
- 'stats': build_stats(),
- 'changelog_table': changelog_table,
+ 'dashboard': dashboard,
'new_release': new_release,
})
diff --git a/netbox/project-static/bundle.js b/netbox/project-static/bundle.js
index 76a1581ad..6f651cd05 100644
--- a/netbox/project-static/bundle.js
+++ b/netbox/project-static/bundle.js
@@ -40,9 +40,6 @@ async function bundleGraphIQL() {
async function bundleNetBox() {
const entryPoints = {
netbox: 'src/index.ts',
- lldp: 'src/device/lldp.ts',
- config: 'src/device/config.ts',
- status: 'src/device/status.ts',
};
try {
const result = await esbuild.build({
diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js
index cda30523c..02e2c5518 100644
Binary files a/netbox/project-static/dist/config.js and b/netbox/project-static/dist/config.js differ
diff --git a/netbox/project-static/dist/config.js.map b/netbox/project-static/dist/config.js.map
index 0ca9fb89e..65dcddcf2 100644
Binary files a/netbox/project-static/dist/config.js.map and b/netbox/project-static/dist/config.js.map differ
diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js
index da3c3bd46..77430ea57 100644
Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ
diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map
index a36df817f..d7a46d320 100644
Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ
diff --git a/netbox/project-static/dist/netbox-external.css b/netbox/project-static/dist/netbox-external.css
index 8164a7fa8..edbb6aec5 100644
Binary files a/netbox/project-static/dist/netbox-external.css and b/netbox/project-static/dist/netbox-external.css differ
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index d0058eae9..f34ab134c 100644
Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ
diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map
index 384195df5..5286b72bb 100644
Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ
diff --git a/netbox/project-static/dist/status.js b/netbox/project-static/dist/status.js
index a6a5534ec..cf9cd63ab 100644
Binary files a/netbox/project-static/dist/status.js and b/netbox/project-static/dist/status.js differ
diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map
index a773c5600..6073a6bd7 100644
Binary files a/netbox/project-static/dist/status.js.map and b/netbox/project-static/dist/status.js.map differ
diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json
index 8258c2be4..f10b5b7ac 100644
--- a/netbox/project-static/package.json
+++ b/netbox/project-static/package.json
@@ -29,9 +29,9 @@
"color2k": "^2.0.0",
"dayjs": "^1.11.5",
"flatpickr": "4.6.13",
+ "gridstack": "^7.2.3",
"htmx.org": "^1.8.0",
"just-debounce-it": "^3.1.1",
- "masonry-layout": "^4.2.2",
"query-string": "^7.1.1",
"sass": "^1.55.0",
"simplebar": "^5.3.9",
@@ -56,4 +56,4 @@
"resolutions": {
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
}
-}
\ No newline at end of file
+}
diff --git a/netbox/project-static/src/bs.ts b/netbox/project-static/src/bs.ts
index e819b7cb1..ecc99ba1a 100644
--- a/netbox/project-static/src/bs.ts
+++ b/netbox/project-static/src/bs.ts
@@ -1,5 +1,4 @@
import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap';
-import Masonry from 'masonry-layout';
import { createElement, getElements } from './util';
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
@@ -12,18 +11,6 @@ window.Popover = Popover;
window.Toast = Toast;
window.Tooltip = Tooltip;
-/**
- * Initialize masonry-layout for homepage (or any other masonry layout cards).
- */
-function initMasonry(): void {
- for (const grid of getElements('.masonry')) {
- new Masonry(grid, {
- itemSelector: '.masonry-item',
- percentPosition: true,
- });
- }
-}
-
function initTooltips() {
for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) {
new Tooltip(tooltip, { container: 'body' });
@@ -194,7 +181,6 @@ export function initBootstrap(): void {
for (const func of [
initTooltips,
initModals,
- initMasonry,
initTabs,
initImagePreview,
initSidebarAccordions,
diff --git a/netbox/project-static/src/dashboard.ts b/netbox/project-static/src/dashboard.ts
new file mode 100644
index 000000000..6ce10bdab
--- /dev/null
+++ b/netbox/project-static/src/dashboard.ts
@@ -0,0 +1,47 @@
+import { GridStack, GridStackOptions, GridStackWidget } from 'gridstack';
+import { createToast } from './bs';
+import { apiPatch, hasError } from './util';
+
+async function saveDashboardLayout(
+ url: string,
+ gridData: GridStackWidget[] | GridStackOptions,
+): Promise> {
+ let data = {
+ layout: gridData
+ }
+ return await apiPatch(url, data);
+}
+
+export function initDashboard(): void {
+ // Exit if this page does not contain a dashboard
+ const dashboard = document.getElementById('dashboard') as Nullable;
+ if (dashboard == null) {
+ return;
+ }
+
+ // Initialize the grid
+ let grid = GridStack.init({
+ cellHeight: 100,
+ });
+
+ // Create a listener for the dashboard save button
+ const gridSaveButton = document.getElementById('save_dashboard') as HTMLButtonElement;
+ if (gridSaveButton === null) {
+ return;
+ }
+ gridSaveButton.addEventListener('click', () => {
+ const url = gridSaveButton.getAttribute('data-url');
+ if (url == null) {
+ return;
+ }
+ let gridData = grid.save(false);
+ saveDashboardLayout(url, gridData).then(res => {
+ if (hasError(res)) {
+ const toast = createToast('danger', 'Error Saving Dashboard Config', res.error);
+ toast.show();
+ } else {
+ location.reload();
+ }
+ });
+ });
+}
diff --git a/netbox/project-static/src/device/config.ts b/netbox/project-static/src/device/config.ts
deleted file mode 100644
index c9c19e8d3..000000000
--- a/netbox/project-static/src/device/config.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { createToast } from '../bs';
-import { apiGetBase, getNetboxData, hasError, toggleLoader } from '../util';
-
-/**
- * Initialize device config elements.
- */
-function initConfig(): void {
- toggleLoader('show');
- const url = getNetboxData('data-object-url');
-
- if (url !== null) {
- apiGetBase(url)
- .then(data => {
- if (hasError(data)) {
- createToast('danger', 'Error Fetching Device Config', data.error).show();
- console.error(data.error);
- return;
- } else if (hasError>(data.get_config)) {
- createToast('danger', 'Error Fetching Device Config', data.get_config.error).show();
- console.error(data.get_config.error);
- return;
- } else {
- const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[];
-
- for (const configType of configTypes) {
- const element = document.getElementById(`${configType}_config`);
- if (element !== null) {
- const config = data.get_config[configType];
- if (typeof config === 'string') {
- // If the returned config is a string, set the element innerHTML as-is.
- element.innerHTML = config;
- } else {
- // If the returned config is an object (dict), convert it to JSON.
- element.innerHTML = JSON.stringify(data.get_config[configType], null, 2);
- }
- }
- }
- }
- })
- .finally(() => {
- toggleLoader('hide');
- });
- }
-}
-
-if (document.readyState !== 'loading') {
- initConfig();
-} else {
- document.addEventListener('DOMContentLoaded', initConfig);
-}
diff --git a/netbox/project-static/src/device/lldp.ts b/netbox/project-static/src/device/lldp.ts
deleted file mode 100644
index ebf71138c..000000000
--- a/netbox/project-static/src/device/lldp.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { createToast } from '../bs';
-import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util';
-
-// Match an interface name that begins with a capital letter and is followed by at least one other
-// alphabetic character, and ends with a forward-slash-separated numeric sequence such as 0/1/2.
-const CISCO_IOS_PATTERN = new RegExp(/^([A-Z][A-Za-z]+)[^0-9]*([0-9/]+)$/);
-
-// Mapping of overrides to default Cisco IOS interface alias behavior (default behavior is to use
-// the first two characters).
-const CISCO_IOS_OVERRIDES = new Map([
- // Cisco IOS abbreviates 25G (TwentyFiveGigE) interfaces as 'Twe'.
- ['TwentyFiveGigE', 'Twe'],
-]);
-
-/**
- * Get an attribute from a row's cell.
- *
- * @param row Interface row
- * @param query CSS media query
- * @param attr Cell attribute
- */
-function getData(row: HTMLTableRowElement, query: string, attr: string): string | null {
- return row.querySelector(query)?.getAttribute(attr) ?? null;
-}
-
-/**
- * Get preconfigured alias for given interface. Primarily for matching long-form Cisco IOS
- * interface names with short-form Cisco IOS interface names. For example, `GigabitEthernet0/1/2`
- * would become `Gi0/1/2`.
- *
- * This should probably be replaced with something in the primary application (Django), such as
- * a database field attached to given interface types. However, this is a temporary measure to
- * replace the functionality of this one-liner:
- *
- * @see https://github.com/netbox-community/netbox/blob/9cc4992fad2fe04ef0211d998c517414e8871d8c/netbox/templates/dcim/device/lldp_neighbors.html#L69
- *
- * @param name Long-form/original interface name.
- */
-function getInterfaceAlias(name: string | null): string | null {
- if (name === null) {
- return name;
- }
- if (name.match(CISCO_IOS_PATTERN)) {
- // Extract the base name and numeric portions of the interface. For example, an input interface
- // of `GigabitEthernet0/0/1` would result in an array of `['GigabitEthernet', '0/0/1']`.
- const [base, numeric] = (name.match(CISCO_IOS_PATTERN) ?? []).slice(1, 3);
-
- if (isTruthy(base) && isTruthy(numeric)) {
- // Check the override map and use its value if the base name is present in the map.
- // Otherwise, use the first two characters of the base name. For example,
- // `GigabitEthernet0/0/1` would become `Gi0/0/1`, but `TwentyFiveGigE0/0/1` would become
- // `Twe0/0/1`.
- const aliasBase = CISCO_IOS_OVERRIDES.get(base) || base.slice(0, 2);
- return `${aliasBase}${numeric}`;
- }
- }
- return name;
-}
-
-/**
- * Update row styles based on LLDP neighbor data.
- */
-function updateRowStyle(data: LLDPNeighborDetail) {
- for (const [fullIface, neighbors] of Object.entries(data.get_lldp_neighbors_detail)) {
- const [iface] = fullIface.split('.');
-
- const row = document.getElementById(iface) as Nullable;
-
- if (row !== null) {
- for (const neighbor of neighbors) {
- const deviceCell = row.querySelector('td.device');
- const interfaceCell = row.querySelector('td.interface');
- const configuredDevice = getData(row, 'td.configured_device', 'data');
- const configuredChassis = getData(row, 'td.configured_chassis', 'data-chassis');
- const configuredIface = getData(row, 'td.configured_interface', 'data');
-
- const interfaceAlias = getInterfaceAlias(configuredIface);
-
- const remoteName = neighbor.remote_system_name ?? '';
- const remotePort = neighbor.remote_port ?? '';
- const [neighborDevice] = remoteName.split('.');
- const [neighborIface] = remotePort.split('.');
-
- if (deviceCell !== null) {
- deviceCell.innerText = neighborDevice;
- }
-
- if (interfaceCell !== null) {
- interfaceCell.innerText = neighborIface;
- }
-
- // Interface has an LLDP neighbor, but the neighbor is not configured in NetBox.
- const nonConfiguredDevice = !isTruthy(configuredDevice) && isTruthy(neighborDevice);
-
- // NetBox device or chassis matches LLDP neighbor.
- const validNode =
- configuredDevice === neighborDevice || configuredChassis === neighborDevice;
-
- // NetBox configured interface matches LLDP neighbor interface.
- const validInterface =
- configuredIface === neighborIface || interfaceAlias === neighborIface;
-
- if (nonConfiguredDevice) {
- row.classList.add('info');
- } else if (validNode && validInterface) {
- row.classList.add('success');
- } else {
- row.classList.add('danger');
- }
- }
- }
- }
-}
-
-/**
- * Initialize LLDP Neighbor fetching.
- */
-function initLldpNeighbors() {
- toggleLoader('show');
- const url = getNetboxData('object-url');
- if (url !== null) {
- apiGetBase(url)
- .then(data => {
- if (hasError(data)) {
- createToast('danger', 'Error Retrieving LLDP Neighbor Information', data.error).show();
- toggleLoader('hide');
- return;
- } else {
- updateRowStyle(data);
- }
- return;
- })
- .finally(() => {
- toggleLoader('hide');
- });
- }
-}
-
-if (document.readyState !== 'loading') {
- initLldpNeighbors();
-} else {
- document.addEventListener('DOMContentLoaded', initLldpNeighbors);
-}
diff --git a/netbox/project-static/src/device/status.ts b/netbox/project-static/src/device/status.ts
deleted file mode 100644
index 8261ebc82..000000000
--- a/netbox/project-static/src/device/status.ts
+++ /dev/null
@@ -1,379 +0,0 @@
-import dayjs from 'dayjs';
-import utc from 'dayjs/plugin/utc';
-import timezone from 'dayjs/plugin/timezone';
-import duration from 'dayjs/plugin/duration';
-import advancedFormat from 'dayjs/plugin/advancedFormat';
-
-import { createToast } from '../bs';
-import { apiGetBase, getNetboxData, hasError, toggleLoader, createElement, cToF } from '../util';
-
-type Uptime = {
- utc: string;
- zoned: string | null;
- duration: string;
-};
-
-dayjs.extend(utc);
-dayjs.extend(timezone);
-dayjs.extend(advancedFormat);
-dayjs.extend(duration);
-
-const factKeys = [
- 'hostname',
- 'fqdn',
- 'vendor',
- 'model',
- 'serial_number',
- 'os_version',
-] as (keyof DeviceFacts)[];
-
-type DurationKeys = 'years' | 'months' | 'days' | 'hours' | 'minutes' | 'seconds';
-const formatKeys = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'] as DurationKeys[];
-
-/**
- * From a number of seconds that have elapsed since reboot, extract human-readable dates in the
- * following formats:
- * - Relative time since reboot (e.g. 1 month, 28 days, 1 hour, 30 seconds).
- * - Time stamp in browser-relative timezone.
- * - Time stamp in UTC.
- * @param seconds Seconds since reboot.
- */
-function getUptime(seconds: number): Uptime {
- const relDate = new Date();
-
- // Get the user's UTC offset, to determine if the user is in UTC or not.
- const offset = relDate.getTimezoneOffset();
- const relNow = dayjs(relDate);
-
- // Get a dayjs object for the device reboot time (now - number of seconds).
- const relThen = relNow.subtract(seconds, 'seconds');
-
- // Get a human-readable version of the time in UTC.
- const utc = relThen.tz('Etc/UTC').format('YYYY-MM-DD HH:MM:ss z');
-
- // We only want to show the UTC time if the user is not already in UTC time.
- let zoned = null;
- if (offset !== 0) {
- // If the user is not in UTC time, return a human-readable version in the user's timezone.
- zoned = relThen.format('YYYY-MM-DD HH:MM:ss z');
- }
- // Get a dayjs duration object to create a human-readable relative time string.
- const between = dayjs.duration(seconds, 'seconds');
-
- // Array of all non-zero-value duration properties. For example, if duration.year() is 0, we
- // don't care about it and shouldn't show it to the user.
- let parts = [] as string[];
- for (const key of formatKeys) {
- // Get the property value. For example, duration.year(), duration.month(), etc.
- const value = between[key]();
- if (value === 1) {
- // If the duration for this key is 1, drop the trailing 's'. For example, '1 seconds' would
- // become '1 second'.
- const label = key.replace(/s$/, '');
- parts = [...parts, `${value} ${label}`];
- } else if (value > 1) {
- // If the duration for this key is more than one, add it to the array as-is.
- parts = [...parts, `${value} ${key}`];
- }
- }
- // Set the duration to something safe, so we don't show 'undefined' or an empty string to the user.
- let duration = 'None';
- if (parts.length > 0) {
- // If the array actually has elements, reassign the duration to a human-readable version.
- duration = parts.join(', ');
- }
-
- return { utc, zoned, duration };
-}
-
-/**
- * After the `get_facts` result is received, parse its content and update HTML elements
- * accordingly.
- *
- * @param facts NAPALM Device Facts
- */
-function processFacts(facts: DeviceFacts): void {
- for (const key of factKeys) {
- if (key in facts) {
- // Find the target element which should have its innerHTML/innerText set to a NAPALM value.
- const element = document.getElementById(key);
- if (element !== null) {
- element.innerHTML = String(facts[key]);
- }
- }
- }
- const { uptime } = facts;
- const { utc, zoned, duration } = getUptime(uptime);
-
- // Find the duration (relative time) element and set its value.
- const uptimeDurationElement = document.getElementById('uptime-duration');
- if (uptimeDurationElement !== null) {
- uptimeDurationElement.innerHTML = duration;
- }
- // Find the time stamp element and set its value.
- const uptimeElement = document.getElementById('uptime');
- if (uptimeElement !== null) {
- if (zoned === null) {
- // If the user is in UTC time, only add the UTC time stamp.
- uptimeElement.innerHTML = utc;
- } else {
- // Otherwise, add both time stamps.
- uptimeElement.innerHTML = [zoned, `${utc}`].join('');
- }
- }
-}
-
-/**
- * Insert a title row before the next table row. The title row describes each environment key/value
- * pair from the NAPALM response.
- *
- * @param next Next adjacent element. For example, if this is the CPU data, `next` would be the
- * memory row.
- * @param title1 Column 1 Title
- * @param title2 Column 2 Title
- */
-function insertTitleRow(next: E, title1: string, title2: string): void {
- // Create cell element that contains the key title.
- const col1Title = createElement('th', { innerText: title1 }, ['border-end', 'text-end']);
- // Create cell element that contains the value title.
- const col2Title = createElement('th', { innerText: title2 }, ['border-start', 'text-start']);
- // Create title row element with the two header cells as children.
- const titleRow = createElement('tr', {}, [], [col1Title, col2Title]);
- // Insert the entire row just before the beginning of the next row (i.e., at the end of this row).
- next.insertAdjacentElement('beforebegin', titleRow);
-}
-
-/**
- * Insert a "No Data" row, for when the NAPALM response doesn't contain this type of data.
- *
- * @param next Next adjacent element.For example, if this is the CPU data, `next` would be the
- * memory row.
- */
-function insertNoneRow>(next: E): void {
- const none = createElement('td', { colSpan: '2', innerText: 'No Data' }, [
- 'text-muted',
- 'text-center',
- ]);
- const titleRow = createElement('tr', {}, [], [none]);
- if (next !== null) {
- next.insertAdjacentElement('beforebegin', titleRow);
- }
-}
-
-function getNext(id: string): Nullable {
- const element = document.getElementById(id);
- if (element !== null) {
- return element.nextElementSibling as Nullable;
- }
- return null;
-}
-
-/**
- * Create & insert table rows for each CPU in the NAPALM response.
- *
- * @param cpu NAPALM CPU data.
- */
-function processCpu(cpu: DeviceEnvironment['cpu']): void {
- // Find the next adjacent element, so we can insert elements before it.
- const next = getNext('status-cpu');
- if (typeof cpu !== 'undefined') {
- if (next !== null) {
- insertTitleRow(next, 'Name', 'Usage');
- for (const [core, data] of Object.entries(cpu)) {
- const usage = data['%usage'];
- const kCell = createElement('td', { innerText: core }, ['border-end', 'text-end']);
- const vCell = createElement('td', { innerText: `${usage} %` }, [
- 'border-start',
- 'text-start',
- ]);
- const row = createElement('tr', {}, [], [kCell, vCell]);
- next.insertAdjacentElement('beforebegin', row);
- }
- }
- } else {
- insertNoneRow(next);
- }
-}
-
-/**
- * Create & insert table rows for the memory in the NAPALM response.
- *
- * @param mem NAPALM memory data.
- */
-function processMemory(mem: DeviceEnvironment['memory']): void {
- // Find the next adjacent element, so we can insert elements before it.
- const next = getNext('status-memory');
- if (typeof mem !== 'undefined') {
- if (next !== null) {
- insertTitleRow(next, 'Available', 'Used');
- const { available_ram: avail, used_ram: used } = mem;
- const aCell = createElement('td', { innerText: avail }, ['border-end', 'text-end']);
- const uCell = createElement('td', { innerText: used }, ['border-start', 'text-start']);
- const row = createElement('tr', {}, [], [aCell, uCell]);
- next.insertAdjacentElement('beforebegin', row);
- }
- } else {
- insertNoneRow(next);
- }
-}
-
-/**
- * Create & insert table rows for each temperature sensor in the NAPALM response.
- *
- * @param temp NAPALM temperature data.
- */
-function processTemp(temp: DeviceEnvironment['temperature']): void {
- // Find the next adjacent element, so we can insert elements before it.
- const next = getNext('status-temperature');
- if (typeof temp !== 'undefined') {
- if (next !== null) {
- insertTitleRow(next, 'Sensor', 'Value');
- for (const [sensor, data] of Object.entries(temp)) {
- const tempC = data.temperature;
- const tempF = cToF(tempC);
- const innerHTML = `${tempC} °C ${tempF} °F`;
- const status = data.is_alert ? 'warning' : data.is_critical ? 'danger' : 'success';
- const kCell = createElement('td', { innerText: sensor }, ['border-end', 'text-end']);
- const vCell = createElement('td', { innerHTML }, ['border-start', 'text-start']);
- const row = createElement('tr', {}, [`table-${status}`], [kCell, vCell]);
- next.insertAdjacentElement('beforebegin', row);
- }
- }
- } else {
- insertNoneRow(next);
- }
-}
-
-/**
- * Create & insert table rows for each fan in the NAPALM response.
- *
- * @param fans NAPALM fan data.
- */
-function processFans(fans: DeviceEnvironment['fans']): void {
- // Find the next adjacent element, so we can insert elements before it.
- const next = getNext('status-fans');
- if (typeof fans !== 'undefined') {
- if (next !== null) {
- insertTitleRow(next, 'Fan', 'Status');
- for (const [fan, data] of Object.entries(fans)) {
- const { status } = data;
- const goodIcon = createElement('i', {}, ['mdi', 'mdi-check-bold', 'text-success']);
- const badIcon = createElement('i', {}, ['mdi', 'mdi-close', 'text-warning']);
- const kCell = createElement('td', { innerText: fan }, ['border-end', 'text-end']);
- const vCell = createElement(
- 'td',
- {},
- ['border-start', 'text-start'],
- [status ? goodIcon : badIcon],
- );
- const row = createElement(
- 'tr',
- {},
- [`table-${status ? 'success' : 'warning'}`],
- [kCell, vCell],
- );
- next.insertAdjacentElement('beforebegin', row);
- }
- }
- } else {
- insertNoneRow(next);
- }
-}
-
-/**
- * Create & insert table rows for each PSU in the NAPALM response.
- *
- * @param power NAPALM power data.
- */
-function processPower(power: DeviceEnvironment['power']): void {
- // Find the next adjacent element, so we can insert elements before it.
- const next = getNext('status-power');
- if (typeof power !== 'undefined') {
- if (next !== null) {
- insertTitleRow(next, 'PSU', 'Status');
- for (const [psu, data] of Object.entries(power)) {
- const { status } = data;
- const goodIcon = createElement('i', {}, ['mdi', 'mdi-check-bold', 'text-success']);
- const badIcon = createElement('i', {}, ['mdi', 'mdi-close', 'text-warning']);
- const kCell = createElement('td', { innerText: psu }, ['border-end', 'text-end']);
- const vCell = createElement(
- 'td',
- {},
- ['border-start', 'text-start'],
- [status ? goodIcon : badIcon],
- );
- const row = createElement(
- 'tr',
- {},
- [`table-${status ? 'success' : 'warning'}`],
- [kCell, vCell],
- );
- next.insertAdjacentElement('beforebegin', row);
- }
- }
- } else {
- insertNoneRow(next);
- }
-}
-
-/**
- * After the `get_environment` result is received, parse its content and update HTML elements
- * accordingly.
- *
- * @param env NAPALM Device Environment
- */
-function processEnvironment(env: DeviceEnvironment): void {
- const { cpu, memory, temperature, fans, power } = env;
- processCpu(cpu);
- processMemory(memory);
- processTemp(temperature);
- processFans(fans);
- processPower(power);
-}
-
-/**
- * Initialize NAPALM device status handlers.
- */
-function initStatus(): void {
- // Show loading state for both Facts & Environment cards.
- toggleLoader('show');
-
- const url = getNetboxData('data-object-url');
-
- if (url !== null) {
- apiGetBase(url)
- .then(data => {
- if (hasError(data)) {
- // If the API returns an error, show it to the user.
- createToast('danger', 'Error Fetching Device Status', data.error).show();
- } else {
- if (!hasError(data.get_facts)) {
- processFacts(data.get_facts);
- } else {
- // If the device facts data contains an error, show it to the user.
- createToast('danger', 'Error Fetching Device Facts', data.get_facts.error).show();
- }
- if (!hasError(data.get_environment)) {
- processEnvironment(data.get_environment);
- } else {
- // If the device environment data contains an error, show it to the user.
- createToast(
- 'danger',
- 'Error Fetching Device Environment Data',
- data.get_environment.error,
- ).show();
- }
- }
- return;
- })
- .finally(() => toggleLoader('hide'));
- } else {
- toggleLoader('hide');
- }
-}
-
-if (document.readyState !== 'loading') {
- initStatus();
-} else {
- document.addEventListener('DOMContentLoaded', initStatus);
-}
diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts
index bd80a0c49..5b2c65d80 100644
--- a/netbox/project-static/src/htmx.ts
+++ b/netbox/project-static/src/htmx.ts
@@ -3,7 +3,6 @@ import { initButtons } from './buttons';
import { initSelect } from './select';
function initDepedencies(): void {
- console.log('initDepedencies()');
for (const init of [initButtons, initSelect]) {
init();
}
diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts
index f19b879fe..ed294e655 100644
--- a/netbox/project-static/src/netbox.ts
+++ b/netbox/project-static/src/netbox.ts
@@ -10,6 +10,7 @@ import { initDateSelector } from './dateSelector';
import { initTableConfig } from './tableConfig';
import { initInterfaceTable } from './tables';
import { initSideNav } from './sidenav';
+import { initDashboard } from './dashboard';
import { initRackElevation } from './racks';
import { initLinks } from './links';
import { initHtmx } from './htmx';
@@ -28,6 +29,7 @@ function initDocument(): void {
initTableConfig,
initInterfaceTable,
initSideNav,
+ initDashboard,
initRackElevation,
initLinks,
initHtmx,
diff --git a/netbox/project-static/src/select/api/index.ts b/netbox/project-static/src/select/api/index.ts
index 59a9ee59b..3fef1ad6a 100644
--- a/netbox/project-static/src/select/api/index.ts
+++ b/netbox/project-static/src/select/api/index.ts
@@ -2,7 +2,7 @@ import { getElements } from '../../util';
import { APISelect } from './apiSelect';
export function initApiSelect(): void {
- for (const select of getElements('.netbox-api-select')) {
+ for (const select of getElements('.netbox-api-select:not([data-ssid])')) {
new APISelect(select);
}
}
diff --git a/netbox/project-static/src/select/color.ts b/netbox/project-static/src/select/color.ts
index e2c93c37c..4c8d6454a 100644
--- a/netbox/project-static/src/select/color.ts
+++ b/netbox/project-static/src/select/color.ts
@@ -40,7 +40,9 @@ function styleContainer(
* the selected option.
*/
export function initColorSelect(): void {
- for (const select of getElements('select.netbox-color-select')) {
+ for (const select of getElements(
+ 'select.netbox-color-select:not([data-ssid])',
+ )) {
for (const option of select.options) {
if (canChangeColor(option)) {
// Get the background color from the option's value.
diff --git a/netbox/project-static/src/select/static.ts b/netbox/project-static/src/select/static.ts
index c649537ba..19031bb7d 100644
--- a/netbox/project-static/src/select/static.ts
+++ b/netbox/project-static/src/select/static.ts
@@ -2,7 +2,7 @@ import SlimSelect from 'slim-select';
import { getElements } from '../util';
export function initStaticSelect(): void {
- for (const select of getElements('.netbox-static-select')) {
+ for (const select of getElements('.netbox-static-select:not([data-ssid])')) {
if (select !== null) {
const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts
index 9f6ff100d..e1ada2e19 100644
--- a/netbox/project-static/src/util.ts
+++ b/netbox/project-static/src/util.ts
@@ -397,16 +397,6 @@ export function createElement<
return element as HTMLElementTagNameMap[T];
}
-/**
- * Convert Celsius to Fahrenheit, for NAPALM temperature sensors.
- *
- * @param celsius Degrees in Celsius.
- * @returns Degrees in Fahrenheit.
- */
-export function cToF(celsius: number): number {
- return Math.round((celsius * (9 / 5) + 32 + Number.EPSILON) * 10) / 10;
-}
-
/**
* Deduplicate an array of objects based on the value of a property.
*
diff --git a/netbox/project-static/styles/_external.scss b/netbox/project-static/styles/_external.scss
index aee6aa95d..a44238653 100644
--- a/netbox/project-static/styles/_external.scss
+++ b/netbox/project-static/styles/_external.scss
@@ -2,3 +2,4 @@
@import '../node_modules/@mdi/font/css/materialdesignicons.min.css';
@import '../node_modules/flatpickr/dist/flatpickr.css';
@import '../node_modules/simplebar/dist/simplebar.css';
+@import 'gridstack/dist/gridstack.min.css';
diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index 9dca72d25..c4bee7557 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -875,11 +875,6 @@ delegate@^3.1.2:
resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
-desandro-matches-selector@^2.0.0:
- version "2.0.2"
- resolved "https://registry.yarnpkg.com/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz#717beed4dc13e7d8f3762f707a6d58a6774218e1"
- integrity sha1-cXvu1NwT59jzdi9wem1YpndCGOE=
-
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
@@ -1411,11 +1406,6 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
-ev-emitter@^1.0.0:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/ev-emitter/-/ev-emitter-1.1.1.tgz#8f18b0ce5c76a5d18017f71c0a795c65b9138f2a"
- integrity sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q==
-
event-target-shim@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
@@ -1496,13 +1486,6 @@ find-up@^5.0.0:
locate-path "^6.0.0"
path-exists "^4.0.0"
-fizzy-ui-utils@^2.0.0:
- version "2.0.7"
- resolved "https://registry.yarnpkg.com/fizzy-ui-utils/-/fizzy-ui-utils-2.0.7.tgz#7df45dcc4eb374a08b65d39bb9a4beedf7330505"
- integrity sha512-CZXDVXQ1If3/r8s0T+v+qVeMshhfcuq0rqIFgJnrtd+Bu8GmDmqMjntjUePypVtjHXKJ6V4sw9zeyox34n9aCg==
- dependencies:
- desandro-matches-selector "^2.0.0"
-
flat-cache@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
@@ -1582,11 +1565,6 @@ get-intrinsic@^1.1.3:
has "^1.0.3"
has-symbols "^1.0.3"
-get-size@^2.0.2:
- version "2.0.3"
- resolved "https://registry.yarnpkg.com/get-size/-/get-size-2.0.3.tgz#54a1d0256b20ea7ac646516756202769941ad2ef"
- integrity sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q==
-
get-symbol-description@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
@@ -1784,6 +1762,11 @@ graphql-ws@^5.4.1:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5"
integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA==
+gridstack@^7.2.3:
+ version "7.2.3"
+ resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.2.3.tgz#bc04d3588eb5f2b7edd910e31fdac5bea8069ff2"
+ integrity sha512-1s4Fx+Hr4nKl064q/ygrd41XiZaC2gG6R+yz5nbOibP9vODJ6mOtjIM5x8qKN12FknakaMpVBnCa1T6V7H15hQ==
+
has-bigints@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
@@ -2163,14 +2146,6 @@ markdown-it@^10.0.0:
mdurl "^1.0.1"
uc.micro "^1.0.5"
-masonry-layout@^4.2.2:
- version "4.2.2"
- resolved "https://registry.yarnpkg.com/masonry-layout/-/masonry-layout-4.2.2.tgz#d57b44af13e601bfcdc423f1dd8348b5524de348"
- integrity sha512-iGtAlrpHNyxaR19CvKC3npnEcAwszXoyJiI8ARV2ePi7fmYhIud25MHK8Zx4P0LCC4d3TNO9+rFa1KoK1OEOaA==
- dependencies:
- get-size "^2.0.2"
- outlayer "^2.1.0"
-
mdurl@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
@@ -2341,15 +2316,6 @@ optionator@^0.9.1:
type-check "^0.4.0"
word-wrap "^1.2.3"
-outlayer@^2.1.0:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/outlayer/-/outlayer-2.1.1.tgz#29863b6de10ea5dadfffcadfa0d728907387e9a2"
- integrity sha1-KYY7beEOpdrf/8rfoNcokHOH6aI=
- dependencies:
- ev-emitter "^1.0.0"
- fizzy-ui-utils "^2.0.0"
- get-size "^2.0.2"
-
p-limit@3.1.0, p-limit@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
diff --git a/netbox/templates/dcim/device/config.html b/netbox/templates/dcim/device/config.html
deleted file mode 100644
index f3609d3a4..000000000
--- a/netbox/templates/dcim/device/config.html
+++ /dev/null
@@ -1,45 +0,0 @@
-{% extends 'dcim/device/base.html' %}
-{% load static %}
-
-{% block title %}{{ object }} - Config{% endblock %}
-
-{% block head %}
-
-{% endblock %}
-
-{% block content %}
-
-{% endblock %}
-
-{% block data %}
-
-{% endblock %}
diff --git a/netbox/templates/dcim/device/lldp_neighbors.html b/netbox/templates/dcim/device/lldp_neighbors.html
deleted file mode 100644
index 2be6aba4d..000000000
--- a/netbox/templates/dcim/device/lldp_neighbors.html
+++ /dev/null
@@ -1,66 +0,0 @@
-{% extends 'dcim/device/base.html' %}
-{% load static %}
-
-{% block title %}{{ object }} - LLDP Neighbors{% endblock %}
-
-{% block head %}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
-
- Interface |
- Configured Device |
- Configured Interface |
- LLDP Device |
- LLDP Interface |
-
-
-
- {% for iface in interfaces %}
-
- {{ iface }} |
- {% with peer=iface.connected_endpoints.0 %}
- {% if peer.device %}
-
- {{ peer.device }}
- |
-
- {{ peer }}
- |
- {% elif peer.circuit %}
- {% with circuit=peer.circuit %}
-
-
- {{ circuit.provider }} {{ circuit }}
- |
- {% endwith %}
- {% else %}
- None |
- {% endif %}
- {% endwith %}
- |
- |
-
- {% endfor %}
-
-
-
-
-{% endblock %}
-
-{% block data %}
-
-{% endblock %}
diff --git a/netbox/templates/dcim/device/status.html b/netbox/templates/dcim/device/status.html
deleted file mode 100644
index 51dd7d27e..000000000
--- a/netbox/templates/dcim/device/status.html
+++ /dev/null
@@ -1,93 +0,0 @@
-{% extends 'dcim/device/base.html' %}
-{% load static %}
-
-{% block title %}{{ object }} - Status{% endblock %}
-
-{% block head %}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
-
-
- Hostname |
- |
-
-
- FQDN |
- |
-
-
- Vendor |
- |
-
-
- Model |
- |
-
-
- Serial Number |
- |
-
-
- OS Version |
- |
-
-
- Uptime |
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
- CPU |
-
-
- Memory |
-
-
- Temperature |
-
-
- Fans |
-
-
- Power |
-
-
-
-
-
-
-
-
-{% endblock %}
-
-{% block data %}
-
-{% endblock %}
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html
index 5123699d4..a834ed7e9 100644
--- a/netbox/templates/dcim/platform.html
+++ b/netbox/templates/dcim/platform.html
@@ -43,20 +43,10 @@
Config Template |
{{ object.config_template|linkify|placeholder }} |
-
- NAPALM Driver |
- {{ object.napalm_driver|placeholder }} |
-
{% include 'inc/panels/tags.html' %}
-
-
-
-
{{ object.napalm_args|json }}
-
-
{% plugin_left_page object %}
diff --git a/netbox/templates/extras/dashboard/widget.html b/netbox/templates/extras/dashboard/widget.html
new file mode 100644
index 000000000..4ed84f067
--- /dev/null
+++ b/netbox/templates/extras/dashboard/widget.html
@@ -0,0 +1,37 @@
+{% load dashboard %}
+
+
+
+
+
+ {% render_widget widget %}
+
+
+
diff --git a/netbox/templates/extras/dashboard/widget_add.html b/netbox/templates/extras/dashboard/widget_add.html
new file mode 100644
index 000000000..e752a393d
--- /dev/null
+++ b/netbox/templates/extras/dashboard/widget_add.html
@@ -0,0 +1,27 @@
+{% load form_helpers %}
+
+
diff --git a/netbox/templates/extras/dashboard/widget_config.html b/netbox/templates/extras/dashboard/widget_config.html
new file mode 100644
index 000000000..6f8f8cc20
--- /dev/null
+++ b/netbox/templates/extras/dashboard/widget_config.html
@@ -0,0 +1,20 @@
+{% load form_helpers %}
+
+
diff --git a/netbox/templates/extras/dashboard/widgets/changelog.html b/netbox/templates/extras/dashboard/widgets/changelog.html
new file mode 100644
index 000000000..dfa4dba3f
--- /dev/null
+++ b/netbox/templates/extras/dashboard/widgets/changelog.html
@@ -0,0 +1,4 @@
+
diff --git a/netbox/templates/extras/dashboard/widgets/objectcounts.html b/netbox/templates/extras/dashboard/widgets/objectcounts.html
new file mode 100644
index 000000000..d75d88218
--- /dev/null
+++ b/netbox/templates/extras/dashboard/widgets/objectcounts.html
@@ -0,0 +1,14 @@
+{% load helpers %}
+
+{% if counts %}
+
+{% endif %}
diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html
index 78aaaa105..11fdcafb9 100644
--- a/netbox/templates/extras/webhook.html
+++ b/netbox/templates/extras/webhook.html
@@ -40,6 +40,14 @@
Delete |
{% checkmark object.type_delete %} |
+
+ Job start |
+ {% checkmark object.type_job_start %} |
+
+
+ Job end |
+ {% checkmark object.type_job_end %} |
+
diff --git a/netbox/templates/home.html b/netbox/templates/home.html
index cef797f40..de8a26a7f 100644
--- a/netbox/templates/home.html
+++ b/netbox/templates/home.html
@@ -3,80 +3,48 @@
{% load render_table from django_tables2 %}
{% block header %}
- {% if new_release %}
- {# new_release is set only if the current user is a superuser or staff member #}
-
- {% endif %}
+ {% if new_release %}
+ {# new_release is set only if the current user is a superuser or staff member #}
+
+ {% endif %}
{% endblock %}
{% block title %}Home{% endblock %}
{% block content-wrapper %}
-
- {# General stats #}
-
- {% for section, items, icon in stats %}
-
- {% endfor %}
-
-
- {# Changelog #}
- {% if perms.extras.view_objectchange %}
-
- {% endif %}
+ {# Render the user's customized dashboard #}
+
+ {% for widget in dashboard %}
+ {% include 'extras/dashboard/widget.html' %}
+ {% endfor %}
+
+
{% endblock content-wrapper %}
+
+{% block modals %}
+ {% include 'inc/htmx_modal.html' %}
+{% endblock modals %}
diff --git a/netbox/templates/inc/htmx_modal.html b/netbox/templates/inc/htmx_modal.html
index d15e5b799..771f5d595 100644
--- a/netbox/templates/inc/htmx_modal.html
+++ b/netbox/templates/inc/htmx_modal.html
@@ -1,5 +1,5 @@
-
+
{# Dynamic content goes here #}
diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html
index a54a0aee5..f05febe47 100644
--- a/netbox/templates/ipam/asn.html
+++ b/netbox/templates/ipam/asn.html
@@ -7,6 +7,9 @@
{% block breadcrumbs %}
{{ block.super }}
{{ object.rir }}
+ {% if object.range %}
+
{{ object.range }}
+ {% endif %}
{% endblock breadcrumbs %}
{% block content %}
diff --git a/netbox/templates/ipam/asnrange.html b/netbox/templates/ipam/asnrange.html
new file mode 100644
index 000000000..f9ec5765f
--- /dev/null
+++ b/netbox/templates/ipam/asnrange.html
@@ -0,0 +1,57 @@
+{% extends 'ipam/asnrange/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Name |
+ {{ object.name }} |
+
+
+ RIR |
+
+ {{ object.rir }}
+ |
+
+
+ Range |
+ {{ object.range_as_string }} |
+
+
+ Tenant |
+
+ {% if object.tenant.group %}
+ {{ object.tenant.group|linkify }} /
+ {% endif %}
+ {{ object.tenant|linkify|placeholder }}
+ |
+
+
+ Description |
+ {{ object.description|placeholder }} |
+
+
+
+
+ {% plugin_left_page object %}
+ {% include 'inc/panels/tags.html' %}
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock content %}
diff --git a/netbox/templates/ipam/asnrange/asns.html b/netbox/templates/ipam/asnrange/asns.html
new file mode 100644
index 000000000..69d4e8abb
--- /dev/null
+++ b/netbox/templates/ipam/asnrange/asns.html
@@ -0,0 +1,36 @@
+{% extends 'ipam/asnrange/base.html' %}
+{% load helpers %}
+
+{% block content %}
+ {% include 'inc/table_controls_htmx.html' with table_modal="ASNTable_config" %}
+
+
+{% endblock %}
+
+{% block modals %}
+ {{ block.super }}
+ {% table_config_form table %}
+{% endblock modals %}
diff --git a/netbox/templates/ipam/asnrange/base.html b/netbox/templates/ipam/asnrange/base.html
new file mode 100644
index 000000000..2ab472019
--- /dev/null
+++ b/netbox/templates/ipam/asnrange/base.html
@@ -0,0 +1,6 @@
+{% extends 'generic/object.html' %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+
{{ object.rir }}
+{% endblock breadcrumbs %}
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html
index 6ba9e4bea..93c3c2889 100644
--- a/netbox/templates/ipam/iprange.html
+++ b/netbox/templates/ipam/iprange.html
@@ -30,7 +30,12 @@
Utilization |
- {% utilization_graph object.utilization %}
+ {% if object.mark_utilized %}
+ {% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
+ (Marked fully utilized)
+ {% else %}
+ {% utilization_graph object.utilization %}
+ {% endif %}
|
diff --git a/netbox/tenancy/migrations/0010_tenant_relax_uniqueness.py b/netbox/tenancy/migrations/0010_tenant_relax_uniqueness.py
new file mode 100644
index 000000000..6082fbfe9
--- /dev/null
+++ b/netbox/tenancy/migrations/0010_tenant_relax_uniqueness.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.1.7 on 2023-03-01 01:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0009_standardize_description_comments'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tenant',
+ name='name',
+ field=models.CharField(max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='tenant',
+ name='slug',
+ field=models.SlugField(max_length=100),
+ ),
+ migrations.AddConstraint(
+ model_name='tenant',
+ constraint=models.UniqueConstraint(fields=('group', 'name'), name='tenancy_tenant_unique_group_name', violation_error_message='Tenant name must be unique per group.'),
+ ),
+ migrations.AddConstraint(
+ model_name='tenant',
+ constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('name',), name='tenancy_tenant_unique_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='tenant',
+ constraint=models.UniqueConstraint(fields=('group', 'slug'), name='tenancy_tenant_unique_group_slug', violation_error_message='Tenant slug must be unique per group.'),
+ ),
+ migrations.AddConstraint(
+ model_name='tenant',
+ constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('slug',), name='tenancy_tenant_unique_slug'),
+ ),
+ ]
diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py
index 4c0c11e2a..a41b8bf99 100644
--- a/netbox/tenancy/models/tenants.py
+++ b/netbox/tenancy/models/tenants.py
@@ -1,5 +1,6 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
+from django.db.models import Q
from django.urls import reverse
from netbox.models import NestedGroupModel, PrimaryModel
@@ -36,12 +37,10 @@ class Tenant(PrimaryModel):
department.
"""
name = models.CharField(
- max_length=100,
- unique=True
+ max_length=100
)
slug = models.SlugField(
- max_length=100,
- unique=True
+ max_length=100
)
group = models.ForeignKey(
to='tenancy.TenantGroup',
@@ -62,6 +61,28 @@ class Tenant(PrimaryModel):
class Meta:
ordering = ['name']
+ constraints = (
+ models.UniqueConstraint(
+ fields=('group', 'name'),
+ name='%(app_label)s_%(class)s_unique_group_name',
+ violation_error_message="Tenant name must be unique per group."
+ ),
+ models.UniqueConstraint(
+ fields=('name',),
+ name='%(app_label)s_%(class)s_unique_name',
+ condition=Q(group__isnull=True)
+ ),
+ models.UniqueConstraint(
+ fields=('group', 'slug'),
+ name='%(app_label)s_%(class)s_unique_group_slug',
+ violation_error_message="Tenant slug must be unique per group."
+ ),
+ models.UniqueConstraint(
+ fields=('slug',),
+ name='%(app_label)s_%(class)s_unique_slug',
+ condition=Q(group__isnull=True)
+ ),
+ )
def __str__(self):
return self.name
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 07e903569..4e7d9ca52 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -140,7 +140,10 @@ class UserConfig(models.Model):
# Set a key based on the last item in the path. Raise TypeError if attempting to overwrite a non-leaf node.
key = keys[-1]
if key in d and type(d[key]) is dict:
- raise TypeError(f"Key '{path}' has child keys; cannot assign a value")
+ if type(value) is dict:
+ d[key].update(value)
+ else:
+ raise TypeError(f"Key '{path}' is a dictionary; cannot assign a non-dictionary value")
else:
d[key] = value
diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py
index 9303e5f3a..096b60a70 100644
--- a/netbox/utilities/constants.py
+++ b/netbox/utilities/constants.py
@@ -43,6 +43,7 @@ ADVISORY_LOCK_KEYS = {
'available-prefixes': 100100,
'available-ips': 100200,
'available-vlans': 100300,
+ 'available-asns': 100400,
}
#
diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html
index b3bccd716..a2b27ed2a 100644
--- a/netbox/utilities/templates/builtins/customfield_value.html
+++ b/netbox/utilities/templates/builtins/customfield_value.html
@@ -9,6 +9,8 @@
{% checkmark value false="False" %}
{% elif customfield.type == 'date' and value %}
{{ value|annotated_date }}
+{% elif customfield.type == 'datetime' and value %}
+ {{ value|annotated_date }}
{% elif customfield.type == 'url' and value %}
{{ value|truncatechars:70 }}
{% elif customfield.type == 'json' and value %}
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index e461eac8a..03be99894 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/netbox/virtualization/forms/model_forms.py
@@ -4,7 +4,6 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from dcim.forms.common import InterfaceCommonForm
-from dcim.forms.model_forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
from ipam.models import IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm
@@ -237,10 +236,6 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags',
'local_context_data',
]
- help_texts = {
- 'local_context_data': _("Local config context data overwrites all sources contexts in the final rendered "
- "config context"),
- }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -358,9 +353,6 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
}
),
}
- help_texts = {
- 'mode': INTERFACE_MODE_HELP_TEXT,
- }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)