From 86aed4e0732a1a61bff5af943b581b58e26e541d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Sep 2021 12:14:15 -0400 Subject: [PATCH 001/289] Closes #7318: Raise minimum required PostgreSQL version from 9.6 to 10 --- docs/configuration/required-settings.md | 2 +- docs/index.md | 2 +- docs/installation/1-postgresql.md | 4 ++-- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 2 +- docs/release-notes/version-3.1.md | 8 ++++++++ 6 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 docs/release-notes/version-3.1.md diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index 3158fc73a..a62d14fef 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 9.6 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 10 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/index.md b/docs/index.md index c25c5be16..7abbd9310 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,7 +48,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 9.6+ | +| Database | PostgreSQL 10+ | | Task queuing | Redis/django-rq | | Live device access | NAPALM | diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 43b12f0e8..36a4f87b9 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -3,7 +3,7 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). !!! warning - NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported. + NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -22,7 +22,7 @@ This section entails the installation and configuration of a local PostgreSQL da ``` !!! info - PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). + PostgreSQL 10 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: diff --git a/docs/installation/index.md b/docs/installation/index.md index 893b1f639..375338d7e 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -20,7 +20,7 @@ The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 | Dependency | Minimum Version | |------------|-----------------| | Python | 3.7 | -| PostgreSQL | 9.6 | +| PostgreSQL | 10 | | Redis | 4.0 | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 9854afeb4..cd14bf8f0 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -11,7 +11,7 @@ NetBox v3.0 and later requires the following: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.7 | -| PostgreSQL | 9.6 | +| PostgreSQL | 10 | | Redis | 4.0 | ## Install the Latest Release diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md new file mode 100644 index 000000000..4758177e3 --- /dev/null +++ b/docs/release-notes/version-3.1.md @@ -0,0 +1,8 @@ +## v3.1-beta1 (FUTURE) + +!!! warning "PostgreSQL 10 Required" + NetBox v3.1 requires PostgreSQL 10 or later. + +### Other Changes + +* [#7318](https://github.com/netbox-community/netbox/issues/7318) - Raise minimum required PostgreSQL version from 9.6 to 10 From 18c3bb673f762ec92e96ce75c0d21b2e98664348 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 7 Oct 2021 15:09:42 -0400 Subject: [PATCH 002/289] Closes #1337: Add WWN field to interfaces --- docs/release-notes/version-3.1.md | 4 ++ netbox/dcim/api/serializers.py | 2 +- netbox/dcim/fields.py | 55 +++++++++++++++++--- netbox/dcim/filtersets.py | 3 +- netbox/dcim/forms/bulk_edit.py | 6 ++- netbox/dcim/forms/bulk_import.py | 4 +- netbox/dcim/forms/filtersets.py | 6 ++- netbox/dcim/forms/models.py | 2 +- netbox/dcim/migrations/0134_interface_wwn.py | 17 ++++++ netbox/dcim/models/device_components.py | 8 ++- netbox/dcim/tables/devices.py | 4 +- netbox/dcim/tests/test_views.py | 3 ++ netbox/netbox/graphql/__init__.py | 3 +- netbox/templates/dcim/interface.html | 4 ++ netbox/templates/dcim/interface_edit.html | 1 + netbox/utilities/filters.py | 4 ++ 16 files changed, 107 insertions(+), 19 deletions(-) create mode 100644 netbox/dcim/migrations/0134_interface_wwn.py diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 4758177e3..4856c1a4c 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,6 +3,10 @@ !!! warning "PostgreSQL 10 Required" NetBox v3.1 requires PostgreSQL 10 or later. +### Enhancements + +* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces + ### Other Changes * [#7318](https://github.com/netbox-community/netbox/issues/7318) - Raise minimum required PostgreSQL version from 9.6 to 10 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8e2fa15af..a7d2e88da 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -645,7 +645,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', + 'wwn', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 21af2ed14..d3afe5c08 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -2,11 +2,30 @@ 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, mac_unix_expanded +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__ = ( + 'ASNField', + 'MACAddressField', + 'PathField', + 'WWNField', +) + + +class mac_unix_expanded_uppercase(mac_unix_expanded): + word_fmt = '%.2X' + + +class eui64_unix_expanded_uppercase(eui64_unix_expanded): + word_fmt = '%.2X' + + +# +# Fields +# class ASNField(models.BigIntegerField): description = "32-bit ASN field" @@ -24,10 +43,6 @@ class ASNField(models.BigIntegerField): return super().formfield(**defaults) -class mac_unix_expanded_uppercase(mac_unix_expanded): - word_fmt = '%.2X' - - class MACAddressField(models.Field): description = "PostgreSQL MAC Address field" @@ -42,8 +57,8 @@ class MACAddressField(models.Field): return value try: return EUI(value, version=48, dialect=mac_unix_expanded_uppercase) - except AddrFormatError as e: - raise ValidationError("Invalid MAC address format: {}".format(value)) + except AddrFormatError: + raise ValidationError(f"Invalid MAC address format: {value}") def db_type(self, connection): return 'macaddr' @@ -54,6 +69,32 @@ class MACAddressField(models.Field): return str(self.to_python(value)) +class WWNField(models.Field): + description = "World Wide Name field" + + def python_type(self): + return EUI + + def from_db_value(self, value, expression, connection): + return self.to_python(value) + + def to_python(self, value): + if value is None: + return value + try: + return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase) + except AddrFormatError: + raise ValidationError(f"Invalid WWN format: {value}") + + def db_type(self, connection): + return 'macaddr8' + + def get_prep_value(self, value): + if not value: + return None + return str(self.to_python(value)) + + class PathField(ArrayField): """ An ArrayField which holds a set of objects, each identified by a (type, ID) tuple. diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6f2c23c90..9457507bb 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -10,7 +10,7 @@ from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant from utilities.choices import ColorChoices from utilities.filters import ( - ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, + ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster @@ -964,6 +964,7 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT label='LAG interface (ID)', ) mac_address = MultiValueMACAddressFilter() + wwn = MultiValueWWNFilter() tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index c1b1bcb3a..5cfe86118 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -921,7 +921,8 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', + 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', + 'mode', ]), BootstrapMixin, AddRemoveTagsForm, @@ -972,7 +973,8 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'untagged_vlan', + 'tagged_vlans', ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 072cdf0e0..8f7755869 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -577,8 +577,8 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', - 'mgmt_only', 'description', 'mode', + 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', + 'mtu', 'mgmt_only', 'description', 'mode', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 95ff9aa3d..0079217ab 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -957,7 +957,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface field_groups = [ ['q', 'tag'], - ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'], + ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ] type = forms.MultipleChoiceField( @@ -981,6 +981,10 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='MAC address' ) + wwn = forms.CharField( + required=False, + label='WWN' + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 009e1fe3f..0b6e66c3c 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1091,7 +1091,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { diff --git a/netbox/dcim/migrations/0134_interface_wwn.py b/netbox/dcim/migrations/0134_interface_wwn.py new file mode 100644 index 000000000..0739edbbb --- /dev/null +++ b/netbox/dcim/migrations/0134_interface_wwn.py @@ -0,0 +1,17 @@ +import dcim.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0133_port_colors'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wwn', + field=dcim.fields.WWNField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a321c8059..386776b41 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -9,7 +9,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * -from dcim.fields import MACAddressField +from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG from extras.utils import extras_features from netbox.models import PrimaryModel @@ -511,6 +511,12 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): verbose_name='Management only', help_text='This interface is used only for out-of-band management' ) + wwn = WWNField( + null=True, + blank=True, + verbose_name='WWN', + help_text='64-bit World Wide Name' + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c22e673b7..c2b4b907b 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -492,7 +492,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', + 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) @@ -524,7 +524,7 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 18eaeec3b..00904d444 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1469,6 +1469,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'enabled': False, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), + 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 65000, 'mgmt_only': True, 'description': 'A front port', @@ -1485,6 +1486,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'enabled': False, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), + 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, 'mgmt_only': True, 'description': 'A front port', @@ -1499,6 +1501,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'enabled': True, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), + 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, 'mgmt_only': True, 'description': 'New description', diff --git a/netbox/netbox/graphql/__init__.py b/netbox/netbox/graphql/__init__.py index 069f6a9c8..0ad25a541 100644 --- a/netbox/netbox/graphql/__init__.py +++ b/netbox/netbox/graphql/__init__.py @@ -2,7 +2,7 @@ import graphene from graphene_django.converter import convert_django_field from taggit.managers import TaggableManager -from dcim.fields import MACAddressField +from dcim.fields import MACAddressField, WWNField from ipam.fields import IPAddressField, IPNetworkField @@ -17,6 +17,7 @@ def convert_field_to_tags_list(field, registry=None): @convert_django_field.register(IPAddressField) @convert_django_field.register(IPNetworkField) @convert_django_field.register(MACAddressField) +@convert_django_field.register(WWNField) def convert_field_to_string(field, registry=None): # TODO: Update to use get_django_field_description under django_graphene v3.0 return graphene.String(description=field.help_text, required=not field.null) diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3a4d16db3..f9a9b0425 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -91,6 +91,10 @@ MAC Address {{ object.mac_address|placeholder }} + + WWN + {{ object.wwn|placeholder }} + 802.1Q Mode {{ object.get_mode_display|placeholder }} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 38b22fe5e..041eab73a 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -20,6 +20,7 @@ {% render_field form.parent %} {% render_field form.lag %} {% render_field form.mac_address %} + {% render_field form.wwn %} {% render_field form.mtu %} {% render_field form.description %} {% render_field form.tags %} diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index ed71afc1b..8dac65aac 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -57,6 +57,10 @@ class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(MACAddressField) +class MultiValueWWNFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(MACAddressField) + + class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): """ Filters for a set of Models, including all descendant models within a Tree. Example: [,] From 5a6190e321e9494d458fa7d4fa56520b5d50ed17 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 7 Oct 2021 15:46:21 -0400 Subject: [PATCH 003/289] Closes #6874: Add tenant assignment for locations --- docs/release-notes/version-3.1.md | 1 + netbox/dcim/api/serializers.py | 5 +++-- netbox/dcim/forms/bulk_edit.py | 6 +++++- netbox/dcim/forms/bulk_import.py | 8 +++++++- netbox/dcim/forms/filtersets.py | 7 ++++++- netbox/dcim/forms/models.py | 10 ++++++++-- .../dcim/migrations/0135_location_tenant.py | 18 +++++++++++++++++ netbox/dcim/models/sites.py | 8 +++++++- netbox/dcim/tables/sites.py | 5 +++-- netbox/dcim/tests/test_views.py | 20 ++++++++++--------- netbox/templates/dcim/location.html | 13 ++++++++++++ 11 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 netbox/dcim/migrations/0135_location_tenant.py diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 4856c1a4c..00a6e2fda 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces +* [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations ### Other Changes diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a7d2e88da..d6e44c281 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -138,14 +138,15 @@ class LocationSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', - 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'custom_fields', + 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 5cfe86118..fd87d7304 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -148,13 +148,17 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): 'site_id': '$site' } ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) description = forms.CharField( max_length=200, required=False ) class Meta: - nullable_fields = ['parent', 'description'] + nullable_fields = ['parent', 'tenant', 'description'] class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 8f7755869..ff9ab6fff 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -120,10 +120,16 @@ class LocationCSVForm(CustomFieldModelCSVForm): 'invalid_choice': 'Location not found.', } ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'description') + fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') class RackRoleCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0079217ab..4f4e10e96 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -175,8 +175,13 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo tag = TagFilterField(model) -class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): model = Location + field_groups = [ + ['q'], + ['region_id', 'site_group_id', 'site_id', 'parent_id'], + ['tenant_group_id', 'tenant_id'], + ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 0b6e66c3c..a8c2991a4 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -157,7 +157,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class LocationForm(BootstrapMixin, CustomFieldModelForm): +class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -191,7 +191,13 @@ class LocationForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', + ) + fieldsets = ( + ('Location', ( + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', + )), + ('Tenancy', ('tenant_group', 'tenant')), ) diff --git a/netbox/dcim/migrations/0135_location_tenant.py b/netbox/dcim/migrations/0135_location_tenant.py new file mode 100644 index 000000000..0b1f429f9 --- /dev/null +++ b/netbox/dcim/migrations/0135_location_tenant.py @@ -0,0 +1,18 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0002_tenant_ordering'), + ('dcim', '0134_interface_wwn'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'), + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 56946642b..b343f61f2 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,7 +7,6 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from django.core.exceptions import ValidationError from dcim.fields import ASNField from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel @@ -281,6 +280,13 @@ class Location(NestedGroupModel): null=True, db_index=True ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='locations', + blank=True, + null=True + ) description = models.CharField( max_length=200, blank=True diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 37fa019a1..3ff6ab75b 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -103,6 +103,7 @@ class LocationTable(BaseTable): site = tables.Column( linkify=True ) + tenant = TenantColumn() rack_count = LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, @@ -120,5 +121,5 @@ class LocationTable(BaseTable): class Meta(BaseTable.Meta): model = Location - fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions') + fields = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 00904d444..545a56f81 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -12,6 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VLAN +from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -157,13 +158,13 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') + tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') locations = ( - Location(name='Location 1', slug='location-1', site=site), - Location(name='Location 2', slug='location-2', site=site), - Location(name='Location 3', slug='location-3', site=site), + Location(name='Location 1', slug='location-1', site=site, tenant=tenant), + Location(name='Location 2', slug='location-2', site=site, tenant=tenant), + Location(name='Location 3', slug='location-3', site=site, tenant=tenant), ) for location in locations: location.save() @@ -172,14 +173,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, + 'tenant': tenant.pk, 'description': 'A new location', } cls.csv_data = ( - "site,name,slug,description", - "Site 1,Location 4,location-4,Fourth location", - "Site 1,Location 5,location-5,Fifth location", - "Site 1,Location 6,location-6,Sixth location", + "site,tenant,name,slug,description", + "Site 1,Tenant 1,Location 4,location-4,Fourth location", + "Site 1,Tenant 1,Location 5,location-5,Fifth location", + "Site 1,Tenant 1,Location 6,location-6,Sixth location", ) cls.bulk_edit_data = { diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index b062ddcb5..cd0f2a92a 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -40,6 +40,19 @@ {% endif %} + + Tenant + + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} + + Racks From 8e1535f7ecba14aa9259b0ad62f0eabeccc12d82 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 10:46:41 -0400 Subject: [PATCH 004/289] Add RF channel fields to Interface --- netbox/dcim/api/serializers.py | 10 +- netbox/dcim/choices.py | 131 ++++++++++++++++++++++ netbox/dcim/constants.py | 1 + netbox/dcim/filtersets.py | 5 +- netbox/dcim/forms/bulk_edit.py | 6 +- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 11 ++ netbox/dcim/forms/models.py | 5 +- netbox/dcim/forms/object_create.py | 15 ++- netbox/dcim/migrations/0136_wireless.py | 21 ++++ netbox/dcim/models/device_components.py | 18 +++ netbox/dcim/tables/devices.py | 4 +- netbox/templates/dcim/interface.html | 10 ++ netbox/templates/dcim/interface_edit.html | 10 ++ 14 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 netbox/dcim/migrations/0136_wireless.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d6e44c281..edd73b87e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -632,6 +632,8 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + rf_channel = ChoiceField(choices=WirelessChannelChoices) + rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -646,10 +648,10 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', - 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - '_occupied', + 'wwn', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', + 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index acea294f8..9a78a74f9 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1135,6 +1135,137 @@ class CableLengthUnitChoices(ChoiceSet): ) +# +# Wireless +# + +class WirelessChannelChoices(ChoiceSet): + CHANNEL_AUTO = 'auto' + + # 2.4 GHz + CHANNEL_24G_1 = '2.4g-1' + CHANNEL_24G_2 = '2.4g-2' + CHANNEL_24G_3 = '2.4g-3' + CHANNEL_24G_4 = '2.4g-4' + CHANNEL_24G_5 = '2.4g-5' + CHANNEL_24G_6 = '2.4g-6' + CHANNEL_24G_7 = '2.4g-7' + CHANNEL_24G_8 = '2.4g-8' + CHANNEL_24G_9 = '2.4g-9' + CHANNEL_24G_10 = '2.4g-10' + CHANNEL_24G_11 = '2.4g-11' + CHANNEL_24G_12 = '2.4g-12' + CHANNEL_24G_13 = '2.4g-13' + + # 5 GHz + CHANNEL_5G_32 = '5g-32' + CHANNEL_5G_34 = '5g-34' + CHANNEL_5G_36 = '5g-36' + CHANNEL_5G_38 = '5g-38' + CHANNEL_5G_40 = '5g-40' + CHANNEL_5G_42 = '5g-42' + CHANNEL_5G_44 = '5g-44' + CHANNEL_5G_46 = '5g-46' + CHANNEL_5G_48 = '5g-48' + CHANNEL_5G_50 = '5g-50' + CHANNEL_5G_52 = '5g-52' + CHANNEL_5G_54 = '5g-54' + CHANNEL_5G_56 = '5g-56' + CHANNEL_5G_58 = '5g-58' + CHANNEL_5G_60 = '5g-60' + CHANNEL_5G_62 = '5g-62' + CHANNEL_5G_64 = '5g-64' + CHANNEL_5G_100 = '5g-100' + CHANNEL_5G_102 = '5g-102' + CHANNEL_5G_104 = '5g-104' + CHANNEL_5G_106 = '5g-106' + CHANNEL_5G_108 = '5g-108' + CHANNEL_5G_110 = '5g-110' + CHANNEL_5G_112 = '5g-112' + CHANNEL_5G_114 = '5g-114' + CHANNEL_5G_116 = '5g-116' + CHANNEL_5G_118 = '5g-118' + CHANNEL_5G_120 = '5g-120' + CHANNEL_5G_122 = '5g-122' + CHANNEL_5G_124 = '5g-124' + CHANNEL_5G_126 = '5g-126' + CHANNEL_5G_128 = '5g-128' + + CHOICES = ( + (CHANNEL_AUTO, 'Auto'), + ( + '2.4 GHz (802.11b/g/n/ax)', + ( + (CHANNEL_24G_1, '1 (2412 MHz)'), + (CHANNEL_24G_2, '2 (2417 MHz)'), + (CHANNEL_24G_3, '3 (2422 MHz)'), + (CHANNEL_24G_4, '4 (2427 MHz)'), + (CHANNEL_24G_5, '5 (2432 MHz)'), + (CHANNEL_24G_6, '6 (2437 MHz)'), + (CHANNEL_24G_7, '7 (2442 MHz)'), + (CHANNEL_24G_8, '8 (2447 MHz)'), + (CHANNEL_24G_9, '9 (2452 MHz)'), + (CHANNEL_24G_10, '10 (2457 MHz)'), + (CHANNEL_24G_11, '11 (2462 MHz)'), + (CHANNEL_24G_12, '12 (2467 MHz)'), + (CHANNEL_24G_13, '13 (2472 MHz)'), + ) + ), + ( + '5 GHz (802.11a/n/ac/ax)', + ( + (CHANNEL_5G_32, '32 (5160 MHz)'), + (CHANNEL_5G_34, '34 (5170 MHz)'), + (CHANNEL_5G_36, '36 (5180 MHz)'), + (CHANNEL_5G_38, '38 (5190 MHz)'), + (CHANNEL_5G_40, '40 (5200 MHz)'), + (CHANNEL_5G_42, '42 (5210 MHz)'), + (CHANNEL_5G_44, '44 (5220 MHz)'), + (CHANNEL_5G_46, '46 (5230 MHz)'), + (CHANNEL_5G_48, '48 (5240 MHz)'), + (CHANNEL_5G_50, '50 (5250 MHz)'), + (CHANNEL_5G_52, '52 (5260 MHz)'), + (CHANNEL_5G_54, '54 (5270 MHz)'), + (CHANNEL_5G_56, '56 (5280 MHz)'), + (CHANNEL_5G_58, '58 (5290 MHz)'), + (CHANNEL_5G_60, '60 (5300 MHz)'), + (CHANNEL_5G_62, '62 (5310 MHz)'), + (CHANNEL_5G_64, '64 (5320 MHz)'), + (CHANNEL_5G_100, '100 (5500 MHz)'), + (CHANNEL_5G_102, '102 (5510 MHz)'), + (CHANNEL_5G_104, '104 (5520 MHz)'), + (CHANNEL_5G_106, '106 (5530 MHz)'), + (CHANNEL_5G_108, '108 (5540 MHz)'), + (CHANNEL_5G_110, '110 (5550 MHz)'), + (CHANNEL_5G_112, '112 (5560 MHz)'), + (CHANNEL_5G_114, '114 (5570 MHz)'), + (CHANNEL_5G_116, '116 (5580 MHz)'), + (CHANNEL_5G_118, '118 (5590 MHz)'), + (CHANNEL_5G_120, '120 (5600 MHz)'), + (CHANNEL_5G_122, '122 (5610 MHz)'), + (CHANNEL_5G_124, '124 (5620 MHz)'), + (CHANNEL_5G_126, '126 (5630 MHz)'), + (CHANNEL_5G_128, '128 (5640 MHz)'), + ) + ), + ) + + +class WirelessChannelWidthChoices(ChoiceSet): + + CHANNEL_WIDTH_20 = 20 + CHANNEL_WIDTH_40 = 40 + CHANNEL_WIDTH_80 = 80 + CHANNEL_WIDTH_160 = 160 + + CHOICES = ( + (CHANNEL_WIDTH_20, '20 MHz'), + (CHANNEL_WIDTH_40, '40 MHz'), + (CHANNEL_WIDTH_80, '80 MHz'), + (CHANNEL_WIDTH_160, '160 MHz'), + ) + + # # PowerFeeds # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 2a4d368f4..0d64b357b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -42,6 +42,7 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_80211N, InterfaceTypeChoices.TYPE_80211AC, InterfaceTypeChoices.TYPE_80211AD, + InterfaceTypeChoices.TYPE_80211AX, ] NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7f029097e..0c756957a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -990,7 +990,10 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT class Meta: model = Interface - fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] + fields = [ + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_channel', 'rf_channel_width', + 'description', + ] def filter_device(self, queryset, name, value): try: diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index fd87d7304..67a482a26 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -926,7 +926,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', + 'mode', 'rf_channel', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -977,8 +977,8 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'untagged_vlan', - 'tagged_vlans', + 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', + 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index ff9ab6fff..a2685c8e0 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -584,7 +584,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', + 'mtu', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4f4e10e96..605139c1b 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -963,6 +963,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], + ['rf_channel', 'rf_channel_width'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ] type = forms.MultipleChoiceField( @@ -990,6 +991,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + rf_channel = forms.MultipleChoiceField( + choices=WirelessChannelChoices, + required=False, + widget=StaticSelectMultiple() + ) + rf_channel_width = forms.MultipleChoiceField( + choices=WirelessChannelWidthChoices, + required=False, + widget=StaticSelectMultiple() + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a8c2991a4..435fab309 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1098,12 +1098,15 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', + 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', + 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'mode': StaticSelect(), + 'rf_channel': StaticSelect(), + 'rf_channel_width': StaticSelect(), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 7577ad355..db28412e6 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -465,7 +465,19 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): mode = forms.ChoiceField( choices=add_blank_choice(InterfaceModeChoices), required=False, + widget=StaticSelect() + ) + rf_channel = forms.ChoiceField( + choices=add_blank_choice(WirelessChannelChoices), + required=False, widget=StaticSelect(), + label='Wireless channel' + ) + rf_channel_width = forms.ChoiceField( + choices=add_blank_choice(WirelessChannelWidthChoices), + required=False, + widget=StaticSelect(), + label='Channel width' ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -477,7 +489,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' + 'description', 'mgmt_only', 'mark_connected', 'rf_channel', 'rf_channel_width', 'mode' 'untagged_vlan', + 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py new file mode 100644 index 000000000..429a72694 --- /dev/null +++ b/netbox/dcim/migrations/0136_wireless.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0135_location_tenant'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='rf_channel', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='rf_channel_width', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 386776b41..4e0d65f86 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -517,6 +517,18 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): verbose_name='WWN', help_text='64-bit World Wide Name' ) + rf_channel = models.CharField( + max_length=50, + choices=WirelessChannelChoices, + blank=True, + verbose_name='Wireless channel' + ) + rf_channel_width = models.PositiveSmallIntegerField( + choices=WirelessChannelWidthChoices, + blank=True, + null=True, + verbose_name='Channel width' + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -603,6 +615,12 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): if self.pk and self.lag_id == self.pk: raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) + # RF channel attributes may be set only for wireless interfaces + if self.rf_channel and self.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) + if self.rf_channel_width and self.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: raise ValidationError({ diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c2b4b907b..1eae62a05 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -493,8 +493,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', + 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', + 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index f9a9b0425..3283aac4f 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -39,6 +39,16 @@ Type {{ object.get_type_display }} + {% if object.is_wireless %} + + Channel + {{ object.get_rf_channel_display|placeholder }} + + + Channel Width + {{ object.get_rf_channel_width_display|placeholder }} + + {% endif %} Enabled diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 041eab73a..e91c74d31 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -29,6 +29,16 @@ {% render_field form.mark_connected %} + {% if form.instance.is_wireless %} +
+
+
Wireless
+
+ {% render_field form.rf_channel %} + {% render_field form.rf_channel_width %} +
+ {% endif %} +
802.1Q Switching
From 8b80b0c3df451a894c1286f7afdbb5cd940b8f61 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 12:27:12 -0400 Subject: [PATCH 005/289] Introduce the wireless app and SSID model --- netbox/netbox/api/views.py | 1 + netbox/netbox/graphql/schema.py | 2 + netbox/netbox/navigation_menu.py | 14 +++++++ netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 2 + netbox/templates/wireless/ssid.html | 46 ++++++++++++++++++++++ netbox/wireless/__init__.py | 0 netbox/wireless/api/__init__.py | 0 netbox/wireless/api/nested_serializers.py | 16 ++++++++ netbox/wireless/api/serializers.py | 21 ++++++++++ netbox/wireless/api/urls.py | 12 ++++++ netbox/wireless/api/views.py | 24 +++++++++++ netbox/wireless/apps.py | 5 +++ netbox/wireless/filtersets.py | 31 +++++++++++++++ netbox/wireless/forms/__init__.py | 4 ++ netbox/wireless/forms/bulk_edit.py | 29 ++++++++++++++ netbox/wireless/forms/bulk_import.py | 20 ++++++++++ netbox/wireless/forms/filtersets.py | 19 +++++++++ netbox/wireless/forms/models.py | 32 +++++++++++++++ netbox/wireless/graphql/__init__.py | 0 netbox/wireless/graphql/schema.py | 9 +++++ netbox/wireless/graphql/types.py | 14 +++++++ netbox/wireless/migrations/0001_initial.py | 36 +++++++++++++++++ netbox/wireless/migrations/__init__.py | 0 netbox/wireless/models.py | 40 +++++++++++++++++++ netbox/wireless/tables.py | 24 +++++++++++ netbox/wireless/urls.py | 22 +++++++++++ netbox/wireless/views.py | 46 ++++++++++++++++++++++ 28 files changed, 470 insertions(+) create mode 100644 netbox/templates/wireless/ssid.html create mode 100644 netbox/wireless/__init__.py create mode 100644 netbox/wireless/api/__init__.py create mode 100644 netbox/wireless/api/nested_serializers.py create mode 100644 netbox/wireless/api/serializers.py create mode 100644 netbox/wireless/api/urls.py create mode 100644 netbox/wireless/api/views.py create mode 100644 netbox/wireless/apps.py create mode 100644 netbox/wireless/filtersets.py create mode 100644 netbox/wireless/forms/__init__.py create mode 100644 netbox/wireless/forms/bulk_edit.py create mode 100644 netbox/wireless/forms/bulk_import.py create mode 100644 netbox/wireless/forms/filtersets.py create mode 100644 netbox/wireless/forms/models.py create mode 100644 netbox/wireless/graphql/__init__.py create mode 100644 netbox/wireless/graphql/schema.py create mode 100644 netbox/wireless/graphql/types.py create mode 100644 netbox/wireless/migrations/0001_initial.py create mode 100644 netbox/wireless/migrations/__init__.py create mode 100644 netbox/wireless/models.py create mode 100644 netbox/wireless/tables.py create mode 100644 netbox/wireless/urls.py create mode 100644 netbox/wireless/views.py diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 74000e978..7ad64aeae 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -308,6 +308,7 @@ class APIRootView(APIView): ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), ('users', reverse('users-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), + ('wireless', reverse('wireless-api:api-root', request=request, format=format)), ))) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index bb752b8c4..812c1656d 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -7,6 +7,7 @@ from ipam.graphql.schema import IPAMQuery from tenancy.graphql.schema import TenancyQuery from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery +from wireless.graphql.schema import WirelessQuery class Query( @@ -17,6 +18,7 @@ class Query( TenancyQuery, UsersQuery, VirtualizationQuery, + WirelessQuery, graphene.ObjectType ): pass diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a3978f16e..0a78f35ab 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -188,6 +188,19 @@ CONNECTIONS_MENU = Menu( ), ) +WIRELESS_MENU = Menu( + label='Wireless', + icon_class='mdi mdi-wifi', + groups=( + MenuGroup( + label='Wireless', + items=( + get_model_item('wireless', 'ssid', 'SSIDs'), + ), + ), + ), +) + IPAM_MENU = Menu( label='IPAM', icon_class='mdi mdi-counter', @@ -343,6 +356,7 @@ MENUS = [ ORGANIZATION_MENU, DEVICES_MENU, CONNECTIONS_MENU, + WIRELESS_MENU, IPAM_MENU, VIRTUALIZATION_MENU, CIRCUITS_MENU, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3df9a855a..e41c77d1d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -326,6 +326,7 @@ INSTALLED_APPS = [ 'users', 'utilities', 'virtualization', + 'wireless', 'django_rq', # Must come after extras to allow overriding management commands 'drf_yasg', ] diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 3d4c60c93..4e0a2e2c6 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -48,6 +48,7 @@ _patterns = [ path('tenancy/', include('tenancy.urls')), path('user/', include('users.urls')), path('virtualization/', include('virtualization.urls')), + path('wireless/', include('wireless.urls')), # API path('api/', APIRootView.as_view(), name='api-root'), @@ -58,6 +59,7 @@ _patterns = [ path('api/tenancy/', include('tenancy.api.urls')), path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), + path('api/wireless/', include('wireless.api.urls')), path('api/status/', StatusView.as_view(), name='api-status'), path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'), path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'), diff --git a/netbox/templates/wireless/ssid.html b/netbox/templates/wireless/ssid.html new file mode 100644 index 000000000..5425149aa --- /dev/null +++ b/netbox/templates/wireless/ssid.html @@ -0,0 +1,46 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
SSID
+
+ + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
VLAN + {% if object.vlan %} + {{ object.vlan }} + {% else %} + None + {% endif %} +
+
+
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/wireless/__init__.py b/netbox/wireless/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/api/__init__.py b/netbox/wireless/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py new file mode 100644 index 000000000..50454a641 --- /dev/null +++ b/netbox/wireless/api/nested_serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from netbox.api import WritableNestedSerializer +from wireless.models import * + +__all__ = ( + 'NestedSSIDSerializer', +) + + +class NestedSSIDSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + + class Meta: + model = SSID + fields = ['id', 'url', 'display', 'name'] diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py new file mode 100644 index 000000000..c129e5c96 --- /dev/null +++ b/netbox/wireless/api/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from dcim.api.serializers import NestedInterfaceSerializer +from ipam.api.serializers import NestedVLANSerializer +from netbox.api.serializers import PrimaryModelSerializer +from wireless.models import * + +__all__ = ( + 'SSIDSerializer', +) + + +class SSIDSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + vlan = NestedVLANSerializer(required=False, allow_null=True) + + class Meta: + model = SSID + fields = [ + 'id', 'url', 'display', 'name', 'description', 'vlan', + ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py new file mode 100644 index 000000000..f6936708c --- /dev/null +++ b/netbox/wireless/api/urls.py @@ -0,0 +1,12 @@ +from netbox.api import OrderedDefaultRouter +from . import views + + +router = OrderedDefaultRouter() +router.APIRootView = views.WirelessRootView + +# SSIDs +router.register('ssids', views.SSIDViewSet) + +app_name = 'wireless-api' +urlpatterns = router.urls diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py new file mode 100644 index 000000000..97827eb7e --- /dev/null +++ b/netbox/wireless/api/views.py @@ -0,0 +1,24 @@ +from rest_framework.routers import APIRootView + +from extras.api.views import CustomFieldModelViewSet +from wireless import filtersets +from wireless.models import * +from . import serializers + + +class WirelessRootView(APIRootView): + """ + Wireless API root view + """ + def get_view_name(self): + return 'Wireless' + + +# +# Providers +# + +class SSIDViewSet(CustomFieldModelViewSet): + queryset = SSID.objects.prefetch_related('tags') + serializer_class = serializers.SSIDSerializer + filterset_class = filtersets.SSIDFilterSet diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py new file mode 100644 index 000000000..1f6deff22 --- /dev/null +++ b/netbox/wireless/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WirelessConfig(AppConfig): + name = 'wireless' diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py new file mode 100644 index 000000000..232bc74ff --- /dev/null +++ b/netbox/wireless/filtersets.py @@ -0,0 +1,31 @@ +import django_filters +from django.db.models import Q + +from extras.filters import TagFilter +from netbox.filtersets import PrimaryModelFilterSet +from .models import * + +__all__ = ( + 'SSIDFilterSet', +) + + +class SSIDFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + class Meta: + model = SSID + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) diff --git a/netbox/wireless/forms/__init__.py b/netbox/wireless/forms/__init__.py new file mode 100644 index 000000000..62c2ec2d9 --- /dev/null +++ b/netbox/wireless/forms/__init__.py @@ -0,0 +1,4 @@ +from .models import * +from .filtersets import * +from .bulk_edit import * +from .bulk_import import * diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py new file mode 100644 index 000000000..ed9fb650b --- /dev/null +++ b/netbox/wireless/forms/bulk_edit.py @@ -0,0 +1,29 @@ +from django import forms + +from dcim.models import * +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.models import VLAN +from utilities.forms import BootstrapMixin, DynamicModelChoiceField + +__all__ = ( + 'SSIDBulkEditForm', +) + + +class SSIDBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerFeed.objects.all(), + widget=forms.MultipleHiddenInput + ) + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = [ + 'vlan', 'description', + ] diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py new file mode 100644 index 000000000..0cf997fd3 --- /dev/null +++ b/netbox/wireless/forms/bulk_import.py @@ -0,0 +1,20 @@ +from extras.forms import CustomFieldModelCSVForm +from ipam.models import VLAN +from utilities.forms import CSVModelChoiceField +from wireless.models import SSID + +__all__ = ( + 'SSIDCSVForm', +) + + +class SSIDCSVForm(CustomFieldModelCSVForm): + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + to_field_name='name', + help_text='Bridged VLAN' + ) + + class Meta: + model = SSID + fields = ('name', 'description', 'vlan') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py new file mode 100644 index 000000000..733b807f7 --- /dev/null +++ b/netbox/wireless/forms/filtersets.py @@ -0,0 +1,19 @@ +from django import forms +from django.utils.translation import gettext as _ + +from dcim.models import * +from extras.forms import CustomFieldModelFilterForm +from utilities.forms import BootstrapMixin, TagFilterField + + +class SSIDFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = PowerFeed + field_groups = [ + ['q', 'tag'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py new file mode 100644 index 000000000..ea6d51223 --- /dev/null +++ b/netbox/wireless/forms/models.py @@ -0,0 +1,32 @@ +from dcim.constants import * +from dcim.models import * +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from ipam.models import VLAN +from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from wireless.models import SSID + +__all__ = ( + 'SSIDForm', +) + + +class SSIDForm(BootstrapMixin, CustomFieldModelForm): + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = SSID + fields = [ + 'name', 'description', 'vlan', 'tags', + ] + fieldsets = ( + ('SSID', ('name', 'description', 'tags')), + ('VLAN', ('vlan',)), + ) diff --git a/netbox/wireless/graphql/__init__.py b/netbox/wireless/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py new file mode 100644 index 000000000..d0beec7d9 --- /dev/null +++ b/netbox/wireless/graphql/schema.py @@ -0,0 +1,9 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class WirelessQuery(graphene.ObjectType): + ssid = ObjectField(SSIDType) + ssid_list = ObjectListField(SSIDType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py new file mode 100644 index 000000000..66e73429d --- /dev/null +++ b/netbox/wireless/graphql/types.py @@ -0,0 +1,14 @@ +from wireless import filtersets, models +from netbox.graphql.types import ObjectType + +__all__ = ( + 'SSIDType', +) + + +class SSIDType(ObjectType): + + class Meta: + model = models.SSID + fields = '__all__' + filterset_class = filtersets.SSIDFilterSet diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py new file mode 100644 index 000000000..b0011dad9 --- /dev/null +++ b/netbox/wireless/migrations/0001_initial.py @@ -0,0 +1,36 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0136_wireless'), + ('extras', '0062_clear_secrets_changelog'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.CreateModel( + name='SSID', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=32)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), + ], + options={ + 'verbose_name': 'SSID', + 'verbose_name_plural': 'SSIDs', + 'ordering': ('name', 'pk'), + }, + ), + ] diff --git a/netbox/wireless/migrations/__init__.py b/netbox/wireless/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py new file mode 100644 index 000000000..5bb964345 --- /dev/null +++ b/netbox/wireless/models.py @@ -0,0 +1,40 @@ +from django.db import models + +from extras.utils import extras_features +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'SSID', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class SSID(PrimaryModel): + """ + A service set identifier belonging to a wireless network. + """ + name = models.CharField( + max_length=32 + ) + vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name='VLAN' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('name', 'pk') + verbose_name = 'SSID' + verbose_name_plural = 'SSIDs' + + def __str__(self): + return self.name diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py new file mode 100644 index 000000000..846296bb4 --- /dev/null +++ b/netbox/wireless/tables.py @@ -0,0 +1,24 @@ +import django_tables2 as tables + +from .models import SSID +from utilities.tables import BaseTable, TagColumn, ToggleColumn + +__all__ = ( + 'SSIDTable', +) + + +class SSIDTable(BaseTable): + pk = ToggleColumn() + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + tags = TagColumn( + url_name='dcim:cable_list' + ) + + class Meta(BaseTable.Meta): + model = SSID + fields = ('pk', 'id', 'name', 'description', 'vlan') + default_columns = ('pk', 'name', 'description', 'vlan') diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py new file mode 100644 index 000000000..57e0eab9b --- /dev/null +++ b/netbox/wireless/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from extras.views import ObjectChangeLogView, ObjectJournalView +from . import views +from .models import * + +app_name = 'wireless' +urlpatterns = ( + + # SSIDs + path('ssids/', views.SSIDListView.as_view(), name='ssid_list'), + path('ssids/add/', views.SSIDEditView.as_view(), name='ssid_add'), + path('ssids/import/', views.SSIDBulkImportView.as_view(), name='ssid_import'), + path('ssids/edit/', views.SSIDBulkEditView.as_view(), name='ssid_bulk_edit'), + path('ssids/delete/', views.SSIDBulkDeleteView.as_view(), name='ssid_bulk_delete'), + path('ssids//', views.SSIDView.as_view(), name='ssid'), + path('ssids//edit/', views.SSIDEditView.as_view(), name='ssid_edit'), + path('ssids//delete/', views.SSIDDeleteView.as_view(), name='ssid_delete'), + path('ssids//changelog/', ObjectChangeLogView.as_view(), name='ssid_changelog', kwargs={'model': SSID}), + path('ssids//journal/', ObjectJournalView.as_view(), name='ssid_journal', kwargs={'model': SSID}), + +) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py new file mode 100644 index 000000000..b0d1f5156 --- /dev/null +++ b/netbox/wireless/views.py @@ -0,0 +1,46 @@ +from netbox.views import generic +from . import filtersets, forms, tables +from .models import * + + +# +# SSIDs +# + +class SSIDListView(generic.ObjectListView): + queryset = SSID.objects.all() + filterset = filtersets.SSIDFilterSet + filterset_form = forms.SSIDFilterForm + table = tables.SSIDTable + + +class SSIDView(generic.ObjectView): + queryset = SSID.objects.prefetch_related('power_panel', 'rack') + + +class SSIDEditView(generic.ObjectEditView): + queryset = SSID.objects.all() + model_form = forms.SSIDForm + + +class SSIDDeleteView(generic.ObjectDeleteView): + queryset = SSID.objects.all() + + +class SSIDBulkImportView(generic.BulkImportView): + queryset = SSID.objects.all() + model_form = forms.SSIDCSVForm + table = tables.SSIDTable + + +class SSIDBulkEditView(generic.BulkEditView): + queryset = SSID.objects.prefetch_related('power_panel', 'rack') + filterset = filtersets.SSIDFilterSet + table = tables.SSIDTable + form = forms.SSIDBulkEditForm + + +class SSIDBulkDeleteView(generic.BulkDeleteView): + queryset = SSID.objects.prefetch_related('power_panel', 'rack') + filterset = filtersets.SSIDFilterSet + table = tables.SSIDTable From 38f6d22d2d7ac412efa9b50ef4f598d17726e0a9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 13:48:06 -0400 Subject: [PATCH 006/289] Enable attachment of wireless interfaces to SSIDs --- netbox/dcim/forms/models.py | 10 ++++++-- netbox/dcim/migrations/0136_wireless.py | 6 +++++ netbox/dcim/models/device_components.py | 6 +++++ netbox/templates/dcim/interface.html | 27 ++++++++++++++++++++++ netbox/templates/dcim/interface_edit.html | 1 + netbox/wireless/migrations/0001_initial.py | 1 - netbox/wireless/models.py | 10 +++++++- netbox/wireless/tables.py | 7 +++--- netbox/wireless/views.py | 6 ++--- 9 files changed, 63 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 435fab309..2a6dc1f6f 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -16,6 +16,7 @@ from utilities.forms import ( SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup +from wireless.models import SSID from .common import InterfaceCommonForm __all__ = ( @@ -1068,6 +1069,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'type': 'lag', } ) + ssids = DynamicModelMultipleChoiceField( + queryset=SSID.objects.all(), + required=False, + label='SSIDs' + ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -1098,8 +1104,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', - 'tags', + 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'ssids', 'untagged_vlan', + 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 429a72694..0a1d15365 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -4,6 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('wireless', '__first__'), ('dcim', '0135_location_tenant'), ] @@ -18,4 +19,9 @@ class Migration(migrations.Migration): name='rf_channel_width', field=models.PositiveSmallIntegerField(blank=True, null=True), ), + migrations.AddField( + model_name='interface', + name='ssids', + field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.SSID'), + ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4e0d65f86..5e3e7e6db 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -529,6 +529,12 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): null=True, verbose_name='Channel width' ) + ssids = models.ManyToManyField( + to='wireless.SSID', + related_name='interfaces', + blank=True, + verbose_name='SSIDs' + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3283aac4f..bc4fc23e2 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -258,6 +258,33 @@
{% endif %} + {% if object.is_wireless %} +
+
SSIDs
+
+ + + + + + + + {% for ssid in object.ssids.all %} + + + + {% empty %} + + + + {% endfor %} + +
Name
+ {{ ssid.name }} +
None
+
+
+ {% endif %} {% if object.is_lag %}
LAG Members
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index e91c74d31..9fc752432 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -36,6 +36,7 @@
{% render_field form.rf_channel %} {% render_field form.rf_channel_width %} + {% render_field form.ssids %} {% endif %} diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py index b0011dad9..78d1dfc73 100644 --- a/netbox/wireless/migrations/0001_initial.py +++ b/netbox/wireless/migrations/0001_initial.py @@ -9,7 +9,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0136_wireless'), ('extras', '0062_clear_secrets_changelog'), ('ipam', '0050_iprange'), ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 5bb964345..2bdcecd79 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,7 +1,8 @@ from django.db import models +from django.urls import reverse from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -9,6 +10,10 @@ __all__ = ( ) +# +# SSIDs +# + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SSID(PrimaryModel): """ @@ -38,3 +43,6 @@ class SSID(PrimaryModel): def __str__(self): return self.name + + def get_absolute_url(self): + return reverse('wireless:ssid', args=[self.pk]) diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 846296bb4..9d3705549 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -10,9 +10,8 @@ __all__ = ( class SSIDTable(BaseTable): pk = ToggleColumn() - id = tables.Column( - linkify=True, - verbose_name='ID' + name = tables.Column( + linkify=True ) tags = TagColumn( url_name='dcim:cable_list' @@ -20,5 +19,5 @@ class SSIDTable(BaseTable): class Meta(BaseTable.Meta): model = SSID - fields = ('pk', 'id', 'name', 'description', 'vlan') + fields = ('pk', 'name', 'description', 'vlan') default_columns = ('pk', 'name', 'description', 'vlan') diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index b0d1f5156..b741330b7 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -15,7 +15,7 @@ class SSIDListView(generic.ObjectListView): class SSIDView(generic.ObjectView): - queryset = SSID.objects.prefetch_related('power_panel', 'rack') + queryset = SSID.objects.all() class SSIDEditView(generic.ObjectEditView): @@ -34,13 +34,13 @@ class SSIDBulkImportView(generic.BulkImportView): class SSIDBulkEditView(generic.BulkEditView): - queryset = SSID.objects.prefetch_related('power_panel', 'rack') + queryset = SSID.objects.all() filterset = filtersets.SSIDFilterSet table = tables.SSIDTable form = forms.SSIDBulkEditForm class SSIDBulkDeleteView(generic.BulkDeleteView): - queryset = SSID.objects.prefetch_related('power_panel', 'rack') + queryset = SSID.objects.all() filterset = filtersets.SSIDFilterSet table = tables.SSIDTable From 5271680483709114760dc0695e3d1dbbb6f45186 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 17:02:53 -0400 Subject: [PATCH 007/289] Rename SSID model to WirelessLAN --- netbox/dcim/forms/models.py | 10 ++-- netbox/dcim/migrations/0136_wireless.py | 6 +-- netbox/dcim/models/device_components.py | 6 +-- netbox/netbox/navigation_menu.py | 2 +- netbox/templates/dcim/interface.html | 8 +-- netbox/templates/dcim/interface_edit.html | 2 +- .../wireless/{ssid.html => wirelesslan.html} | 6 +-- netbox/wireless/api/nested_serializers.py | 8 +-- netbox/wireless/api/serializers.py | 9 ++-- netbox/wireless/api/urls.py | 3 +- netbox/wireless/api/views.py | 8 +-- netbox/wireless/constants.py | 1 + netbox/wireless/filtersets.py | 10 ++-- netbox/wireless/forms/bulk_edit.py | 4 +- netbox/wireless/forms/bulk_import.py | 10 ++-- netbox/wireless/forms/filtersets.py | 6 +-- netbox/wireless/forms/models.py | 14 +++-- netbox/wireless/graphql/schema.py | 4 +- netbox/wireless/graphql/types.py | 8 +-- netbox/wireless/migrations/0001_initial.py | 11 ++-- netbox/wireless/models.py | 19 +++---- netbox/wireless/tables.py | 16 +++--- netbox/wireless/urls.py | 22 ++++---- netbox/wireless/views.py | 52 +++++++++---------- 24 files changed, 119 insertions(+), 126 deletions(-) rename netbox/templates/wireless/{ssid.html => wirelesslan.html} (90%) create mode 100644 netbox/wireless/constants.py diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2a6dc1f6f..cd697e9f3 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -16,7 +16,7 @@ from utilities.forms import ( SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup -from wireless.models import SSID +from wireless.models import WirelessLAN from .common import InterfaceCommonForm __all__ = ( @@ -1069,10 +1069,10 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'type': 'lag', } ) - ssids = DynamicModelMultipleChoiceField( - queryset=SSID.objects.all(), + wireless_lans = DynamicModelMultipleChoiceField( + queryset=WirelessLAN.objects.all(), required=False, - label='SSIDs' + label='Wireless LANs' ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -1104,7 +1104,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'ssids', 'untagged_vlan', + 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 0a1d15365..108e63802 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('wireless', '__first__'), + ('wireless', '0001_initial'), ('dcim', '0135_location_tenant'), ] @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='interface', - name='ssids', - field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.SSID'), + name='wireless_lans', + field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5e3e7e6db..60eb4c368 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -529,11 +529,11 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): null=True, verbose_name='Channel width' ) - ssids = models.ManyToManyField( - to='wireless.SSID', + wireless_lans = models.ManyToManyField( + to='wireless.WirelessLAN', related_name='interfaces', blank=True, - verbose_name='SSIDs' + verbose_name='Wireless LANs' ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 0a78f35ab..073189d31 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -195,7 +195,7 @@ WIRELESS_MENU = Menu( MenuGroup( label='Wireless', items=( - get_model_item('wireless', 'ssid', 'SSIDs'), + get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), ), ), ), diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bc4fc23e2..33eaa95db 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -260,19 +260,19 @@ {% endif %} {% if object.is_wireless %}
-
SSIDs
+
Wireless LANs
- + - {% for ssid in object.ssids.all %} + {% for wlan in object.wlans.all %} {% empty %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 9fc752432..51834f4e2 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -36,7 +36,7 @@ {% render_field form.rf_channel %} {% render_field form.rf_channel_width %} - {% render_field form.ssids %} + {% render_field form.wireless_lans %} {% endif %} diff --git a/netbox/templates/wireless/ssid.html b/netbox/templates/wireless/wirelesslan.html similarity index 90% rename from netbox/templates/wireless/ssid.html rename to netbox/templates/wireless/wirelesslan.html index 5425149aa..98bde8688 100644 --- a/netbox/templates/wireless/ssid.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -6,12 +6,12 @@
-
SSID
+
Wireless LAN
NameSSID
- {{ ssid.name }} + {{ wlan.ssid }}
- - + + diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index 50454a641..e290653a2 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -4,13 +4,13 @@ from netbox.api import WritableNestedSerializer from wireless.models import * __all__ = ( - 'NestedSSIDSerializer', + 'NestedWirelessLANSerializer', ) -class NestedSSIDSerializer(WritableNestedSerializer): +class NestedWirelessLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') class Meta: - model = SSID - fields = ['id', 'url', 'display', 'name'] + model = WirelessLAN + fields = ['id', 'url', 'display', 'ssid'] diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index c129e5c96..08642259f 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -1,21 +1,20 @@ from rest_framework import serializers -from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api.serializers import PrimaryModelSerializer from wireless.models import * __all__ = ( - 'SSIDSerializer', + 'WirelessLANSerializer', ) -class SSIDSerializer(PrimaryModelSerializer): +class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) class Meta: - model = SSID + model = WirelessLAN fields = [ - 'id', 'url', 'display', 'name', 'description', 'vlan', + 'id', 'url', 'display', 'ssid', 'description', 'vlan', ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index f6936708c..638f31bbf 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -5,8 +5,7 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView -# SSIDs -router.register('ssids', views.SSIDViewSet) +router.register('wireless-lans', views.WirelessLANViewSet) app_name = 'wireless-api' urlpatterns = router.urls diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 97827eb7e..a09b2e23d 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -18,7 +18,7 @@ class WirelessRootView(APIRootView): # Providers # -class SSIDViewSet(CustomFieldModelViewSet): - queryset = SSID.objects.prefetch_related('tags') - serializer_class = serializers.SSIDSerializer - filterset_class = filtersets.SSIDFilterSet +class WirelessLANViewSet(CustomFieldModelViewSet): + queryset = WirelessLAN.objects.prefetch_related('tags') + serializer_class = serializers.WirelessLANSerializer + filterset_class = filtersets.WirelessLANFilterSet diff --git a/netbox/wireless/constants.py b/netbox/wireless/constants.py new file mode 100644 index 000000000..188c4abd9 --- /dev/null +++ b/netbox/wireless/constants.py @@ -0,0 +1 @@ +SSID_MAX_LENGTH = 32 # Per IEEE 802.11-2007 diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 232bc74ff..c148354a0 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -6,11 +6,11 @@ from netbox.filtersets import PrimaryModelFilterSet from .models import * __all__ = ( - 'SSIDFilterSet', + 'WirelessLANFilterSet', ) -class SSIDFilterSet(PrimaryModelFilterSet): +class WirelessLANFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -18,14 +18,14 @@ class SSIDFilterSet(PrimaryModelFilterSet): tag = TagFilter() class Meta: - model = SSID - fields = ['id', 'name'] + model = WirelessLAN + fields = ['id', 'ssid'] def search(self, queryset, name, value): if not value.strip(): return queryset qs_filter = ( - Q(name__icontains=value) | + Q(ssid__icontains=value) | Q(description__icontains=value) ) return queryset.filter(qs_filter) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index ed9fb650b..c11a16239 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -6,11 +6,11 @@ from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField __all__ = ( - 'SSIDBulkEditForm', + 'WirelessLANBulkEditForm', ) -class SSIDBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 0cf997fd3..5dc07f91a 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,14 +1,14 @@ from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN from utilities.forms import CSVModelChoiceField -from wireless.models import SSID +from wireless.models import WirelessLAN __all__ = ( - 'SSIDCSVForm', + 'WirelessLANCSVForm', ) -class SSIDCSVForm(CustomFieldModelCSVForm): +class WirelessLANCSVForm(CustomFieldModelCSVForm): vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), to_field_name='name', @@ -16,5 +16,5 @@ class SSIDCSVForm(CustomFieldModelCSVForm): ) class Meta: - model = SSID - fields = ('name', 'description', 'vlan') + model = WirelessLAN + fields = ('ssid', 'description', 'vlan') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 733b807f7..99e38918e 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -1,13 +1,13 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import * from extras.forms import CustomFieldModelFilterForm from utilities.forms import BootstrapMixin, TagFilterField +from .models import WirelessLAN -class SSIDFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = PowerFeed +class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLAN field_groups = [ ['q', 'tag'], ] diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index ea6d51223..95b43c7d3 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,17 +1,15 @@ -from dcim.constants import * -from dcim.models import * from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from wireless.models import SSID +from wireless.models import WirelessLAN __all__ = ( - 'SSIDForm', + 'WirelessLANForm', ) -class SSIDForm(BootstrapMixin, CustomFieldModelForm): +class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False @@ -22,11 +20,11 @@ class SSIDForm(BootstrapMixin, CustomFieldModelForm): ) class Meta: - model = SSID + model = WirelessLAN fields = [ - 'name', 'description', 'vlan', 'tags', + 'ssid', 'description', 'vlan', 'tags', ] fieldsets = ( - ('SSID', ('name', 'description', 'tags')), + ('Wireless LAN', ('ssid', 'description', 'tags')), ('VLAN', ('vlan',)), ) diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index d0beec7d9..8297f4545 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -5,5 +5,5 @@ from .types import * class WirelessQuery(graphene.ObjectType): - ssid = ObjectField(SSIDType) - ssid_list = ObjectListField(SSIDType) + wirelesslan = ObjectField(WirelessLANType) + wirelesslan_list = ObjectListField(WirelessLANType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 66e73429d..4cdb75ebe 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -2,13 +2,13 @@ from wireless import filtersets, models from netbox.graphql.types import ObjectType __all__ = ( - 'SSIDType', + 'WirelessLANType', ) -class SSIDType(ObjectType): +class WirelessLANType(ObjectType): class Meta: - model = models.SSID + model = models.WirelessLAN fields = '__all__' - filterset_class = filtersets.SSIDFilterSet + filterset_class = filtersets.WirelessLANFilterSet diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py index 78d1dfc73..c93a17190 100644 --- a/netbox/wireless/migrations/0001_initial.py +++ b/netbox/wireless/migrations/0001_initial.py @@ -9,27 +9,26 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('extras', '0062_clear_secrets_changelog'), ('ipam', '0050_iprange'), + ('extras', '0062_clear_secrets_changelog'), ] operations = [ migrations.CreateModel( - name='SSID', + name='WirelessLAN', fields=[ ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=32)), + ('ssid', models.CharField(max_length=32)), ('description', models.CharField(blank=True, max_length=200)), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), ], options={ - 'verbose_name': 'SSID', - 'verbose_name_plural': 'SSIDs', - 'ordering': ('name', 'pk'), + 'verbose_name': 'Wireless LAN', + 'ordering': ('ssid', 'pk'), }, ), ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 2bdcecd79..363631ef5 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,25 +1,23 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet __all__ = ( - 'SSID', + 'WirelessLAN', ) -# -# SSIDs -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class SSID(PrimaryModel): +class WirelessLAN(PrimaryModel): """ A service set identifier belonging to a wireless network. """ - name = models.CharField( + ssid = models.CharField( max_length=32 ) vlan = models.ForeignKey( @@ -37,12 +35,11 @@ class SSID(PrimaryModel): objects = RestrictedQuerySet.as_manager() class Meta: - ordering = ('name', 'pk') - verbose_name = 'SSID' - verbose_name_plural = 'SSIDs' + ordering = ('ssid', 'pk') + verbose_name = 'Wireless LAN' def __str__(self): - return self.name + return self.ssid def get_absolute_url(self): return reverse('wireless:ssid', args=[self.pk]) diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 9d3705549..133353f57 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,23 +1,23 @@ import django_tables2 as tables -from .models import SSID +from .models import WirelessLAN from utilities.tables import BaseTable, TagColumn, ToggleColumn __all__ = ( - 'SSIDTable', + 'WirelessLANTable', ) -class SSIDTable(BaseTable): +class WirelessLANTable(BaseTable): pk = ToggleColumn() - name = tables.Column( + ssid = tables.Column( linkify=True ) tags = TagColumn( - url_name='dcim:cable_list' + url_name='wireless:wirelesslan_list' ) class Meta(BaseTable.Meta): - model = SSID - fields = ('pk', 'name', 'description', 'vlan') - default_columns = ('pk', 'name', 'description', 'vlan') + model = WirelessLAN + fields = ('pk', 'ssid', 'description', 'vlan') + default_columns = ('pk', 'ssid', 'description', 'vlan') diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index 57e0eab9b..c30ca472c 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -7,16 +7,16 @@ from .models import * app_name = 'wireless' urlpatterns = ( - # SSIDs - path('ssids/', views.SSIDListView.as_view(), name='ssid_list'), - path('ssids/add/', views.SSIDEditView.as_view(), name='ssid_add'), - path('ssids/import/', views.SSIDBulkImportView.as_view(), name='ssid_import'), - path('ssids/edit/', views.SSIDBulkEditView.as_view(), name='ssid_bulk_edit'), - path('ssids/delete/', views.SSIDBulkDeleteView.as_view(), name='ssid_bulk_delete'), - path('ssids//', views.SSIDView.as_view(), name='ssid'), - path('ssids//edit/', views.SSIDEditView.as_view(), name='ssid_edit'), - path('ssids//delete/', views.SSIDDeleteView.as_view(), name='ssid_delete'), - path('ssids//changelog/', ObjectChangeLogView.as_view(), name='ssid_changelog', kwargs={'model': SSID}), - path('ssids//journal/', ObjectJournalView.as_view(), name='ssid_journal', kwargs={'model': SSID}), + # Wireless LANs + path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), + path('wireless-lans/add/', views.WirelessLANEditView.as_view(), name='wirelesslan_add'), + path('wireless-lans/import/', views.WirelessLANBulkImportView.as_view(), name='wirelesslan_import'), + path('wireless-lans/edit/', views.WirelessLANBulkEditView.as_view(), name='wirelesslan_bulk_edit'), + path('wireless-lans/delete/', views.WirelessLANBulkDeleteView.as_view(), name='wirelesslan_bulk_delete'), + path('wireless-lans//', views.WirelessLANView.as_view(), name='wirelesslan'), + path('wireless-lans//edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'), + path('wireless-lans//delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'), + path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), + path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), ) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index b741330b7..6e1c0b1b7 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -4,43 +4,43 @@ from .models import * # -# SSIDs +# Wireless LANs # -class SSIDListView(generic.ObjectListView): - queryset = SSID.objects.all() - filterset = filtersets.SSIDFilterSet - filterset_form = forms.SSIDFilterForm - table = tables.SSIDTable +class WirelessLANListView(generic.ObjectListView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + filterset_form = forms.WirelessLANFilterForm + table = tables.WirelessLANTable -class SSIDView(generic.ObjectView): - queryset = SSID.objects.all() +class WirelessLANView(generic.ObjectView): + queryset = WirelessLAN.objects.all() -class SSIDEditView(generic.ObjectEditView): - queryset = SSID.objects.all() - model_form = forms.SSIDForm +class WirelessLANEditView(generic.ObjectEditView): + queryset = WirelessLAN.objects.all() + model_form = forms.WirelessLANForm -class SSIDDeleteView(generic.ObjectDeleteView): - queryset = SSID.objects.all() +class WirelessLANDeleteView(generic.ObjectDeleteView): + queryset = WirelessLAN.objects.all() -class SSIDBulkImportView(generic.BulkImportView): - queryset = SSID.objects.all() - model_form = forms.SSIDCSVForm - table = tables.SSIDTable +class WirelessLANBulkImportView(generic.BulkImportView): + queryset = WirelessLAN.objects.all() + model_form = forms.WirelessLANCSVForm + table = tables.WirelessLANTable -class SSIDBulkEditView(generic.BulkEditView): - queryset = SSID.objects.all() - filterset = filtersets.SSIDFilterSet - table = tables.SSIDTable - form = forms.SSIDBulkEditForm +class WirelessLANBulkEditView(generic.BulkEditView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + table = tables.WirelessLANTable + form = forms.WirelessLANBulkEditForm -class SSIDBulkDeleteView(generic.BulkDeleteView): - queryset = SSID.objects.all() - filterset = filtersets.SSIDFilterSet - table = tables.SSIDTable +class WirelessLANBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + table = tables.WirelessLANTable From 90e9f344944ef192b4b3ebc56a895d93e2995440 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 09:46:17 -0400 Subject: [PATCH 008/289] Add WirelessLink model --- netbox/dcim/migrations/0136_wireless.py | 8 +- netbox/dcim/migrations/0137_wireless.py | 19 +++++ netbox/netbox/navigation_menu.py | 1 + .../wireless/inc/wirelesslink_interface.html | 20 +++++ netbox/templates/wireless/wirelesslan.html | 2 +- netbox/templates/wireless/wirelesslink.html | 48 +++++++++++ netbox/wireless/api/nested_serializers.py | 11 ++- netbox/wireless/api/serializers.py | 17 +++- netbox/wireless/api/urls.py | 1 + netbox/wireless/api/views.py | 12 +-- netbox/wireless/filtersets.py | 22 +++++ netbox/wireless/forms/bulk_edit.py | 27 +++++- netbox/wireless/forms/bulk_import.py | 17 +++- netbox/wireless/forms/filtersets.py | 28 ++++++- netbox/wireless/forms/models.py | 29 ++++++- netbox/wireless/migrations/0001_initial.py | 34 -------- netbox/wireless/migrations/0001_wireless.py | 57 +++++++++++++ netbox/wireless/models.py | 83 ++++++++++++++++++- netbox/wireless/tables.py | 25 +++++- netbox/wireless/urls.py | 12 +++ netbox/wireless/views.py | 43 ++++++++++ 21 files changed, 458 insertions(+), 58 deletions(-) create mode 100644 netbox/dcim/migrations/0137_wireless.py create mode 100644 netbox/templates/wireless/inc/wirelesslink_interface.html create mode 100644 netbox/templates/wireless/wirelesslink.html delete mode 100644 netbox/wireless/migrations/0001_initial.py create mode 100644 netbox/wireless/migrations/0001_wireless.py diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 108e63802..3b33f7d3f 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -1,10 +1,11 @@ +# Generated by Django 3.2.8 on 2021-10-13 13:44 + from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('wireless', '0001_initial'), ('dcim', '0135_location_tenant'), ] @@ -19,9 +20,4 @@ class Migration(migrations.Migration): name='rf_channel_width', field=models.PositiveSmallIntegerField(blank=True, null=True), ), - migrations.AddField( - model_name='interface', - name='wireless_lans', - field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), - ), ] diff --git a/netbox/dcim/migrations/0137_wireless.py b/netbox/dcim/migrations/0137_wireless.py new file mode 100644 index 000000000..9108735a1 --- /dev/null +++ b/netbox/dcim/migrations/0137_wireless.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-13 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0136_wireless'), + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wireless_lans', + field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), + ), + ] diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 073189d31..b3e11f6ce 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -196,6 +196,7 @@ WIRELESS_MENU = Menu( label='Wireless', items=( get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), + get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), ), ), ), diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html new file mode 100644 index 000000000..9c4669ad1 --- /dev/null +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -0,0 +1,20 @@ +
Name{{ object.name }}SSID{{ object.ssid }}
Description
+ + + + + + + + + + + + +
Device + {{ interface.device }} +
Interface + {{ interface }} +
Type + {{ interface.get_type_display }} +
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 98bde8688..f8fabf558 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -30,7 +30,7 @@
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslan_list' %} {% plugin_left_page object %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html new file mode 100644 index 000000000..6196adae4 --- /dev/null +++ b/netbox/templates/wireless/wirelesslink.html @@ -0,0 +1,48 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Interface A
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %} +
+
+
+
Link Properties
+
+ + + + + + + + + +
SSID{{ object.ssid|placeholder }}
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+
+
Interface B
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslink_list' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index e290653a2..5a8cf6671 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -5,12 +5,21 @@ from wireless.models import * __all__ = ( 'NestedWirelessLANSerializer', + 'NestedWirelessLinkSerializer', ) class NestedWirelessLANSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') class Meta: model = WirelessLAN fields = ['id', 'url', 'display', 'ssid'] + + +class NestedWirelessLinkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + + class Meta: + model = WirelessLink + fields = ['id', 'url', 'display', 'ssid'] diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 08642259f..90515f53e 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -1,16 +1,19 @@ from rest_framework import serializers +from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api.serializers import PrimaryModelSerializer from wireless.models import * +from .nested_serializers import * __all__ = ( 'WirelessLANSerializer', + 'WirelessLinkSerializer', ) class WirelessLANSerializer(PrimaryModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) class Meta: @@ -18,3 +21,15 @@ class WirelessLANSerializer(PrimaryModelSerializer): fields = [ 'id', 'url', 'display', 'ssid', 'description', 'vlan', ] + + +class WirelessLinkSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + interface_a = NestedInterfaceSerializer() + interface_b = NestedInterfaceSerializer() + + class Meta: + model = WirelessLAN + fields = [ + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'description', + ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 638f31bbf..431bb05f8 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -6,6 +6,7 @@ router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView router.register('wireless-lans', views.WirelessLANViewSet) +router.register('wireless-links', views.WirelessLinkViewSet) app_name = 'wireless-api' urlpatterns = router.urls diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index a09b2e23d..aa361a7f7 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -14,11 +14,13 @@ class WirelessRootView(APIRootView): return 'Wireless' -# -# Providers -# - class WirelessLANViewSet(CustomFieldModelViewSet): - queryset = WirelessLAN.objects.prefetch_related('tags') + queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') serializer_class = serializers.WirelessLANSerializer filterset_class = filtersets.WirelessLANFilterSet + + +class WirelessLinkViewSet(CustomFieldModelViewSet): + queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags') + serializer_class = serializers.WirelessLinkSerializer + filterset_class = filtersets.WirelessLinkFilterSet diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index c148354a0..7341ada9d 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -7,6 +7,7 @@ from .models import * __all__ = ( 'WirelessLANFilterSet', + 'WirelessLinkFilterSet', ) @@ -29,3 +30,24 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): Q(description__icontains=value) ) return queryset.filter(qs_filter) + + +class WirelessLinkFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + class Meta: + model = WirelessLink + fields = ['id', 'ssid'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(ssid__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index c11a16239..65666ccb1 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -4,9 +4,11 @@ from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField +from wireless.constants import SSID_MAX_LENGTH __all__ = ( 'WirelessLANBulkEditForm', + 'WirelessLinkBulkEditForm', ) @@ -19,11 +21,30 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode queryset=VLAN.objects.all(), required=False, ) + ssid = forms.CharField( + max_length=SSID_MAX_LENGTH, + required=False + ) description = forms.CharField( required=False ) class Meta: - nullable_fields = [ - 'vlan', 'description', - ] + nullable_fields = ['vlan', 'ssid', 'description'] + + +class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerFeed.objects.all(), + widget=forms.MultipleHiddenInput + ) + ssid = forms.CharField( + max_length=SSID_MAX_LENGTH, + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ['ssid', 'description'] diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 5dc07f91a..caf322dc1 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,10 +1,12 @@ +from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN from utilities.forms import CSVModelChoiceField -from wireless.models import WirelessLAN +from wireless.models import * __all__ = ( 'WirelessLANCSVForm', + 'WirelessLinkCSVForm', ) @@ -18,3 +20,16 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): class Meta: model = WirelessLAN fields = ('ssid', 'description', 'vlan') + + +class WirelessLinkCSVForm(CustomFieldModelCSVForm): + interface_a = CSVModelChoiceField( + queryset=Interface.objects.all() + ) + interface_b = CSVModelChoiceField( + queryset=Interface.objects.all() + ) + + class Meta: + model = WirelessLink + fields = ('interface_a', 'interface_b', 'ssid', 'description') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 99e38918e..fa1912099 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -3,7 +3,12 @@ from django.utils.translation import gettext as _ from extras.forms import CustomFieldModelFilterForm from utilities.forms import BootstrapMixin, TagFilterField -from .models import WirelessLAN +from wireless.models import * + +__all__ = ( + 'WirelessLANFilterForm', + 'WirelessLinkFilterForm', +) class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -16,4 +21,25 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + ssid = forms.CharField( + required=False, + label='SSID' + ) + tag = TagFilterField(model) + + +class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLink + field_groups = [ + ['q', 'tag'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + ssid = forms.CharField( + required=False, + label='SSID' + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 95b43c7d3..08e864340 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,11 +1,13 @@ +from dcim.models import Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from wireless.models import WirelessLAN +from wireless.models import * __all__ = ( 'WirelessLANForm', + 'WirelessLinkForm', ) @@ -28,3 +30,28 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): ('Wireless LAN', ('ssid', 'description', 'tags')), ('VLAN', ('vlan',)), ) + + +class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): + interface_a = DynamicModelChoiceField( + queryset=Interface.objects.all(), + query_params={ + 'kind': 'wireless' + } + ) + interface_b = DynamicModelChoiceField( + queryset=Interface.objects.all(), + query_params={ + 'kind': 'wireless' + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = WirelessLink + fields = [ + 'interface_a', 'interface_b', 'ssid', 'description', 'tags', + ] diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py deleted file mode 100644 index c93a17190..000000000 --- a/netbox/wireless/migrations/0001_initial.py +++ /dev/null @@ -1,34 +0,0 @@ -import django.core.serializers.json -from django.db import migrations, models -import django.db.models.deletion -import taggit.managers - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('ipam', '0050_iprange'), - ('extras', '0062_clear_secrets_changelog'), - ] - - operations = [ - migrations.CreateModel( - name='WirelessLAN', - fields=[ - ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('ssid', models.CharField(max_length=32)), - ('description', models.CharField(blank=True, max_length=200)), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), - ], - options={ - 'verbose_name': 'Wireless LAN', - 'ordering': ('ssid', 'pk'), - }, - ), - ] diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py new file mode 100644 index 000000000..2fb07e5fd --- /dev/null +++ b/netbox/wireless/migrations/0001_wireless.py @@ -0,0 +1,57 @@ +# Generated by Django 3.2.8 on 2021-10-13 13:44 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0136_wireless'), + ('extras', '0062_clear_secrets_changelog'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.CreateModel( + name='WirelessLAN', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('ssid', models.CharField(max_length=32)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), + ], + options={ + 'verbose_name': 'Wireless LAN', + 'ordering': ('ssid', 'pk'), + }, + ), + migrations.CreateModel( + name='WirelessLink', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('ssid', models.CharField(blank=True, max_length=32)), + ('description', models.CharField(blank=True, max_length=200)), + ('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), + ('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), + ('interface_a', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')), + ('interface_b', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['pk'], + 'unique_together': {('interface_a', 'interface_b')}, + }, + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 363631ef5..41f3dbf6d 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -6,19 +6,22 @@ from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet +from .constants import SSID_MAX_LENGTH __all__ = ( 'WirelessLAN', + 'WirelessLink', ) @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLAN(PrimaryModel): """ - A service set identifier belonging to a wireless network. + A wireless network formed among an arbitrary number of access point and clients. """ ssid = models.CharField( - max_length=32 + max_length=SSID_MAX_LENGTH, + verbose_name='SSID' ) vlan = models.ForeignKey( to='ipam.VLAN', @@ -42,4 +45,78 @@ class WirelessLAN(PrimaryModel): return self.ssid def get_absolute_url(self): - return reverse('wireless:ssid', args=[self.pk]) + return reverse('wireless:wirelesslan', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class WirelessLink(PrimaryModel): + """ + A point-to-point connection between two wireless Interfaces. + """ + interface_a = models.ForeignKey( + to='dcim.Interface', + limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + interface_b = models.ForeignKey( + to='dcim.Interface', + limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + ssid = models.CharField( + max_length=SSID_MAX_LENGTH, + blank=True, + verbose_name='SSID' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + # Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their + # associated Devices. + _interface_a_device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + _interface_b_device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['pk'] + unique_together = ('interface_a', 'interface_b') + + def get_absolute_url(self): + return reverse('wireless:wirelesslink', args=[self.pk]) + + def clean(self): + + # Validate interface types + if self.interface_a.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({ + 'interface_a': f"{self.interface_a.get_type_display()} is not a wireless interface." + }) + if self.interface_b.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({ + 'interface_a': f"{self.interface_b.get_type_display()} is not a wireless interface." + }) + + def save(self, *args, **kwargs): + + # Store the parent Device for the A and B interfaces + self._interface_a_device = self.interface_a.device + self._interface_b_device = self.interface_b.device + + super().save(*args, **kwargs) diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 133353f57..31c9e56a8 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,10 +1,11 @@ import django_tables2 as tables -from .models import WirelessLAN +from .models import * from utilities.tables import BaseTable, TagColumn, ToggleColumn __all__ = ( 'WirelessLANTable', + 'WirelessLinkTable', ) @@ -21,3 +22,25 @@ class WirelessLANTable(BaseTable): model = WirelessLAN fields = ('pk', 'ssid', 'description', 'vlan') default_columns = ('pk', 'ssid', 'description', 'vlan') + + +class WirelessLinkTable(BaseTable): + pk = ToggleColumn() + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + interface_a = tables.Column( + linkify=True + ) + interface_b = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='wireless:wirelesslink_list' + ) + + class Meta(BaseTable.Meta): + model = WirelessLink + fields = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') + default_columns = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index c30ca472c..21d704e6a 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -19,4 +19,16 @@ urlpatterns = ( path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), + # Wireless links + path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'), + path('wireless-links/add/', views.WirelessLinkEditView.as_view(), name='wirelesslink_add'), + path('wireless-links/import/', views.WirelessLinkBulkImportView.as_view(), name='wirelesslink_import'), + path('wireless-links/edit/', views.WirelessLinkBulkEditView.as_view(), name='wirelesslink_bulk_edit'), + path('wireless-links/delete/', views.WirelessLinkBulkDeleteView.as_view(), name='wirelesslink_bulk_delete'), + path('wireless-links//', views.WirelessLinkView.as_view(), name='wirelesslink'), + path('wireless-links//edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'), + path('wireless-links//delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'), + path('wireless-links//changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}), + path('wireless-links//journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}), + ) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 6e1c0b1b7..041ffbd42 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -44,3 +44,46 @@ class WirelessLANBulkDeleteView(generic.BulkDeleteView): queryset = WirelessLAN.objects.all() filterset = filtersets.WirelessLANFilterSet table = tables.WirelessLANTable + + +# +# Wireless Links +# + +class WirelessLinkListView(generic.ObjectListView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + filterset_form = forms.WirelessLinkFilterForm + table = tables.WirelessLinkTable + + +class WirelessLinkView(generic.ObjectView): + queryset = WirelessLink.objects.all() + + +class WirelessLinkEditView(generic.ObjectEditView): + queryset = WirelessLink.objects.all() + model_form = forms.WirelessLinkForm + + +class WirelessLinkDeleteView(generic.ObjectDeleteView): + queryset = WirelessLink.objects.all() + + +class WirelessLinkBulkImportView(generic.BulkImportView): + queryset = WirelessLink.objects.all() + model_form = forms.WirelessLinkCSVForm + table = tables.WirelessLinkTable + + +class WirelessLinkBulkEditView(generic.BulkEditView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + table = tables.WirelessLinkTable + form = forms.WirelessLinkBulkEditForm + + +class WirelessLinkBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + table = tables.WirelessLinkTable From 445e16f6682e250f855c2cc5185e8dce4f146e51 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 11:48:15 -0400 Subject: [PATCH 009/289] Reference WirelessLink on both attached Interfaces --- .../0138_interface_wireless_link.py | 20 +++++++ netbox/dcim/models/device_components.py | 19 ++++-- netbox/dcim/tables/template_code.py | 2 +- netbox/templates/dcim/interface.html | 19 +++++- netbox/wireless/apps.py | 3 + netbox/wireless/forms/models.py | 6 +- netbox/wireless/signals.py | 58 +++++++++++++++++++ 7 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 netbox/dcim/migrations/0138_interface_wireless_link.py create mode 100644 netbox/wireless/signals.py diff --git a/netbox/dcim/migrations/0138_interface_wireless_link.py b/netbox/dcim/migrations/0138_interface_wireless_link.py new file mode 100644 index 000000000..42b7a1042 --- /dev/null +++ b/netbox/dcim/migrations/0138_interface_wireless_link.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.8 on 2021-10-13 15:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ('dcim', '0137_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wireless_link', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 60eb4c368..dc8246990 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -529,6 +529,13 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): null=True, verbose_name='Channel width' ) + wireless_link = models.ForeignKey( + to='wireless.WirelessLink', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) wireless_lans = models.ManyToManyField( to='wireless.WirelessLAN', related_name='interfaces', @@ -568,14 +575,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): def clean(self): super().clean() - # Virtual interfaces cannot be connected - if not self.is_connectable and self.cable: + # Virtual Interfaces cannot have a Cable attached + if self.is_virtual and self.cable: raise ValidationError({ 'type': f"{self.get_type_display()} interfaces cannot have a cable attached." }) - # Non-connectable interfaces cannot be marked as connected - if not self.is_connectable and self.mark_connected: + # Virtual Interfaces cannot be marked as connected + if self.is_virtual and self.mark_connected: raise ValidationError({ 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." }) @@ -635,8 +642,8 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): }) @property - def is_connectable(self): - return self.type not in NONCONNECTABLE_IFACE_TYPES + def is_wired(self): + return not self.is_virtual and not self.is_wireless @property def is_virtual(self): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2f359e1b9..9e2dc519b 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -203,7 +203,7 @@ INTERFACE_BUTTONS = """ {% endif %} -{% elif record.is_connectable and perms.dcim.add_cable %} +{% elif record.is_wired and perms.dcim.add_cable %} {% if not record.mark_connected %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 33eaa95db..bf24a89ef 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -117,7 +117,7 @@ {% plugin_left_page object %}
- {% if object.is_connectable %} + {% if not object.is_virtual %}
Connection @@ -221,10 +221,19 @@ + {% elif object.wireless_link %} + + + + + +
Wireless Link + {{ object.wireless_link }} +
{% else %}
Not Connected - {% if perms.dcim.add_cable %} + {% if object.is_wired and perms.dcim.add_cable %} + {% elif object.is_wireless and perms.wireless.add_wirelesslink %} + {% endif %}
{% endif %} diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py index 1f6deff22..59e47aba5 100644 --- a/netbox/wireless/apps.py +++ b/netbox/wireless/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class WirelessConfig(AppConfig): name = 'wireless' + + def ready(self): + import wireless.signals diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 08e864340..c494fb5a2 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -37,13 +37,15 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): queryset=Interface.objects.all(), query_params={ 'kind': 'wireless' - } + }, + label='Interface A' ) interface_b = DynamicModelChoiceField( queryset=Interface.objects.all(), query_params={ 'kind': 'wireless' - } + }, + label='Interface B' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py new file mode 100644 index 000000000..a42566a00 --- /dev/null +++ b/netbox/wireless/signals.py @@ -0,0 +1,58 @@ +import logging + +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + +from dcim.models import Interface +from .models import WirelessLink + + +# +# Wireless links +# + +@receiver(post_save, sender=WirelessLink) +def update_connected_interfaces(instance, raw=False, **kwargs): + """ + When a WirelessLink is saved, save a reference to it on each connected interface. + """ + print('update_connected_interfaces') + logger = logging.getLogger('netbox.wireless.wirelesslink') + if raw: + logger.debug(f"Skipping endpoint updates for imported wireless link {instance}") + return + + if instance.interface_a.wireless_link != instance: + logger.debug(f"Updating interface A for wireless link {instance}") + instance.interface_a.wireless_link = instance + # instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field + instance.interface_a.save() + if instance.interface_b.cable != instance: + logger.debug(f"Updating interface B for wireless link {instance}") + instance.interface_b.wireless_link = instance + # instance.interface_b._cable_peer = instance.interface_a + instance.interface_b.save() + + +@receiver(post_delete, sender=WirelessLink) +def nullify_connected_interfaces(instance, **kwargs): + """ + When a WirelessLink is deleted, update its two connected Interfaces + """ + print('nullify_connected_interfaces') + logger = logging.getLogger('netbox.wireless.wirelesslink') + + if instance.interface_a is not None: + logger.debug(f"Nullifying interface A for wireless link {instance}") + Interface.objects.filter(pk=instance.interface_a.pk).update( + wireless_link=None, + _cable_peer_type=None, + _cable_peer_id=None + ) + if instance.interface_b is not None: + logger.debug(f"Nullifying interface B for wireless link {instance}") + Interface.objects.filter(pk=instance.interface_b.pk).update( + wireless_link=None, + _cable_peer_type=None, + _cable_peer_id=None + ) From 138af27bf7c18d6833233ff84029eb8ef6a186bf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 13:28:14 -0400 Subject: [PATCH 010/289] Record wireless links as part of cable path --- netbox/dcim/models/cables.py | 10 ++++----- netbox/dcim/models/device_components.py | 11 ++++++++++ netbox/dcim/signals.py | 28 +------------------------ netbox/dcim/utils.py | 27 ++++++++++++++++++++++++ netbox/wireless/signals.py | 21 +++++++++++++------ 5 files changed, 59 insertions(+), 38 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c3f8cac3f..6c61c0712 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -379,7 +379,7 @@ class CablePath(BigIDModel): """ from circuits.models import CircuitTermination - if origin is None or origin.cable is None: + if origin is None or origin.link is None: return None destination = None @@ -389,12 +389,12 @@ class CablePath(BigIDModel): is_split = False node = origin - while node.cable is not None: - if node.cable.status != CableStatusChoices.STATUS_CONNECTED: + while node.link is not None: + if hasattr(node.link, 'status') and node.link.status != CableStatusChoices.STATUS_CONNECTED: is_active = False - # Follow the cable to its far-end termination - path.append(object_to_path_node(node.cable)) + # Follow the link to its far-end termination + path.append(object_to_path_node(node.link)) peer_termination = node.get_cable_peer() # Follow a FrontPort to its corresponding RearPort diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index dc8246990..38e902f22 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -157,6 +157,13 @@ class CableTermination(models.Model): def parent_object(self): raise NotImplementedError("CableTermination models must implement parent_object()") + @property + def link(self): + """ + Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination. + """ + return self.cable + class PathEndpoint(models.Model): """ @@ -657,6 +664,10 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): def is_lag(self): return self.type == InterfaceTypeChoices.TYPE_LAG + @property + def link(self): + return self.cable or self.wireless_link + # # Pass-through ports diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 9fc68ee70..942bf04e4 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -2,37 +2,11 @@ import logging from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, post_delete, pre_delete -from django.db import transaction from django.dispatch import receiver from .choices import CableStatusChoices from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis - - -def create_cablepath(node): - """ - Create CablePaths for all paths originating from the specified node. - """ - cp = CablePath.from_origin(node) - if cp: - try: - cp.save() - except Exception as e: - print(node, node.pk) - raise e - - -def rebuild_paths(obj): - """ - Rebuild all CablePaths which traverse the specified node - """ - cable_paths = CablePath.objects.filter(path__contains=obj) - - with transaction.atomic(): - for cp in cable_paths: - cp.delete() - if cp.origin: - create_cablepath(cp.origin) +from .utils import create_cablepath, rebuild_paths # diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 91c5c7c77..ec3a44603 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db import transaction def compile_path_node(ct_id, object_id): @@ -26,3 +27,29 @@ def path_node_to_object(repr): ct_id, object_id = decompile_path_node(repr) ct = ContentType.objects.get_for_id(ct_id) return ct.model_class().objects.get(pk=object_id) + + +def create_cablepath(node): + """ + Create CablePaths for all paths originating from the specified node. + """ + from dcim.models import CablePath + + cp = CablePath.from_origin(node) + if cp: + cp.save() + + +def rebuild_paths(obj): + """ + Rebuild all CablePaths which traverse the specified node + """ + from dcim.models import CablePath + + cable_paths = CablePath.objects.filter(path__contains=obj) + + with transaction.atomic(): + for cp in cable_paths: + cp.delete() + if cp.origin: + create_cablepath(cp.origin) diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index a42566a00..b8dc8a186 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -3,7 +3,8 @@ import logging from django.db.models.signals import post_save, post_delete from django.dispatch import receiver -from dcim.models import Interface +from dcim.models import CablePath, Interface +from dcim.utils import create_cablepath from .models import WirelessLink @@ -12,11 +13,10 @@ from .models import WirelessLink # @receiver(post_save, sender=WirelessLink) -def update_connected_interfaces(instance, raw=False, **kwargs): +def update_connected_interfaces(instance, created, raw=False, **kwargs): """ When a WirelessLink is saved, save a reference to it on each connected interface. """ - print('update_connected_interfaces') logger = logging.getLogger('netbox.wireless.wirelesslink') if raw: logger.debug(f"Skipping endpoint updates for imported wireless link {instance}") @@ -25,21 +25,25 @@ def update_connected_interfaces(instance, raw=False, **kwargs): if instance.interface_a.wireless_link != instance: logger.debug(f"Updating interface A for wireless link {instance}") instance.interface_a.wireless_link = instance - # instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field + instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field instance.interface_a.save() if instance.interface_b.cable != instance: logger.debug(f"Updating interface B for wireless link {instance}") instance.interface_b.wireless_link = instance - # instance.interface_b._cable_peer = instance.interface_a + instance.interface_b._cable_peer = instance.interface_a instance.interface_b.save() + # Create/update cable paths + if created: + for interface in (instance.interface_a, instance.interface_b): + create_cablepath(interface) + @receiver(post_delete, sender=WirelessLink) def nullify_connected_interfaces(instance, **kwargs): """ When a WirelessLink is deleted, update its two connected Interfaces """ - print('nullify_connected_interfaces') logger = logging.getLogger('netbox.wireless.wirelesslink') if instance.interface_a is not None: @@ -56,3 +60,8 @@ def nullify_connected_interfaces(instance, **kwargs): _cable_peer_type=None, _cable_peer_id=None ) + + # Delete and retrace any dependent cable paths + for cablepath in CablePath.objects.filter(path__contains=instance): + print(f'Deleting cable path {cablepath.pk}') + cablepath.delete() From 1c73bd5079876cec632d8a7eafa71e01e701a369 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 13:39:14 -0400 Subject: [PATCH 011/289] Resolve test errors --- netbox/dcim/api/serializers.py | 4 ++-- netbox/dcim/graphql/types.py | 3 +++ netbox/wireless/api/serializers.py | 2 +- netbox/wireless/graphql/types.py | 9 +++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index edd73b87e..28d3a143b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -632,8 +632,8 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) - rf_channel = ChoiceField(choices=WirelessChannelChoices) - rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) + rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index be10556be..55f1ba150 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -206,6 +206,9 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType): def resolve_mode(self, info): return self.mode or None + def resolve_rf_channel(self, info): + return self.rf_channel or None + class InterfaceTemplateType(ComponentTemplateObjectType): diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 90515f53e..9337d6864 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -29,7 +29,7 @@ class WirelessLinkSerializer(PrimaryModelSerializer): interface_b = NestedInterfaceSerializer() class Meta: - model = WirelessLAN + model = WirelessLink fields = [ 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'description', ] diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 4cdb75ebe..0afd8e69a 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -3,6 +3,7 @@ from netbox.graphql.types import ObjectType __all__ = ( 'WirelessLANType', + 'WirelessLinkType', ) @@ -12,3 +13,11 @@ class WirelessLANType(ObjectType): model = models.WirelessLAN fields = '__all__' filterset_class = filtersets.WirelessLANFilterSet + + +class WirelessLinkType(ObjectType): + + class Meta: + model = models.WirelessLink + fields = '__all__' + filterset_class = filtersets.WirelessLinkFilterSet From ac2cd552b9641023e7ddf615fcb461e24fe42ea4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 14:04:53 -0400 Subject: [PATCH 012/289] Rename cable_peer fields to link_peer --- netbox/circuits/api/serializers.py | 6 +- .../migrations/0003_rename_cable_peer.py | 23 +++++ netbox/circuits/models.py | 4 +- netbox/dcim/api/serializers.py | 54 +++++------ netbox/dcim/api/views.py | 12 +-- .../dcim/migrations/0139_rename_cable_peer.py | 93 +++++++++++++++++++ netbox/dcim/models/__init__.py | 2 +- netbox/dcim/models/cables.py | 2 +- netbox/dcim/models/device_components.py | 52 +++++------ netbox/dcim/models/power.py | 4 +- netbox/dcim/models/racks.py | 8 +- netbox/dcim/signals.py | 8 +- netbox/dcim/tables/devices.py | 51 +++++----- netbox/dcim/tables/power.py | 4 +- netbox/dcim/tables/template_code.py | 2 +- netbox/dcim/tests/test_models.py | 8 +- .../circuits/inc/circuit_termination.html | 2 +- netbox/wireless/models.py | 3 + netbox/wireless/signals.py | 12 +-- 19 files changed, 236 insertions(+), 114 deletions(-) create mode 100644 netbox/circuits/migrations/0003_rename_cable_peer.py create mode 100644 netbox/dcim/migrations/0139_rename_cable_peer.py diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ac6285610..e00b3dfc8 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer -from dcim.api.serializers import CableTerminationSerializer +from dcim.api.serializers import LinkTerminationSerializer from netbox.api import ChoiceField from netbox.api.serializers import ( OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -90,7 +90,7 @@ class CircuitSerializer(PrimaryModelSerializer): ] -class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer): +class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False, allow_null=True) @@ -101,6 +101,6 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSer model = CircuitTermination fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', '_occupied', ] diff --git a/netbox/circuits/migrations/0003_rename_cable_peer.py b/netbox/circuits/migrations/0003_rename_cable_peer.py new file mode 100644 index 000000000..475a84d0f --- /dev/null +++ b/netbox/circuits/migrations/0003_rename_cable_peer.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.8 on 2021-10-13 17:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0002_squashed_0029'), + ] + + operations = [ + migrations.RenameField( + model_name='circuittermination', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='circuittermination', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index bc7dcc219..8420db563 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from dcim.models import CableTermination, PathEndpoint +from dcim.models import LinkTermination, PathEndpoint from extras.models import ObjectChange from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel @@ -246,7 +246,7 @@ class Circuit(PrimaryModel): @extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, CableTermination): +class CircuitTermination(ChangeLoggedModel, LinkTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 28d3a143b..9187901f0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -22,25 +22,25 @@ from virtualization.api.nested_serializers import NestedClusterSerializer from .nested_serializers import * -class CableTerminationSerializer(serializers.ModelSerializer): - cable_peer_type = serializers.SerializerMethodField(read_only=True) - cable_peer = serializers.SerializerMethodField(read_only=True) +class LinkTerminationSerializer(serializers.ModelSerializer): + link_peer_type = serializers.SerializerMethodField(read_only=True) + link_peer = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True) - def get_cable_peer_type(self, obj): - if obj._cable_peer is not None: - return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}' + def get_link_peer_type(self, obj): + if obj._link_peer is not None: + return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}' return None @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_cable_peer(self, obj): + def get_link_peer(self, obj): """ - Return the appropriate serializer for the cable termination model. + Return the appropriate serializer for the link termination model. """ - if obj._cable_peer is not None: - serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested') + if obj._link_peer is not None: + serializer = get_serializer_for_model(obj._link_peer, prefix='Nested') context = {'request': self.context['request']} - return serializer(obj._cable_peer, context=context).data + return serializer(obj._link_peer, context=context).data return None @swagger_serializer_method(serializer_or_field=serializers.BooleanField) @@ -529,7 +529,7 @@ class DeviceNAPALMSerializer(serializers.Serializer): # Device components # -class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -548,12 +548,12 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial model = ConsoleServerPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -572,12 +572,12 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, model = ConsolePort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -601,12 +601,12 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, model = PowerOutlet fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -620,12 +620,12 @@ class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co model = PowerPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) @@ -649,7 +649,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', + 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', ] @@ -668,7 +668,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co return super().validate(data) -class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): +class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -678,7 +678,7 @@ class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): model = RearPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -694,7 +694,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): +class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -705,7 +705,7 @@ class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): model = FrontPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -881,7 +881,7 @@ class PowerPanelSerializer(PrimaryModelSerializer): fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( @@ -911,7 +911,7 @@ class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co model = PowerFeed fields = [ 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2b9d9734c..43ced046c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -513,7 +513,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): # class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') + queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet brief_prefetch_fields = ['device'] @@ -521,7 +521,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_cable_peer', 'tags' + 'device', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet @@ -529,14 +529,14 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') + queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet brief_prefetch_fields = ['device'] class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') + queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet brief_prefetch_fields = ['device'] @@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -625,7 +625,7 @@ class PowerPanelViewSet(ModelViewSet): class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): queryset = PowerFeed.objects.prefetch_related( - 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags' + 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.PowerFeedSerializer filterset_class = filtersets.PowerFeedFilterSet diff --git a/netbox/dcim/migrations/0139_rename_cable_peer.py b/netbox/dcim/migrations/0139_rename_cable_peer.py new file mode 100644 index 000000000..62a4bacdd --- /dev/null +++ b/netbox/dcim/migrations/0139_rename_cable_peer.py @@ -0,0 +1,93 @@ +# Generated by Django 3.2.8 on 2021-10-13 17:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0138_interface_wireless_link'), + ] + + operations = [ + migrations.RenameField( + model_name='consoleport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='consoleport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='consoleserverport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='consoleserverport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='frontport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='frontport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='interface', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='interface', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='powerfeed', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='powerfeed', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='poweroutlet', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='poweroutlet', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='powerport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='powerport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='rearport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='rearport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 0375a9fb4..58a3e1de5 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -10,7 +10,7 @@ __all__ = ( 'BaseInterface', 'Cable', 'CablePath', - 'CableTermination', + 'LinkTermination', 'ConsolePort', 'ConsolePortTemplate', 'ConsoleServerPort', diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 6c61c0712..fb3e71543 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -395,7 +395,7 @@ class CablePath(BigIDModel): # Follow the link to its far-end termination path.append(object_to_path_node(node.link)) - peer_termination = node.get_cable_peer() + peer_termination = node.get_link_peer() # Follow a FrontPort to its corresponding RearPort if isinstance(peer_termination, FrontPort): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 38e902f22..f8649b419 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -22,7 +22,7 @@ from utilities.query_functions import CollateAsChar __all__ = ( 'BaseInterface', - 'CableTermination', + 'LinkTermination', 'ConsolePort', 'ConsoleServerPort', 'DeviceBay', @@ -87,14 +87,14 @@ class ComponentModel(PrimaryModel): return self.device -class CableTermination(models.Model): +class LinkTermination(models.Model): """ - An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and - CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance. + An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples + include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields + reference the attached Cable or WirelessLink instance, respectively. - `_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a - shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in - dcim.signals when a Cable instance is created or deleted, respectively. + `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a + shortcut to referencing `instance.link.termination_b`, for example. """ cable = models.ForeignKey( to='dcim.Cable', @@ -103,20 +103,20 @@ class CableTermination(models.Model): blank=True, null=True ) - _cable_peer_type = models.ForeignKey( + _link_peer_type = models.ForeignKey( to=ContentType, on_delete=models.SET_NULL, related_name='+', blank=True, null=True ) - _cable_peer_id = models.PositiveIntegerField( + _link_peer_id = models.PositiveIntegerField( blank=True, null=True ) - _cable_peer = GenericForeignKey( - ct_field='_cable_peer_type', - fk_field='_cable_peer_id' + _link_peer = GenericForeignKey( + ct_field='_link_peer_type', + fk_field='_link_peer_id' ) mark_connected = models.BooleanField( default=False, @@ -146,8 +146,8 @@ class CableTermination(models.Model): "mark_connected": "Cannot mark as connected with a cable attached." }) - def get_cable_peer(self): - return self._cable_peer + def get_link_peer(self): + return self._link_peer @property def _occupied(self): @@ -226,7 +226,7 @@ class PathEndpoint(models.Model): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsolePort(ComponentModel, CableTermination, PathEndpoint): +class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -258,7 +258,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): +class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -290,7 +290,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPort(ComponentModel, CableTermination, PathEndpoint): +class PowerPort(ComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -340,8 +340,8 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint): poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet) outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) utilization = PowerPort.objects.filter( - _cable_peer_type=poweroutlet_ct, - _cable_peer_id__in=outlet_ids + _link_peer_type=poweroutlet_ct, + _link_peer_id__in=outlet_ids ).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), @@ -354,12 +354,12 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint): } # Calculate per-leg aggregates for three-phase feeds - if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: + if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: for leg, leg_name in PowerOutletFeedLegChoices: outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) utilization = PowerPort.objects.filter( - _cable_peer_type=poweroutlet_ct, - _cable_peer_id__in=outlet_ids + _link_peer_type=poweroutlet_ct, + _link_peer_id__in=outlet_ids ).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), @@ -387,7 +387,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerOutlet(ComponentModel, CableTermination, PathEndpoint): +class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -482,7 +482,7 @@ class BaseInterface(models.Model): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): +class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -674,7 +674,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FrontPort(ComponentModel, CableTermination): +class FrontPort(ComponentModel, LinkTermination): """ A pass-through port on the front of a Device. """ @@ -728,7 +728,7 @@ class FrontPort(ComponentModel, CableTermination): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RearPort(ComponentModel, CableTermination): +class RearPort(ComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 0e9520b36..6d6a04cea 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -10,7 +10,7 @@ from extras.utils import extras_features from netbox.models import PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator -from .device_components import CableTermination, PathEndpoint +from .device_components import LinkTermination, PathEndpoint __all__ = ( 'PowerFeed', @@ -67,7 +67,7 @@ class PowerPanel(PrimaryModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): +class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c287d7d6c..94e7bf53a 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -422,13 +422,13 @@ class Rack(PrimaryModel): return 0 pf_powerports = PowerPort.objects.filter( - _cable_peer_type=ContentType.objects.get_for_model(PowerFeed), - _cable_peer_id__in=powerfeeds.values_list('id', flat=True) + _link_peer_type=ContentType.objects.get_for_model(PowerFeed), + _link_peer_id__in=powerfeeds.values_list('id', flat=True) ) poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports) allocated_draw_total = PowerPort.objects.filter( - _cable_peer_type=ContentType.objects.get_for_model(PowerOutlet), - _cable_peer_id__in=poweroutlets.values_list('id', flat=True) + _link_peer_type=ContentType.objects.get_for_model(PowerOutlet), + _link_peer_id__in=poweroutlets.values_list('id', flat=True) ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0 return int(allocated_draw_total / available_power_total * 100) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 942bf04e4..616546525 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -83,12 +83,12 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): if instance.termination_a.cable != instance: logger.debug(f"Updating termination A for cable {instance}") instance.termination_a.cable = instance - instance.termination_a._cable_peer = instance.termination_b + instance.termination_a._link_peer = instance.termination_b instance.termination_a.save() if instance.termination_b.cable != instance: logger.debug(f"Updating termination B for cable {instance}") instance.termination_b.cable = instance - instance.termination_b._cable_peer = instance.termination_a + instance.termination_b._link_peer = instance.termination_a instance.termination_b.save() # Create/update cable paths @@ -119,11 +119,11 @@ def nullify_connected_endpoints(instance, **kwargs): if instance.termination_a is not None: logger.debug(f"Nullifying termination A for cable {instance}") model = instance.termination_a._meta.model - model.objects.filter(pk=instance.termination_a.pk).update(_cable_peer_type=None, _cable_peer_id=None) + model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None) if instance.termination_b is not None: logger.debug(f"Nullifying termination B for cable {instance}") model = instance.termination_b._meta.model - model.objects.filter(pk=instance.termination_b.pk).update(_cable_peer_type=None, _cable_peer_id=None) + model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None) # Delete and retrace any dependent cable paths for cablepath in CablePath.objects.filter(path__contains=instance): diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 1eae62a05..a375a77cc 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -12,7 +12,7 @@ from utilities.tables import ( MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import ( - CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, + LINKTERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, POWERPORT_BUTTONS, REARPORT_BUTTONS, ) @@ -258,11 +258,11 @@ class CableTerminationTable(BaseTable): orderable=False, verbose_name='Cable Color' ) - cable_peer = TemplateColumn( - accessor='_cable_peer', - template_code=CABLETERMINATION, + link_peer = TemplateColumn( + accessor='_link_peer', + template_code=LINKTERMINATION, orderable=False, - verbose_name='Cable Peer' + verbose_name='Link Peer' ) mark_connected = BooleanColumn() @@ -270,7 +270,7 @@ class CableTerminationTable(BaseTable): class PathEndpointTable(CableTerminationTable): connection = TemplateColumn( accessor='_path.last_node', - template_code=CABLETERMINATION, + template_code=LINKTERMINATION, verbose_name='Connection', orderable=False ) @@ -291,7 +291,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): model = ConsolePort fields = ( 'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', + 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -312,7 +312,7 @@ class DeviceConsolePortTable(ConsolePortTable): model = ConsolePort fields = ( 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', 'actions' + 'link_peer', 'connection', 'tags', 'actions' ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -335,7 +335,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): model = ConsoleServerPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', + 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -357,7 +357,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): model = ConsoleServerPort fields = ( 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', 'actions', + 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -380,7 +380,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', - 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -402,7 +402,7 @@ class DevicePowerPortTable(PowerPortTable): model = PowerPort fields = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', @@ -431,7 +431,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', + 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -452,7 +452,7 @@ class DevicePowerOutletTable(PowerOutletTable): model = PowerOutlet fields = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', @@ -485,6 +485,9 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable } ) mgmt_only = BooleanColumn() + wireless_link = tables.Column( + linkify=True + ) tags = TagColumn( url_name='dcim:interface_list' ) @@ -493,8 +496,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', - 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -525,8 +528,8 @@ class DeviceInterfaceTable(InterfaceTable): model = Interface fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', + 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( @@ -562,7 +565,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): model = FrontPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -586,10 +589,10 @@ class DeviceFrontPortTable(FrontPortTable): model = FrontPort fields = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'tags', 'actions', + 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', + 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', 'actions', ) row_attrs = { @@ -613,7 +616,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'tags', + 'cable_color', 'link_peer', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -635,10 +638,10 @@ class DeviceRearPortTable(RearPortTable): model = RearPort fields = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'tags', 'actions', + 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', ) row_attrs = { 'class': get_cabletermination_row_class diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index b8e032e7f..956282911 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -71,10 +71,10 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', + 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', 'comments', 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', - 'cable_peer', + 'link_peer', ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 9e2dc519b..7e78cb97d 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,4 +1,4 @@ -CABLETERMINATION = """ +LINKTERMINATION = """ {% if value %} {% if value.parent_object %} {{ value.parent_object }} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ae280365e..1042057de 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -494,9 +494,9 @@ class CableTestCase(TestCase): interface1 = Interface.objects.get(pk=self.interface1.pk) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertEqual(self.cable.termination_a, interface1) - self.assertEqual(interface1._cable_peer, interface2) + self.assertEqual(interface1._link_peer, interface2) self.assertEqual(self.cable.termination_b, interface2) - self.assertEqual(interface2._cable_peer, interface1) + self.assertEqual(interface2._link_peer, interface1) def test_cable_deletion(self): """ @@ -508,10 +508,10 @@ class CableTestCase(TestCase): self.assertNotEqual(str(self.cable), '#None') interface1 = Interface.objects.get(pk=self.interface1.pk) self.assertIsNone(interface1.cable) - self.assertIsNone(interface1._cable_peer) + self.assertIsNone(interface1._link_peer) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertIsNone(interface2.cable) - self.assertIsNone(interface2._cable_peer) + self.assertIsNone(interface2._link_peer) def test_cabletermination_deletion(self): """ diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index e2fe6af29..5c224f7c0 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -45,7 +45,7 @@ Marked as connected {% elif termination.cable %} {{ termination.cable }} - {% with peer=termination.get_cable_peer %} + {% with peer=termination.get_link_peer %} to {% if peer.device %} {{ peer.device }}
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 41f3dbf6d..f8c947385 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -98,6 +98,9 @@ class WirelessLink(PrimaryModel): ordering = ['pk'] unique_together = ('interface_a', 'interface_b') + def __str__(self): + return f'#{self.pk}' + def get_absolute_url(self): return reverse('wireless:wirelesslink', args=[self.pk]) diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index b8dc8a186..935e11677 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -25,12 +25,12 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs): if instance.interface_a.wireless_link != instance: logger.debug(f"Updating interface A for wireless link {instance}") instance.interface_a.wireless_link = instance - instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field + instance.interface_a._link_peer = instance.interface_b instance.interface_a.save() if instance.interface_b.cable != instance: logger.debug(f"Updating interface B for wireless link {instance}") instance.interface_b.wireless_link = instance - instance.interface_b._cable_peer = instance.interface_a + instance.interface_b._link_peer = instance.interface_a instance.interface_b.save() # Create/update cable paths @@ -50,15 +50,15 @@ def nullify_connected_interfaces(instance, **kwargs): logger.debug(f"Nullifying interface A for wireless link {instance}") Interface.objects.filter(pk=instance.interface_a.pk).update( wireless_link=None, - _cable_peer_type=None, - _cable_peer_id=None + _link_peer_type=None, + _link_peer_id=None ) if instance.interface_b is not None: logger.debug(f"Nullifying interface B for wireless link {instance}") Interface.objects.filter(pk=instance.interface_b.pk).update( wireless_link=None, - _cable_peer_type=None, - _cable_peer_id=None + _link_peer_type=None, + _link_peer_id=None ) # Delete and retrace any dependent cable paths From ec0560a2c547c6f480ab67b025fa979a939e5f3c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 14:16:10 -0400 Subject: [PATCH 013/289] Fix trace_paths command for wireless links --- netbox/dcim/management/commands/trace_paths.py | 6 +++++- netbox/dcim/tables/template_code.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index fd5f9cfab..d0cd64486 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -1,6 +1,7 @@ from django.core.management.base import BaseCommand from django.core.management.color import no_style from django.db import connection +from django.db.models import Q from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.signals import create_cablepath @@ -67,7 +68,10 @@ class Command(BaseCommand): # Retrace paths for model in ENDPOINT_MODELS: - origins = model.objects.filter(cable__isnull=False) + params = Q(cable__isnull=False) + if hasattr(model, 'wireless_link'): + params |= Q(wireless_link__isnull=False) + origins = model.objects.filter(params) if not options['force']: origins = origins.filter(_path__isnull=True) origins_count = origins.count() diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 7e78cb97d..a5a4d9979 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -195,8 +195,10 @@ INTERFACE_BUTTONS = """ {% endif %} -{% if record.cable %} +{% if record.link %} +{% endif %} +{% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% if perms.dcim.delete_cable %} From 95ed07a95ec0fe2f71441311cb3e6bb9047ef17a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 14:31:30 -0400 Subject: [PATCH 014/289] Add status field to WirelessLink --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/choices.py | 4 ++-- netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_edit.py | 2 +- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 2 +- netbox/dcim/models/cables.py | 8 ++++---- netbox/dcim/signals.py | 4 ++-- netbox/dcim/tests/test_cablepaths.py | 6 +++--- netbox/dcim/tests/test_filtersets.py | 16 ++++++++-------- netbox/dcim/tests/test_views.py | 4 ++-- netbox/templates/wireless/wirelesslink.html | 4 ++++ netbox/wireless/api/serializers.py | 5 ++++- netbox/wireless/filtersets.py | 4 ++++ netbox/wireless/forms/bulk_edit.py | 11 ++++++++--- netbox/wireless/forms/bulk_import.py | 7 ++++++- netbox/wireless/forms/filtersets.py | 8 +++++++- netbox/wireless/forms/models.py | 7 +++++-- netbox/wireless/migrations/0001_wireless.py | 1 + netbox/wireless/models.py | 11 +++++++++++ netbox/wireless/tables.py | 7 ++++--- 21 files changed, 80 insertions(+), 37 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9187901f0..03d14a160 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -757,7 +757,7 @@ class CableSerializer(PrimaryModelSerializer): ) termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) - status = ChoiceField(choices=CableStatusChoices, required=False) + status = ChoiceField(choices=LinkStatusChoices, required=False) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 9a78a74f9..d58d5466d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1030,7 +1030,7 @@ class PortTypeChoices(ChoiceSet): # -# Cables +# Cables/links # class CableTypeChoices(ChoiceSet): @@ -1094,7 +1094,7 @@ class CableTypeChoices(ChoiceSet): ) -class CableStatusChoices(ChoiceSet): +class LinkStatusChoices(ChoiceSet): STATUS_CONNECTED = 'connected' STATUS_PLANNED = 'planned' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0c756957a..f778fd083 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1205,7 +1205,7 @@ class CableFilterSet(PrimaryModelFilterSet): choices=CableTypeChoices ) status = django_filters.MultipleChoiceFilter( - choices=CableStatusChoices + choices=LinkStatusChoices ) color = django_filters.MultipleChoiceFilter( choices=ColorChoices diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 67a482a26..f710e7d8c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -453,7 +453,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE widget=StaticSelect() ) status = forms.ChoiceField( - choices=add_blank_choice(CableStatusChoices), + choices=add_blank_choice(LinkStatusChoices), required=False, widget=StaticSelect(), initial='' diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index a2685c8e0..95b4dd136 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -807,7 +807,7 @@ class CableCSVForm(CustomFieldModelCSVForm): # Cable attributes status = CSVChoiceField( - choices=CableStatusChoices, + choices=LinkStatusChoices, required=False, help_text='Connection status' ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 605139c1b..169b93028 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -732,7 +732,7 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): ) status = forms.ChoiceField( required=False, - choices=add_blank_choice(CableStatusChoices), + choices=add_blank_choice(LinkStatusChoices), widget=StaticSelect() ) color = ColorField( diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index fb3e71543..129617746 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -64,8 +64,8 @@ class Cable(PrimaryModel): ) status = models.CharField( max_length=50, - choices=CableStatusChoices, - default=CableStatusChoices.STATUS_CONNECTED + choices=LinkStatusChoices, + default=LinkStatusChoices.STATUS_CONNECTED ) label = models.CharField( max_length=100, @@ -285,7 +285,7 @@ class Cable(PrimaryModel): self._pk = self.pk def get_status_class(self): - return CableStatusChoices.CSS_CLASSES.get(self.status) + return LinkStatusChoices.CSS_CLASSES.get(self.status) def get_compatible_types(self): """ @@ -390,7 +390,7 @@ class CablePath(BigIDModel): node = origin while node.link is not None: - if hasattr(node.link, 'status') and node.link.status != CableStatusChoices.STATUS_CONNECTED: + if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED: is_active = False # Follow the link to its far-end termination diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 616546525..79e9c6687 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver -from .choices import CableStatusChoices +from .choices import LinkStatusChoices from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis from .utils import create_cablepath, rebuild_paths @@ -102,7 +102,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): # We currently don't support modifying either termination of an existing Cable. (This # may change in the future.) However, we do need to capture status changes and update # any CablePaths accordingly. - if instance.status != CableStatusChoices.STATUS_CONNECTED: + if instance.status != LinkStatusChoices.STATUS_CONNECTED: CablePath.objects.filter(path__contains=instance).update(is_active=False) else: rebuild_paths(instance) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index c0fc89f83..6849df012 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import * -from dcim.choices import CableStatusChoices +from dcim.choices import LinkStatusChoices from dcim.models import * from dcim.utils import object_to_path_node @@ -1142,7 +1142,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.count(), 2) # Change cable 2's status to "planned" - cable2.status = CableStatusChoices.STATUS_PLANNED + cable2.status = LinkStatusChoices.STATUS_PLANNED cable2.save() self.assertPathExists( origin=interface1, @@ -1160,7 +1160,7 @@ class CablePathTestCase(TestCase): # Change cable 2's status to "connected" cable2 = Cable.objects.get(pk=cable2.pk) - cable2.status = CableStatusChoices.STATUS_CONNECTED + cable2.status = LinkStatusChoices.STATUS_CONNECTED cable2.save() self.assertPathExists( origin=interface1, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fb94bde08..0c1ffd54b 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2855,12 +2855,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') # Cables - Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() def test_label(self): @@ -2880,9 +2880,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): - params = {'status': [CableStatusChoices.STATUS_CONNECTED]} + params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'status': [CableStatusChoices.STATUS_PLANNED]} + params = {'status': [LinkStatusChoices.STATUS_PLANNED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_color(self): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 545a56f81..c0af2d438 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1813,7 +1813,7 @@ class CableTestCase( 'termination_b_type': interface_ct.pk, 'termination_b_id': interfaces[3].pk, 'type': CableTypeChoices.TYPE_CAT6, - 'status': CableStatusChoices.STATUS_PLANNED, + 'status': LinkStatusChoices.STATUS_PLANNED, 'label': 'Label', 'color': 'c0c0c0', 'length': 100, @@ -1830,7 +1830,7 @@ class CableTestCase( cls.bulk_edit_data = { 'type': CableTypeChoices.TYPE_CAT5E, - 'status': CableStatusChoices.STATUS_CONNECTED, + 'status': LinkStatusChoices.STATUS_CONNECTED, 'label': 'New label', 'color': '00ff00', 'length': 50, diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 6196adae4..45ec6b0c9 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -15,6 +15,10 @@
Link Properties
+ + + + diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 9337d6864..5a7330129 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -1,7 +1,9 @@ from rest_framework import serializers +from dcim.choices import LinkStatusChoices from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer +from netbox.api import ChoiceField from netbox.api.serializers import PrimaryModelSerializer from wireless.models import * from .nested_serializers import * @@ -25,11 +27,12 @@ class WirelessLANSerializer(PrimaryModelSerializer): class WirelessLinkSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = NestedInterfaceSerializer() interface_b = NestedInterfaceSerializer() class Meta: model = WirelessLink fields = [ - 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'description', + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', ] diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 7341ada9d..f765172dd 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -1,6 +1,7 @@ import django_filters from django.db.models import Q +from dcim.choices import LinkStatusChoices from extras.filters import TagFilter from netbox.filtersets import PrimaryModelFilterSet from .models import * @@ -37,6 +38,9 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): method='search', label='Search', ) + status = django_filters.MultipleChoiceFilter( + choices=LinkStatusChoices + ) tag = TagFilter() class Meta: diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 65666ccb1..72af81f56 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -1,10 +1,11 @@ from django import forms -from dcim.models import * +from dcim.choices import LinkStatusChoices from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField from wireless.constants import SSID_MAX_LENGTH +from wireless.models import * __all__ = ( 'WirelessLANBulkEditForm', @@ -14,7 +15,7 @@ __all__ = ( class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), + queryset=WirelessLAN.objects.all(), widget=forms.MultipleHiddenInput ) vlan = DynamicModelChoiceField( @@ -35,13 +36,17 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), + queryset=WirelessLink.objects.all(), widget=forms.MultipleHiddenInput ) ssid = forms.CharField( max_length=SSID_MAX_LENGTH, required=False ) + status = forms.ChoiceField( + choices=LinkStatusChoices, + required=False + ) description = forms.CharField( required=False ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index caf322dc1..763305c38 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,7 +1,8 @@ +from dcim.choices import LinkStatusChoices from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN -from utilities.forms import CSVModelChoiceField +from utilities.forms import CSVChoiceField, CSVModelChoiceField from wireless.models import * __all__ = ( @@ -23,6 +24,10 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): class WirelessLinkCSVForm(CustomFieldModelCSVForm): + status = CSVChoiceField( + choices=LinkStatusChoices, + help_text='Connection status' + ) interface_a = CSVModelChoiceField( queryset=Interface.objects.all() ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index fa1912099..6da3cd716 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -1,8 +1,9 @@ from django import forms from django.utils.translation import gettext as _ +from dcim.choices import LinkStatusChoices from extras.forms import CustomFieldModelFilterForm -from utilities.forms import BootstrapMixin, TagFilterField +from utilities.forms import add_blank_choice, BootstrapMixin, StaticSelect, TagFilterField from wireless.models import * __all__ = ( @@ -42,4 +43,9 @@ class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): required=False, label='SSID' ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(LinkStatusChoices), + widget=StaticSelect() + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index c494fb5a2..174eb5983 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -2,7 +2,7 @@ from dcim.models import Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN -from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect from wireless.models import * __all__ = ( @@ -55,5 +55,8 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'interface_a', 'interface_b', 'ssid', 'description', 'tags', + 'interface_a', 'interface_b', 'status', 'ssid', 'description', 'tags', ] + widgets = { + 'status': StaticSelect, + } diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 2fb07e5fd..8eb042d7d 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -42,6 +42,7 @@ class Migration(migrations.Migration): ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(blank=True, max_length=32)), + ('status', models.CharField(default='connected', max_length=50)), ('description', models.CharField(blank=True, max_length=200)), ('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), ('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index f8c947385..d02358f1c 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel @@ -70,6 +71,11 @@ class WirelessLink(PrimaryModel): blank=True, verbose_name='SSID' ) + status = models.CharField( + max_length=50, + choices=LinkStatusChoices, + default=LinkStatusChoices.STATUS_CONNECTED + ) description = models.CharField( max_length=200, blank=True @@ -94,6 +100,8 @@ class WirelessLink(PrimaryModel): objects = RestrictedQuerySet.as_manager() + clone_fields = ('ssid', 'status') + class Meta: ordering = ['pk'] unique_together = ('interface_a', 'interface_b') @@ -104,6 +112,9 @@ class WirelessLink(PrimaryModel): def get_absolute_url(self): return reverse('wireless:wirelesslink', args=[self.pk]) + def get_status_class(self): + return LinkStatusChoices.CSS_CLASSES.get(self.status) + def clean(self): # Validate interface types diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 31c9e56a8..9b0ef7291 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from .models import * -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ChoiceFieldColumn, TagColumn, ToggleColumn __all__ = ( 'WirelessLANTable', @@ -30,6 +30,7 @@ class WirelessLinkTable(BaseTable): linkify=True, verbose_name='ID' ) + status = ChoiceFieldColumn() interface_a = tables.Column( linkify=True ) @@ -42,5 +43,5 @@ class WirelessLinkTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLink - fields = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') - default_columns = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') + fields = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') + default_columns = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') From 43f2d4a331253ababf5a2f7e3dcb809cfa98200d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 15:00:35 -0400 Subject: [PATCH 015/289] Add SVG trace support for WirelessLinks --- netbox/dcim/svg.py | 81 ++++++++++++++---- netbox/project-static/dist/cable_trace.css | Bin 1032 -> 1133 bytes netbox/project-static/styles/cable-trace.scss | 7 +- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 5601bc591..b7f1576ee 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -398,6 +398,39 @@ class CableTraceSVG: return group + def _draw_wirelesslink(self, url, labels): + """ + Draw a line with labels representing a WirelessLink. + + :param url: Hyperlink URL + :param labels: Iterable of text labels + """ + group = Group(class_='connector') + + # Draw the wireless link + start = (OFFSET + self.center, self.cursor) + height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 + end = (start[0], start[1] + height) + line = Line(start=start, end=end, class_='wireless-link') + group.add(line) + + self.cursor += PADDING * 2 + + # Add link + link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') + + # Add text label(s) + for i, label in enumerate(labels): + self.cursor += LINE_HEIGHT + text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) + text = Text(label, insert=text_coords, class_='bold' if not i else []) + link.add(text) + + group.add(link) + self.cursor += PADDING * 2 + + return group + def _draw_attachment(self): """ Return an SVG group containing a line element and "Attachment" label. @@ -418,6 +451,9 @@ class CableTraceSVG: """ Return an SVG document representing a cable trace. """ + from dcim.models import Cable + from wireless.models import WirelessLink + traced_path = self.origin.trace() # Prep elements list @@ -452,24 +488,39 @@ class CableTraceSVG: ) terminations.append(termination) - # Connector (either a Cable or attachment to a ProviderNetwork) + # Connector (a Cable or WirelessLink) if connector is not None: # Cable - cable_labels = [ - f'Cable {connector}', - connector.get_status_display() - ] - if connector.type: - cable_labels.append(connector.get_type_display()) - if connector.length and connector.length_unit: - cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}') - cable = self._draw_cable( - color=connector.color or '000000', - url=connector.get_absolute_url(), - labels=cable_labels - ) - connectors.append(cable) + if type(connector) is Cable: + connector_labels = [ + f'Cable {connector}', + connector.get_status_display() + ] + if connector.type: + connector_labels.append(connector.get_type_display()) + if connector.length and connector.length_unit: + connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}') + cable = self._draw_cable( + color=connector.color or '000000', + url=connector.get_absolute_url(), + labels=connector_labels + ) + connectors.append(cable) + + # WirelessLink + elif type(connector) is WirelessLink: + connector_labels = [ + f'Wireless link {connector}', + connector.get_status_display() + ] + if connector.ssid: + connector_labels.append(connector.ssid) + wirelesslink = self._draw_wirelesslink( + url=connector.get_absolute_url(), + labels=connector_labels + ) + connectors.append(wirelesslink) # Far end termination termination = self._draw_box( diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css index 633ccd57232933af139eeee3562e2af22b52336f..50622f1284d056b81de5e643b27c1fa1d8487cdc 100644 GIT binary patch delta 69 zcmeC+c+0Wj05flSW>IQRYH_h{PG(;A4DZ)5ghs^tOzh>96K delta 18 ZcmaFM(ZR9d0P|!`7AH0x(}IdxE&x781 Date: Wed, 13 Oct 2021 16:40:12 -0400 Subject: [PATCH 016/289] Add WirelessLANGroup model --- netbox/netbox/navigation_menu.py | 3 +- netbox/templates/wireless/wirelesslan.html | 10 +++ .../templates/wireless/wirelesslangroup.html | 72 ++++++++++++++++++ netbox/wireless/api/nested_serializers.py | 11 +++ netbox/wireless/api/serializers.py | 15 +++- netbox/wireless/api/urls.py | 1 + netbox/wireless/api/views.py | 12 +++ netbox/wireless/filtersets.py | 18 ++++- netbox/wireless/forms/bulk_edit.py | 25 ++++++- netbox/wireless/forms/bulk_import.py | 25 ++++++- netbox/wireless/forms/filtersets.py | 30 +++++++- netbox/wireless/forms/models.py | 27 ++++++- netbox/wireless/graphql/schema.py | 6 ++ netbox/wireless/graphql/types.py | 9 +++ netbox/wireless/migrations/0001_wireless.py | 25 ++++++- netbox/wireless/models.py | 49 ++++++++++++- netbox/wireless/tables.py | 23 +++++- netbox/wireless/urls.py | 11 +++ netbox/wireless/views.py | 73 +++++++++++++++++++ 19 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 netbox/templates/wireless/wirelesslangroup.html diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index b3e11f6ce..7f64a2df8 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -168,6 +168,7 @@ CONNECTIONS_MENU = Menu( label='Connections', items=( get_model_item('dcim', 'cable', 'Cables', actions=['import']), + get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), MenuItem( link='dcim:interface_connections_list', link_text='Interface Connections', @@ -196,7 +197,7 @@ WIRELESS_MENU = Menu( label='Wireless', items=( get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), - get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), + get_model_item('wireless', 'wirelesslangroup', 'Wireless LAN Groups'), ), ), ), diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index f8fabf558..f2133cd54 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -13,6 +13,16 @@ + + + + diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html new file mode 100644 index 000000000..170f72eff --- /dev/null +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -0,0 +1,72 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + {% for group in object.get_ancestors %} + + {% endfor %} +{% endblock %} + +{% block content %} +
+
+
+
Wireless LAN Group
+
+
Status{{ object.get_status_display }}
SSID {{ object.ssid|placeholder }}SSID {{ object.ssid }}
Group + {% if object.group %} + {{ object.group }} + {% else %} + None + {% endif %} +
Description {{ object.description|placeholder }}
+ + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
Wireless LANs + {{ wirelesslans_table.rows|length }} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
+ +
+
+
+
Wireless LANs
+
+ {% include 'inc/table.html' with table=wirelesslans_table %} +
+ {% if perms.wireless.add_wirelesslan %} + + {% endif %} +
+ {% include 'inc/paginator.html' with paginator=wirelesslans_table.paginator page=wirelesslans_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index 5a8cf6671..e9a840bfc 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -5,10 +5,21 @@ from wireless.models import * __all__ = ( 'NestedWirelessLANSerializer', + 'NestedWirelessLANGroupSerializer', 'NestedWirelessLinkSerializer', ) +class NestedWirelessLANGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') + wirelesslan_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = WirelessLANGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'wirelesslan_count', '_depth'] + + class NestedWirelessLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 5a7330129..24395b77c 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api import ChoiceField -from netbox.api.serializers import PrimaryModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from wireless.models import * from .nested_serializers import * @@ -14,6 +14,19 @@ __all__ = ( ) +class WirelessLANGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') + parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True) + wirelesslan_count = serializers.IntegerField(read_only=True) + + class Meta: + model = WirelessLANGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'wirelesslan_count', '_depth', + ] + + class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 431bb05f8..54f764db6 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -5,6 +5,7 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView +router.register('wireless-lan-groupss', views.WirelessLANGroupViewSet) router.register('wireless-lans', views.WirelessLANViewSet) router.register('wireless-links', views.WirelessLinkViewSet) diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index aa361a7f7..734f6940f 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -14,6 +14,18 @@ class WirelessRootView(APIRootView): return 'Wireless' +class WirelessLANGroupViewSet(CustomFieldModelViewSet): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + serializer_class = serializers.WirelessLANGroupSerializer + filterset_class = filtersets.WirelessLANGroupFilterSet + + class WirelessLANViewSet(CustomFieldModelViewSet): queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') serializer_class = serializers.WirelessLANSerializer diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index f765172dd..ac503e474 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -3,15 +3,31 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from extras.filters import TagFilter -from netbox.filtersets import PrimaryModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from .models import * __all__ = ( 'WirelessLANFilterSet', + 'WirelessLANGroupFilterSet', 'WirelessLinkFilterSet', ) +class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all() + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=WirelessLANGroup.objects.all(), + to_field_name='slug' + ) + + class Meta: + model = WirelessLANGroup + fields = ['id', 'name', 'slug', 'description'] + + class WirelessLANFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 72af81f56..c0d5a925e 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -9,15 +9,38 @@ from wireless.models import * __all__ = ( 'WirelessLANBulkEditForm', + 'WirelessLANGroupBulkEditForm', 'WirelessLinkBulkEditForm', ) +class WirelessLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=WirelessLAN.objects.all(), widget=forms.MultipleHiddenInput ) + group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -31,7 +54,7 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode ) class Meta: - nullable_fields = ['vlan', 'ssid', 'description'] + nullable_fields = ['ssid', 'group', 'vlan', 'description'] class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 763305c38..6b22728f6 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -2,16 +2,37 @@ from dcim.choices import LinkStatusChoices from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN -from utilities.forms import CSVChoiceField, CSVModelChoiceField +from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.models import * __all__ = ( 'WirelessLANCSVForm', + 'WirelessLANGroupCSVForm', 'WirelessLinkCSVForm', ) +class WirelessLANGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent group' + ) + slug = SlugField() + + class Meta: + model = WirelessLANGroup + fields = ('name', 'slug', 'parent', 'description') + + class WirelessLANCSVForm(CustomFieldModelCSVForm): + group = CSVModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), to_field_name='name', @@ -20,7 +41,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): class Meta: model = WirelessLAN - fields = ('ssid', 'description', 'vlan') + fields = ('ssid', 'group', 'description', 'vlan') class WirelessLinkCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 6da3cd716..13aae99a5 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -3,19 +3,38 @@ from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices from extras.forms import CustomFieldModelFilterForm -from utilities.forms import add_blank_choice, BootstrapMixin, StaticSelect, TagFilterField +from utilities.forms import ( + add_blank_choice, BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField, +) from wireless.models import * __all__ = ( 'WirelessLANFilterForm', + 'WirelessLANGroupFilterForm', 'WirelessLinkFilterForm', ) +class WirelessLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLANGroup + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + + class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = WirelessLAN field_groups = [ - ['q', 'tag'], + ('q', 'tag'), + ('group_id',), ] q = forms.CharField( required=False, @@ -26,6 +45,13 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): required=False, label='SSID' ) + group_id = DynamicModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + null_option='None', + label=_('Group'), + fetch_trigger='open' + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 174eb5983..ca20b6ea8 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -2,16 +2,37 @@ from dcim.models import Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN -from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect +from utilities.forms import ( + BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect, +) from wireless.models import * __all__ = ( 'WirelessLANForm', + 'WirelessLANGroupForm', 'WirelessLinkForm', ) +class WirelessLANGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = WirelessLANGroup + fields = [ + 'parent', 'name', 'slug', 'description', + ] + + class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): + group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False @@ -24,10 +45,10 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'description', 'vlan', 'tags', + 'ssid', 'group', 'description', 'vlan', 'tags', ] fieldsets = ( - ('Wireless LAN', ('ssid', 'description', 'tags')), + ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), ('VLAN', ('vlan',)), ) diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index 8297f4545..05fc57c4d 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -7,3 +7,9 @@ from .types import * class WirelessQuery(graphene.ObjectType): wirelesslan = ObjectField(WirelessLANType) wirelesslan_list = ObjectListField(WirelessLANType) + + wirelesslangroup = ObjectField(WirelessLANGroupType) + wirelesslangroup_list = ObjectListField(WirelessLANGroupType) + + wirelesslink = ObjectField(WirelessLinkType) + wirelesslink_list = ObjectListField(WirelessLinkType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 0afd8e69a..4697cc44b 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -3,10 +3,19 @@ from netbox.graphql.types import ObjectType __all__ = ( 'WirelessLANType', + 'WirelessLANGroupType', 'WirelessLinkType', ) +class WirelessLANGroupType(ObjectType): + + class Meta: + model = models.WirelessLANGroup + fields = '__all__' + filterset_class = filtersets.WirelessLANGroupFilterSet + + class WirelessLANType(ObjectType): class Meta: diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 8eb042d7d..068f4d64a 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -1,8 +1,7 @@ -# Generated by Django 3.2.8 on 2021-10-13 13:44 - import django.core.serializers.json from django.db import migrations, models import django.db.models.deletion +import mptt.fields import taggit.managers @@ -17,6 +16,27 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='WirelessLANGroup', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='wireless.wirelesslangroup')), + ], + options={ + 'ordering': ('name', 'pk'), + 'unique_together': {('parent', 'name')}, + }, + ), migrations.CreateModel( name='WirelessLAN', fields=[ @@ -25,6 +45,7 @@ class Migration(migrations.Migration): ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(max_length=32)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')), ('description', models.CharField(blank=True, max_length=200)), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index d02358f1c..12c4e55aa 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,20 +1,58 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features -from netbox.models import BigIDModel, PrimaryModel +from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .constants import SSID_MAX_LENGTH __all__ = ( 'WirelessLAN', + 'WirelessLANGroup', 'WirelessLink', ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class WirelessLANGroup(NestedGroupModel): + """ + A nested grouping of WirelessLANs + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ('name', 'pk') + unique_together = ( + ('parent', 'name') + ) + + def get_absolute_url(self): + return reverse('wireless:wirelesslangroup', args=[self.pk]) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLAN(PrimaryModel): """ @@ -24,6 +62,13 @@ class WirelessLAN(PrimaryModel): max_length=SSID_MAX_LENGTH, verbose_name='SSID' ) + group = models.ForeignKey( + to='wireless.WirelessLANGroup', + on_delete=models.SET_NULL, + related_name='wireless_lans', + blank=True, + null=True + ) vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.PROTECT, @@ -100,7 +145,7 @@ class WirelessLink(PrimaryModel): objects = RestrictedQuerySet.as_manager() - clone_fields = ('ssid', 'status') + clone_fields = ('ssid', 'group', 'status') class Meta: ordering = ['pk'] diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 9b0ef7291..58d77b56f 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,14 +1,35 @@ import django_tables2 as tables +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, +) from .models import * -from utilities.tables import BaseTable, ChoiceFieldColumn, TagColumn, ToggleColumn __all__ = ( 'WirelessLANTable', + 'WirelessLANGroupTable', 'WirelessLinkTable', ) +class WirelessLANGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + wirelesslan_count = LinkedCountColumn( + viewname='wireless:wirelesslan_list', + url_params={'group_id': 'pk'}, + verbose_name='Wireless LANs' + ) + actions = ButtonsColumn(WirelessLANGroup) + + class Meta(BaseTable.Meta): + model = WirelessLANGroup + fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions') + + class WirelessLANTable(BaseTable): pk = ToggleColumn() ssid = tables.Column( diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index 21d704e6a..684f55ad5 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -7,6 +7,17 @@ from .models import * app_name = 'wireless' urlpatterns = ( + # Wireless LAN groups + path('wireless-lan-groups/', views.WirelessLANGroupListView.as_view(), name='wirelesslangroup_list'), + path('wireless-lan-groups/add/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_add'), + path('wireless-lan-groups/import/', views.WirelessLANGroupBulkImportView.as_view(), name='wirelesslangroup_import'), + path('wireless-lan-groups/edit/', views.WirelessLANGroupBulkEditView.as_view(), name='wirelesslangroup_bulk_edit'), + path('wireless-lan-groups/delete/', views.WirelessLANGroupBulkDeleteView.as_view(), name='wirelesslangroup_bulk_delete'), + path('wireless-lan-groups//', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'), + path('wireless-lan-groups//edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'), + path('wireless-lan-groups//delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'), + path('wireless-lan-groups//changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}), + # Wireless LANs path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), path('wireless-lans/add/', views.WirelessLANEditView.as_view(), name='wirelesslan_add'), diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 041ffbd42..6405d46df 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,8 +1,81 @@ from netbox.views import generic +from utilities.tables import paginate_table from . import filtersets, forms, tables from .models import * +# +# Wireless LAN groups +# + +class WirelessLANGroupListView(generic.ObjectListView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + filterset_form = forms.WirelessLANGroupFilterForm + table = tables.WirelessLANGroupTable + + +class WirelessLANGroupView(generic.ObjectView): + queryset = WirelessLANGroup.objects.all() + + def get_extra_context(self, request, instance): + wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter( + group=instance + ) + wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) + paginate_table(wirelesslans_table, request) + + return { + 'wirelesslans_table': wirelesslans_table, + } + + +class WirelessLANGroupEditView(generic.ObjectEditView): + queryset = WirelessLANGroup.objects.all() + model_form = forms.WirelessLANGroupForm + + +class WirelessLANGroupDeleteView(generic.ObjectDeleteView): + queryset = WirelessLANGroup.objects.all() + + +class WirelessLANGroupBulkImportView(generic.BulkImportView): + queryset = WirelessLANGroup.objects.all() + model_form = forms.WirelessLANGroupCSVForm + table = tables.WirelessLANGroupTable + + +class WirelessLANGroupBulkEditView(generic.BulkEditView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + table = tables.WirelessLANGroupTable + form = forms.WirelessLANGroupBulkEditForm + + +class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + table = tables.WirelessLANGroupTable + + # # Wireless LANs # From 438b4b47581f2b47a19243f3e848aabe52830b38 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 20:16:36 -0400 Subject: [PATCH 017/289] Add rf_role to Interface --- netbox/dcim/api/serializers.py | 3 ++- netbox/dcim/choices.py | 10 ++++++++ netbox/dcim/filtersets.py | 4 ++-- netbox/dcim/forms/bulk_edit.py | 2 +- netbox/dcim/forms/bulk_import.py | 7 +++++- netbox/dcim/forms/filtersets.py | 14 ++++++++--- netbox/dcim/forms/models.py | 5 ++-- netbox/dcim/forms/object_create.py | 10 ++++++-- netbox/dcim/graphql/types.py | 3 +++ netbox/dcim/migrations/0136_wireless.py | 7 ++++-- netbox/dcim/models/device_components.py | 12 ++++++++-- netbox/dcim/tables/devices.py | 9 +++---- netbox/templates/dcim/interface.html | 29 +++++++++++++++-------- netbox/templates/dcim/interface_edit.html | 1 + 14 files changed, 86 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 03d14a160..af1b6eeb0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -632,6 +632,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -648,7 +649,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', + 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d58d5466d..b0ebf7cf4 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1139,6 +1139,16 @@ class CableLengthUnitChoices(ChoiceSet): # Wireless # +class WirelessRoleChoices(ChoiceSet): + ROLE_AP = 'ap' + ROLE_STATION = 'station' + + CHOICES = ( + (ROLE_AP, 'Access point'), + (ROLE_STATION, 'Station'), + ) + + class WirelessChannelChoices(ChoiceSet): CHANNEL_AUTO = 'auto' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f778fd083..ab4336dbf 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -991,8 +991,8 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT class Meta: model = Interface fields = [ - 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_channel', 'rf_channel_width', - 'description', + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_width', 'description', ] def filter_device(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index f710e7d8c..382a0570e 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -926,7 +926,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_channel', 'rf_channel_width', + 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 95b4dd136..675e850ed 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -579,12 +579,17 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): required=False, help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) + rf_role = CSVChoiceField( + choices=WirelessRoleChoices, + required=False, + help_text='Wireless role (AP/station)' + ) class Meta: model = Interface fields = ( 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', + 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 169b93028..a6f25ae00 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -963,7 +963,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], - ['rf_channel', 'rf_channel_width'], + ['rf_role', 'rf_channel', 'rf_channel_width'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ] type = forms.MultipleChoiceField( @@ -991,15 +991,23 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + rf_role = forms.MultipleChoiceField( + choices=WirelessRoleChoices, + required=False, + widget=StaticSelectMultiple(), + label='Wireless role' + ) rf_channel = forms.MultipleChoiceField( choices=WirelessChannelChoices, required=False, - widget=StaticSelectMultiple() + widget=StaticSelectMultiple(), + label='Wireless channel' ) rf_channel_width = forms.MultipleChoiceField( choices=WirelessChannelWidthChoices, required=False, - widget=StaticSelectMultiple() + widget=StaticSelectMultiple(), + label='Channel width' ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index cd697e9f3..4cfa40fe2 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1104,13 +1104,14 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'wireless_lans', 'untagged_vlan', - 'tagged_vlans', 'tags', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'mode': StaticSelect(), + 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), 'rf_channel_width': StaticSelect(), } diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index db28412e6..3998dcbc1 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -467,6 +467,12 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, widget=StaticSelect() ) + rf_role = forms.ChoiceField( + choices=add_blank_choice(WirelessRoleChoices), + required=False, + widget=StaticSelect(), + label='Wireless role' + ) rf_channel = forms.ChoiceField( choices=add_blank_choice(WirelessChannelChoices), required=False, @@ -489,8 +495,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'rf_channel', 'rf_channel_width', 'mode' 'untagged_vlan', - 'tagged_vlans', 'tags' + 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_width', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 55f1ba150..3d489973c 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -206,6 +206,9 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType): def resolve_mode(self, info): return self.mode or None + def resolve_rf_role(self, info): + return self.rf_role or None + def resolve_rf_channel(self, info): return self.rf_channel or None diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 3b33f7d3f..7a3ee9673 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.8 on 2021-10-13 13:44 - from django.db import migrations, models @@ -10,6 +8,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='interface', + name='rf_role', + field=models.CharField(blank=True, max_length=30), + ), migrations.AddField( model_name='interface', name='rf_channel', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index f8649b419..74724baf8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -524,6 +524,12 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): verbose_name='WWN', help_text='64-bit World Wide Name' ) + rf_role = models.CharField( + max_length=30, + choices=WirelessRoleChoices, + blank=True, + verbose_name='Wireless role' + ) rf_channel = models.CharField( max_length=50, choices=WirelessChannelChoices, @@ -636,9 +642,11 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # RF channel attributes may be set only for wireless interfaces - if self.rf_channel and self.type not in WIRELESS_IFACE_TYPES: + if self.rf_role and not self.is_wireless: + raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."}) + if self.rf_channel and not self.is_wireless: raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) - if self.rf_channel_width and self.type not in WIRELESS_IFACE_TYPES: + if self.rf_channel_width and not self.is_wireless: raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) # Validate untagged VLAN diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a375a77cc..7f2ff1c71 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -496,8 +496,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', + 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -528,8 +528,9 @@ class DeviceInterfaceTable(InterfaceTable): model = Interface fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', - 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', + 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', + 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bf24a89ef..90c9497ef 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -39,16 +39,6 @@ Type {{ object.get_type_display }} - {% if object.is_wireless %} - - Channel - {{ object.get_rf_channel_display|placeholder }} - - - Channel Width - {{ object.get_rf_channel_width_display|placeholder }} - - {% endif %} Enabled @@ -274,6 +264,25 @@ {% endif %} {% if object.is_wireless %} +
+
Wireless
+
+ + + + + + + + + + + + + +
Role{{ object.get_rf_role_display|placeholder }}
Channel{{ object.get_rf_channel_display|placeholder }}
Channel Width{{ object.get_rf_channel_width_display|placeholder }}
+
+
Wireless LANs
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 51834f4e2..cb8d51828 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -34,6 +34,7 @@
Wireless
+ {% render_field form.rf_role %} {% render_field form.rf_channel %} {% render_field form.rf_channel_width %} {% render_field form.wireless_lans %} From 4c475c1b33018be900393a74082d381eb23d0429 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 20:56:14 -0400 Subject: [PATCH 018/289] Extend wireless channel choices --- netbox/dcim/choices.py | 108 +++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 32 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index b0ebf7cf4..18537812c 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1200,6 +1200,28 @@ class WirelessChannelChoices(ChoiceSet): CHANNEL_5G_124 = '5g-124' CHANNEL_5G_126 = '5g-126' CHANNEL_5G_128 = '5g-128' + CHANNEL_5G_132 = '5g-132' + CHANNEL_5G_134 = '5g-134' + CHANNEL_5G_136 = '5g-136' + CHANNEL_5G_138 = '5g-138' + CHANNEL_5G_140 = '5g-140' + CHANNEL_5G_142 = '5g-142' + CHANNEL_5G_144 = '5g-144' + CHANNEL_5G_149 = '5g-149' + CHANNEL_5G_151 = '5g-151' + CHANNEL_5G_153 = '5g-153' + CHANNEL_5G_155 = '5g-155' + CHANNEL_5G_157 = '5g-157' + CHANNEL_5G_159 = '5g-159' + CHANNEL_5G_161 = '5g-161' + CHANNEL_5G_163 = '5g-163' + CHANNEL_5G_165 = '5g-165' + CHANNEL_5G_167 = '5g-167' + CHANNEL_5G_169 = '5g-169' + CHANNEL_5G_171 = '5g-171' + CHANNEL_5G_173 = '5g-173' + CHANNEL_5G_175 = '5g-175' + CHANNEL_5G_177 = '5g-177' CHOICES = ( (CHANNEL_AUTO, 'Auto'), @@ -1224,38 +1246,60 @@ class WirelessChannelChoices(ChoiceSet): ( '5 GHz (802.11a/n/ac/ax)', ( - (CHANNEL_5G_32, '32 (5160 MHz)'), - (CHANNEL_5G_34, '34 (5170 MHz)'), - (CHANNEL_5G_36, '36 (5180 MHz)'), - (CHANNEL_5G_38, '38 (5190 MHz)'), - (CHANNEL_5G_40, '40 (5200 MHz)'), - (CHANNEL_5G_42, '42 (5210 MHz)'), - (CHANNEL_5G_44, '44 (5220 MHz)'), - (CHANNEL_5G_46, '46 (5230 MHz)'), - (CHANNEL_5G_48, '48 (5240 MHz)'), - (CHANNEL_5G_50, '50 (5250 MHz)'), - (CHANNEL_5G_52, '52 (5260 MHz)'), - (CHANNEL_5G_54, '54 (5270 MHz)'), - (CHANNEL_5G_56, '56 (5280 MHz)'), - (CHANNEL_5G_58, '58 (5290 MHz)'), - (CHANNEL_5G_60, '60 (5300 MHz)'), - (CHANNEL_5G_62, '62 (5310 MHz)'), - (CHANNEL_5G_64, '64 (5320 MHz)'), - (CHANNEL_5G_100, '100 (5500 MHz)'), - (CHANNEL_5G_102, '102 (5510 MHz)'), - (CHANNEL_5G_104, '104 (5520 MHz)'), - (CHANNEL_5G_106, '106 (5530 MHz)'), - (CHANNEL_5G_108, '108 (5540 MHz)'), - (CHANNEL_5G_110, '110 (5550 MHz)'), - (CHANNEL_5G_112, '112 (5560 MHz)'), - (CHANNEL_5G_114, '114 (5570 MHz)'), - (CHANNEL_5G_116, '116 (5580 MHz)'), - (CHANNEL_5G_118, '118 (5590 MHz)'), - (CHANNEL_5G_120, '120 (5600 MHz)'), - (CHANNEL_5G_122, '122 (5610 MHz)'), - (CHANNEL_5G_124, '124 (5620 MHz)'), - (CHANNEL_5G_126, '126 (5630 MHz)'), - (CHANNEL_5G_128, '128 (5640 MHz)'), + (CHANNEL_5G_32, '32 (5160/20 MHz)'), + (CHANNEL_5G_34, '34 (5170/40 MHz)'), + (CHANNEL_5G_36, '36 (5180/20 MHz)'), + (CHANNEL_5G_38, '38 (5190/40 MHz)'), + (CHANNEL_5G_40, '40 (5200/20 MHz)'), + (CHANNEL_5G_42, '42 (5210/80 MHz)'), + (CHANNEL_5G_44, '44 (5220/20 MHz)'), + (CHANNEL_5G_46, '46 (5230/40 MHz)'), + (CHANNEL_5G_48, '48 (5240/20 MHz)'), + (CHANNEL_5G_50, '50 (5250/160 MHz)'), + (CHANNEL_5G_52, '52 (5260/20 MHz)'), + (CHANNEL_5G_54, '54 (5270/40 MHz)'), + (CHANNEL_5G_56, '56 (5280/20 MHz)'), + (CHANNEL_5G_58, '58 (5290/80 MHz)'), + (CHANNEL_5G_60, '60 (5300/20 MHz)'), + (CHANNEL_5G_62, '62 (5310/40 MHz)'), + (CHANNEL_5G_64, '64 (5320/20 MHz)'), + (CHANNEL_5G_100, '100 (5500/20 MHz)'), + (CHANNEL_5G_102, '102 (5510/40 MHz)'), + (CHANNEL_5G_104, '104 (5520/20 MHz)'), + (CHANNEL_5G_106, '106 (5530/80 MHz)'), + (CHANNEL_5G_108, '108 (5540/20 MHz)'), + (CHANNEL_5G_110, '110 (5550/40 MHz)'), + (CHANNEL_5G_112, '112 (5560/20 MHz)'), + (CHANNEL_5G_114, '114 (5570/160 MHz)'), + (CHANNEL_5G_116, '116 (5580/20 MHz)'), + (CHANNEL_5G_118, '118 (5590/40 MHz)'), + (CHANNEL_5G_120, '120 (5600/20 MHz)'), + (CHANNEL_5G_122, '122 (5610/80 MHz)'), + (CHANNEL_5G_124, '124 (5620/20 MHz)'), + (CHANNEL_5G_126, '126 (5630/40 MHz)'), + (CHANNEL_5G_128, '128 (5640/20 MHz)'), + (CHANNEL_5G_132, '132 (5660/20 MHz)'), + (CHANNEL_5G_134, '134 (5670/40 MHz)'), + (CHANNEL_5G_136, '136 (5680/20 MHz)'), + (CHANNEL_5G_138, '138 (5690/80 MHz)'), + (CHANNEL_5G_140, '140 (5700/20 MHz)'), + (CHANNEL_5G_142, '142 (5710/40 MHz)'), + (CHANNEL_5G_144, '144 (5720/20 MHz)'), + (CHANNEL_5G_149, '149 (5745/20 MHz)'), + (CHANNEL_5G_151, '151 (5755/40 MHz)'), + (CHANNEL_5G_153, '153 (5765/20 MHz)'), + (CHANNEL_5G_155, '155 (5775/80 MHz)'), + (CHANNEL_5G_157, '157 (5785/20 MHz)'), + (CHANNEL_5G_159, '159 (5795/40 MHz)'), + (CHANNEL_5G_161, '161 (5805/20 MHz)'), + (CHANNEL_5G_163, '163 (5815/160 MHz)'), + (CHANNEL_5G_165, '165 (5825/20 MHz)'), + (CHANNEL_5G_167, '167 (5835/40 MHz)'), + (CHANNEL_5G_169, '169 (5845/20 MHz)'), + (CHANNEL_5G_171, '171 (5855/80 MHz)'), + (CHANNEL_5G_173, '173 (5865/20 MHz)'), + (CHANNEL_5G_175, '175 (5875/40 MHz)'), + (CHANNEL_5G_177, '177 (5885/20 MHz)'), ) ), ) From bdf359470ea749f46a98ffb7f163a1b04996e0f5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 09:48:12 -0400 Subject: [PATCH 019/289] Include WirelessLAN attached interfaces --- netbox/templates/wireless/wirelesslan.html | 11 +++++++-- netbox/wireless/models.py | 2 +- netbox/wireless/tables.py | 26 ++++++++++++++++++++-- netbox/wireless/views.py | 17 +++++++++++++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index f2133cd54..cfe13ca45 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -49,8 +49,15 @@
-
- {% plugin_full_width_page object %} +
+
+
Attached Interfaces
+
+ {% include 'inc/table.html' with table=interfaces_table %} +
+ {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %} + {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 12c4e55aa..b0cacde15 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -145,7 +145,7 @@ class WirelessLink(PrimaryModel): objects = RestrictedQuerySet.as_manager() - clone_fields = ('ssid', 'group', 'status') + clone_fields = ('ssid', 'status') class Meta: ordering = ['pk'] diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 58d77b56f..671e948d2 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,5 +1,6 @@ import django_tables2 as tables +from dcim.models import Interface from utilities.tables import ( BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, ) @@ -35,14 +36,35 @@ class WirelessLANTable(BaseTable): ssid = tables.Column( linkify=True ) + group = tables.Column( + linkify=True + ) + interface_count = tables.Column( + verbose_name='Interfaces' + ) tags = TagColumn( url_name='wireless:wirelesslan_list' ) class Meta(BaseTable.Meta): model = WirelessLAN - fields = ('pk', 'ssid', 'description', 'vlan') - default_columns = ('pk', 'ssid', 'description', 'vlan') + fields = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'tags') + default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count') + + +class WirelessLANInterfacesTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = Interface + fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel') + default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel') class WirelessLinkTable(BaseTable): diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 6405d46df..a9238df33 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,5 +1,7 @@ +from dcim.models import Interface from netbox.views import generic from utilities.tables import paginate_table +from utilities.utils import count_related from . import filtersets, forms, tables from .models import * @@ -81,7 +83,9 @@ class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView): # class WirelessLANListView(generic.ObjectListView): - queryset = WirelessLAN.objects.all() + queryset = WirelessLAN.objects.annotate( + interface_count=count_related(Interface, 'wireless_lans') + ) filterset = filtersets.WirelessLANFilterSet filterset_form = forms.WirelessLANFilterForm table = tables.WirelessLANTable @@ -90,6 +94,17 @@ class WirelessLANListView(generic.ObjectListView): class WirelessLANView(generic.ObjectView): queryset = WirelessLAN.objects.all() + def get_extra_context(self, request, instance): + attached_interfaces = Interface.objects.restrict(request.user, 'view').filter( + wireless_lans=instance + ) + interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) + paginate_table(interfaces_table, request) + + return { + 'interfaces_table': interfaces_table, + } + class WirelessLANEditView(generic.ObjectEditView): queryset = WirelessLAN.objects.all() From fb9da87abb8462c811391bf5bba530394f9b9f25 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 10:02:05 -0400 Subject: [PATCH 020/289] Add devices to WirelessLinkForm --- netbox/dcim/models/device_components.py | 4 ++++ netbox/wireless/forms/models.py | 20 ++++++++++++++++---- netbox/wireless/tables.py | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 74724baf8..39c618f4d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -656,6 +656,10 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): "device, or it must be global".format(self.untagged_vlan) }) + @property + def _occupied(self): + return super()._occupied or bool(self.wireless_link_id) + @property def is_wired(self): return not self.is_virtual and not self.is_wireless diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index ca20b6ea8..a3454c79a 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,4 +1,4 @@ -from dcim.models import Interface +from dcim.models import Device, Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN @@ -54,18 +54,30 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): + device_a = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device A' + ) interface_a = DynamicModelChoiceField( queryset=Interface.objects.all(), query_params={ - 'kind': 'wireless' + 'kind': 'wireless', + 'device_id': '$device_a', }, + disabled_indicator='_occupied', label='Interface A' ) + device_b = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device B' + ) interface_b = DynamicModelChoiceField( queryset=Interface.objects.all(), query_params={ - 'kind': 'wireless' + 'kind': 'wireless', + 'device_id': '$device_b', }, + disabled_indicator='_occupied', label='Interface B' ) tags = DynamicModelMultipleChoiceField( @@ -76,7 +88,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'interface_a', 'interface_b', 'status', 'ssid', 'description', 'tags', + 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 671e948d2..486fa2a71 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -74,9 +74,17 @@ class WirelessLinkTable(BaseTable): verbose_name='ID' ) status = ChoiceFieldColumn() + device_a = tables.Column( + accessor=tables.A('interface_a__device'), + linkify=True + ) interface_a = tables.Column( linkify=True ) + device_b = tables.Column( + accessor=tables.A('interface_b__device'), + linkify=True + ) interface_b = tables.Column( linkify=True ) @@ -86,5 +94,7 @@ class WirelessLinkTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLink - fields = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') - default_columns = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') + fields = ('pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description') + default_columns = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', + ) From 909b83c537cb08782802af17759f83c26f8237c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 10:06:46 -0400 Subject: [PATCH 021/289] Include interface RF attributes on wireless link view --- .../wireless/inc/wirelesslink_interface.html | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index 9c4669ad1..82f7cfd8d 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -1,3 +1,5 @@ +{% load helpers %} + @@ -17,4 +19,16 @@ {{ interface.get_type_display }} + + + + + + + +
Device
Role + {{ interface.get_rf_role_display|placeholder }} +
Channel + {{ interface.get_rf_channel_display|placeholder }} +
From 176bd2396be49c9783b2a2e86a4acb3d6aeb4612 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 14:48:00 -0400 Subject: [PATCH 022/289] Closes #6711: Add longtext custom field type with Markdown support --- docs/models/extras/customfield.md | 1 + docs/release-notes/version-3.1.md | 1 + netbox/extras/choices.py | 2 + netbox/extras/models/customfields.py | 21 ++++- netbox/extras/tests/test_customfields.py | 86 ++++++++++++++++--- netbox/extras/tests/test_forms.py | 3 + netbox/templates/inc/custom_fields_panel.html | 6 +- 7 files changed, 101 insertions(+), 19 deletions(-) diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index 52b8bab1e..7294fbd34 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -11,6 +11,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: * Text: Free-form text (up to 255 characters) +* Long text: Free-form of any length; supports Markdown rendering * Integer: A whole number (positive or negative) * Boolean: True or false * Date: A date in ISO 8601 format (YYYY-MM-DD) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 00a6e2fda..23567d68e 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces +* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations ### Other Changes diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 4452b5aad..4f350fc9b 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -8,6 +8,7 @@ from utilities.choices import ChoiceSet class CustomFieldTypeChoices(ChoiceSet): TYPE_TEXT = 'text' + TYPE_LONGTEXT = 'longtext' TYPE_INTEGER = 'integer' TYPE_BOOLEAN = 'boolean' TYPE_DATE = 'date' @@ -17,6 +18,7 @@ class CustomFieldTypeChoices(ChoiceSet): CHOICES = ( (TYPE_TEXT, 'Text'), + (TYPE_LONGTEXT, 'Text (long)'), (TYPE_INTEGER, 'Integer'), (TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_DATE, 'Date'), diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index d8e2e11c9..8c0193eaa 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -166,7 +166,10 @@ class CustomField(ChangeLoggedModel): # Validate the field's default value (if any) if self.default is not None: try: - default_value = str(self.default) if self.type == CustomFieldTypeChoices.TYPE_TEXT else self.default + if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): + default_value = str(self.default) + else: + default_value = self.default self.validate(default_value) except ValidationError as err: raise ValidationError({ @@ -184,7 +187,11 @@ class CustomField(ChangeLoggedModel): }) # Regex validation can be set only for text fields - regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL) + regex_types = ( + CustomFieldTypeChoices.TYPE_TEXT, + CustomFieldTypeChoices.TYPE_LONGTEXT, + CustomFieldTypeChoices.TYPE_URL, + ) if self.validation_regex and self.type not in regex_types: raise ValidationError({ 'validation_regex': "Regular expression validation is supported only for text and URL fields" @@ -275,7 +282,13 @@ class CustomField(ChangeLoggedModel): # Text else: - field = forms.CharField(max_length=255, required=required, initial=initial) + if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: + max_length = None + widget = forms.Textarea + else: + max_length = 255 + widget = None + field = forms.CharField(max_length=max_length, required=required, initial=initial, widget=widget) if self.validation_regex: field.validators = [ RegexValidator( @@ -298,7 +311,7 @@ class CustomField(ChangeLoggedModel): if value not in [None, '']: # Validate text field - if self.type == CustomFieldTypeChoices.TYPE_TEXT: + if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): if type(value) is not str: raise ValidationError(f"Value must be a string.") if self.validation_regex and not re.match(self.validation_regex, value): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 32c473678..0a40aeba9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -24,13 +24,46 @@ class CustomFieldTest(TestCase): def test_simple_fields(self): DATA = ( - {'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, - {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': '2016-06-23', 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''}, + { + 'field_type': CustomFieldTypeChoices.TYPE_TEXT, + 'field_value': 'Foobar!', + 'empty_value': '', + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT, + 'field_value': 'Text with **Markdown**', + 'empty_value': '', + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, + 'field_value': 0, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, + 'field_value': 42, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, + 'field_value': True, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, + 'field_value': False, + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_DATE, + 'field_value': '2016-06-23', + 'empty_value': None, + }, + { + 'field_type': CustomFieldTypeChoices.TYPE_URL, + 'field_value': 'http://example.com/', + 'empty_value': '', + }, ) obj_type = ContentType.objects.get_for_model(Site) @@ -149,6 +182,11 @@ class CustomFieldAPITest(APITestCase): cls.cf_text.save() cls.cf_text.content_types.set([content_type]) + # Long text custom field + cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') + cls.cf_longtext.save() + cls.cf_longtext.content_types.set([content_type]) + # Integer custom field cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) cls.cf_integer.save() @@ -185,6 +223,7 @@ class CustomFieldAPITest(APITestCase): # Assign custom field values for site 2 cls.sites[1].custom_field_data = { cls.cf_text.name: 'bar', + cls.cf_longtext.name: 'DEF', cls.cf_integer.name: 456, cls.cf_boolean.name: True, cls.cf_date.name: '2020-01-02', @@ -204,6 +243,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['name'], self.sites[0].name) self.assertEqual(response.data['custom_fields'], { 'text_field': None, + 'longtext_field': None, 'number_field': None, 'boolean_field': None, 'date_field': None, @@ -222,6 +262,7 @@ class CustomFieldAPITest(APITestCase): response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.sites[1].name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) + self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_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']) @@ -245,6 +286,7 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], self.cf_text.default) + self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['date_field'], self.cf_date.default) @@ -254,6 +296,7 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) + self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) @@ -269,6 +312,7 @@ class CustomFieldAPITest(APITestCase): 'slug': 'site-3', 'custom_fields': { 'text_field': 'bar', + 'longtext_field': 'blah blah blah', 'number_field': 456, 'boolean_field': True, 'date_field': '2020-01-02', @@ -286,6 +330,7 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data['custom_fields'] data_cf = data['custom_fields'] self.assertEqual(response_cf['text_field'], data_cf['text_field']) + self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field']) self.assertEqual(response_cf['number_field'], data_cf['number_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) self.assertEqual(response_cf['date_field'], data_cf['date_field']) @@ -295,6 +340,7 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field']) self.assertEqual(site.custom_field_data['number_field'], data_cf['number_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']) @@ -332,6 +378,7 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], self.cf_text.default) + self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['date_field'], self.cf_date.default) @@ -341,6 +388,7 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) + self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) @@ -353,6 +401,7 @@ class CustomFieldAPITest(APITestCase): """ custom_field_data = { 'text_field': 'bar', + 'longtext_field': 'abcdefghij', 'number_field': 456, 'boolean_field': True, 'date_field': '2020-01-02', @@ -388,6 +437,7 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], custom_field_data['text_field']) + self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(response_cf['number_field'], custom_field_data['number_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) @@ -397,6 +447,7 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_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']) @@ -426,6 +477,7 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field']) self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field']) + self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_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['url_field'], original_cfvs['url_field']) @@ -435,6 +487,7 @@ class CustomFieldAPITest(APITestCase): site.refresh_from_db() self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field']) + self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) @@ -491,11 +544,14 @@ class CustomFieldImportTest(TestCase): custom_fields = ( CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), + CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), - CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Choice A', 'Choice B', 'Choice C']), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ + 'Choice A', 'Choice B', 'Choice C', + ]), ) for cf in custom_fields: cf.save() @@ -506,10 +562,10 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'status', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), - ('Site 1', 'site-1', 'active', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), - ('Site 2', 'site-2', 'active', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'), - ('Site 3', 'site-3', 'active', '', '', '', '', '', ''), + ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) @@ -518,8 +574,9 @@ class CustomFieldImportTest(TestCase): # Validate data for site 1 site1 = Site.objects.get(name='Site 1') - self.assertEqual(len(site1.custom_field_data), 6) + self.assertEqual(len(site1.custom_field_data), 7) 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['boolean'], True) self.assertEqual(site1.custom_field_data['date'], '2020-01-01') @@ -528,8 +585,9 @@ class CustomFieldImportTest(TestCase): # Validate data for site 2 site2 = Site.objects.get(name='Site 2') - self.assertEqual(len(site2.custom_field_data), 6) + self.assertEqual(len(site2.custom_field_data), 7) 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['boolean'], False) self.assertEqual(site2.custom_field_data['date'], '2020-01-02') diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cb0a9c081..1ccc2332b 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -17,6 +17,9 @@ class CustomFieldModelFormTest(TestCase): cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) cf_text.content_types.set([obj_type]) + cf_longtext = CustomField.objects.create(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT) + cf_longtext.content_types.set([obj_type]) + cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) cf_integer.content_types.set([obj_type]) diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index fd0379961..eb6e490e7 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -1,3 +1,5 @@ +{% load helpers %} + {% with custom_fields=object.get_custom_fields %} {% if custom_fields %}
@@ -10,7 +12,9 @@ {{ field }} - {% if field.type == 'boolean' and value == True %} + {% if field.type == 'longtext' and value %} + {{ value|render_markdown }} + {% elif field.type == 'boolean' and value == True %} {% elif field.type == 'boolean' and value == False %} From 64dad7dbd282b8576e9211b5ae3e7ba4d78e6d06 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 15:11:03 -0400 Subject: [PATCH 023/289] Optimize migrations --- .../migrations/0003_rename_cable_peer.py | 2 -- ...able_peer.py => 0136_rename_cable_peer.py} | 4 +-- netbox/dcim/migrations/0136_wireless.py | 26 ------------------- netbox/dcim/migrations/0137_wireless.py | 25 +++++++++++++++--- .../0138_interface_wireless_link.py | 20 -------------- netbox/wireless/migrations/0001_wireless.py | 2 +- 6 files changed, 24 insertions(+), 55 deletions(-) rename netbox/dcim/migrations/{0139_rename_cable_peer.py => 0136_rename_cable_peer.py} (96%) delete mode 100644 netbox/dcim/migrations/0136_wireless.py delete mode 100644 netbox/dcim/migrations/0138_interface_wireless_link.py diff --git a/netbox/circuits/migrations/0003_rename_cable_peer.py b/netbox/circuits/migrations/0003_rename_cable_peer.py index 475a84d0f..63dc1006e 100644 --- a/netbox/circuits/migrations/0003_rename_cable_peer.py +++ b/netbox/circuits/migrations/0003_rename_cable_peer.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.8 on 2021-10-13 17:47 - from django.db import migrations diff --git a/netbox/dcim/migrations/0139_rename_cable_peer.py b/netbox/dcim/migrations/0136_rename_cable_peer.py similarity index 96% rename from netbox/dcim/migrations/0139_rename_cable_peer.py rename to netbox/dcim/migrations/0136_rename_cable_peer.py index 62a4bacdd..6958458e8 100644 --- a/netbox/dcim/migrations/0139_rename_cable_peer.py +++ b/netbox/dcim/migrations/0136_rename_cable_peer.py @@ -1,12 +1,10 @@ -# Generated by Django 3.2.8 on 2021-10-13 17:47 - from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('dcim', '0138_interface_wireless_link'), + ('dcim', '0135_location_tenant'), ] operations = [ diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py deleted file mode 100644 index 7a3ee9673..000000000 --- a/netbox/dcim/migrations/0136_wireless.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0135_location_tenant'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='rf_role', - field=models.CharField(blank=True, max_length=30), - ), - migrations.AddField( - model_name='interface', - name='rf_channel', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='interface', - name='rf_channel_width', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0137_wireless.py b/netbox/dcim/migrations/0137_wireless.py index 9108735a1..788157c23 100644 --- a/netbox/dcim/migrations/0137_wireless.py +++ b/netbox/dcim/migrations/0137_wireless.py @@ -1,19 +1,38 @@ -# Generated by Django 3.2.8 on 2021-10-13 13:44 - from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('dcim', '0136_wireless'), + ('dcim', '0136_rename_cable_peer'), ('wireless', '0001_wireless'), ] operations = [ + migrations.AddField( + model_name='interface', + name='rf_role', + field=models.CharField(blank=True, max_length=30), + ), + migrations.AddField( + model_name='interface', + name='rf_channel', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='rf_channel_width', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), migrations.AddField( model_name='interface', name='wireless_lans', field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), ), + migrations.AddField( + model_name='interface', + name='wireless_link', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), + ), ] diff --git a/netbox/dcim/migrations/0138_interface_wireless_link.py b/netbox/dcim/migrations/0138_interface_wireless_link.py deleted file mode 100644 index 42b7a1042..000000000 --- a/netbox/dcim/migrations/0138_interface_wireless_link.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-13 15:29 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('wireless', '0001_wireless'), - ('dcim', '0137_wireless'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='wireless_link', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), - ), - ] diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 068f4d64a..9adc8757b 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0136_wireless'), + ('dcim', '0136_rename_cable_peer'), ('extras', '0062_clear_secrets_changelog'), ('ipam', '0050_iprange'), ] From 2c2c2e9060db4b4243dbd20a9afc352f4aaa9887 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 15:38:29 -0400 Subject: [PATCH 024/289] 3839: Add airflow field to DeviceType --- netbox/dcim/api/serializers.py | 3 ++- netbox/dcim/choices.py | 19 +++++++++++++++++++ netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_edit.py | 5 +++++ netbox/dcim/forms/filtersets.py | 7 ++++++- netbox/dcim/forms/models.py | 7 +++++-- netbox/dcim/forms/object_import.py | 2 +- netbox/dcim/graphql/types.py | 3 +++ .../migrations/0136_devicetype_airflow.py | 18 ++++++++++++++++++ netbox/dcim/models/devices.py | 9 ++++++++- netbox/dcim/tables/devicetypes.py | 2 +- netbox/dcim/tests/test_filtersets.py | 8 ++++++-- netbox/templates/dcim/devicetype.html | 6 ++++++ 13 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 netbox/dcim/migrations/0136_devicetype_airflow.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d6e44c281..e42b0246b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -288,13 +288,14 @@ class DeviceTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) + airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType fields = [ 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', + 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index acea294f8..a4c3cb983 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -174,6 +174,25 @@ class DeviceStatusChoices(ChoiceSet): } +class DeviceAirflowChoices(ChoiceSet): + + AIRFLOW_FRONT_TO_REAR = 'front-to-rear' + AIRFLOW_REAR_TO_FRONT = 'rear-to-front' + AIRFLOW_LEFT_TO_RIGHT = 'left-to-right' + AIRFLOW_RIGHT_TO_LEFT = 'right-to-left' + AIRFLOW_SIDE_TO_REAR = 'side-to-rear' + AIRFLOW_PASSIVE = 'passive' + + CHOICES = ( + (AIRFLOW_FRONT_TO_REAR, 'Front to rear'), + (AIRFLOW_REAR_TO_FRONT, 'Rear to front'), + (AIRFLOW_LEFT_TO_RIGHT, 'Left to right'), + (AIRFLOW_RIGHT_TO_LEFT, 'Right to left'), + (AIRFLOW_SIDE_TO_REAR, 'Side to rear'), + (AIRFLOW_PASSIVE, 'Passive'), + ) + + # # ConsolePorts # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7f029097e..ee7957a92 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -441,7 +441,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): class Meta: model = DeviceType fields = [ - 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', ] def search(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index fd87d7304..1cc79ee48 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -335,6 +335,11 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel widget=BulkEditNullBooleanSelect(), label='Is full depth' ) + airflow = forms.ChoiceField( + choices=add_blank_choice(DeviceAirflowChoices), + required=False, + widget=StaticSelect() + ) class Meta: nullable_fields = [] diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4f4e10e96..e6b9ec8c4 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -385,7 +385,7 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = DeviceType field_groups = [ ['q', 'tag'], - ['manufacturer_id', 'subdevice_role'], + ['manufacturer_id', 'subdevice_role', 'airflow'], ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], ] q = forms.CharField( @@ -404,6 +404,11 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): required=False, widget=StaticSelectMultiple() ) + airflow = forms.MultipleChoiceField( + choices=add_blank_choice(DeviceAirflowChoices), + required=False, + widget=StaticSelectMultiple() + ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a8c2991a4..f0059e770 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -367,12 +367,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', ] fieldsets = ( ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags', + 'manufacturer', 'model', 'slug', 'part_number', 'tags', + )), + ('Chassis', ( + 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', )), ('Images', ('front_image', 'rear_image')), ) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 0596261a6..03f040a00 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -26,7 +26,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'comments', ] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index be10556be..0f186c5d4 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -179,6 +179,9 @@ class DeviceTypeType(PrimaryObjectType): def resolve_subdevice_role(self, info): return self.subdevice_role or None + def resolve_airflow(self, info): + return self.airflow or None + class FrontPortType(ComponentObjectType): diff --git a/netbox/dcim/migrations/0136_devicetype_airflow.py b/netbox/dcim/migrations/0136_devicetype_airflow.py new file mode 100644 index 000000000..2b3bd215f --- /dev/null +++ b/netbox/dcim/migrations/0136_devicetype_airflow.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2021-10-14 19:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0135_location_tenant'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='airflow', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 10cd35c13..2a4f58d10 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -115,6 +115,12 @@ class DeviceType(PrimaryModel): help_text='Parent devices house child devices in device bays. Leave blank ' 'if this device type is neither a parent nor a child.' ) + airflow = models.CharField( + max_length=50, + choices=DeviceAirflowChoices, + blank=True, + verbose_name='Airflow direction' + ) front_image = models.ImageField( upload_to='devicetype-images', blank=True @@ -130,7 +136,7 @@ class DeviceType(PrimaryModel): objects = RestrictedQuerySet.as_manager() clone_fields = [ - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', + 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', ] class Meta: @@ -165,6 +171,7 @@ class DeviceType(PrimaryModel): ('u_height', self.u_height), ('is_full_depth', self.is_full_depth), ('subdevice_role', self.subdevice_role), + ('airflow', self.airflow), ('comments', self.comments), )) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3b11a180b..b3310d5d2 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -77,7 +77,7 @@ class DeviceTypeTable(BaseTable): model = DeviceType fields = ( 'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'comments', 'instance_count', 'tags', + 'airflow', 'comments', 'instance_count', 'tags', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fb94bde08..f9ecf103f 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -638,8 +638,8 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): device_types = ( DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True), - DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), - DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), ) DeviceType.objects.bulk_create(device_types) @@ -704,6 +704,10 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'subdevice_role': SubdeviceRoleChoices.ROLE_PARENT} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_airflow(self): + params = {'airflow': DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_manufacturer(self): manufacturers = Manufacturer.objects.all()[:2] params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 2a9f4a93b..2db37121f 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -90,6 +90,12 @@ {{ object.get_subdevice_role_display|placeholder }} + + Airflow direction + + {{ object.get_airflow_display|placeholder }} + + Front Image From 33ea8763d51e6fcc3d34a29fa227eefd9238ad2a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 16:04:42 -0400 Subject: [PATCH 025/289] 3839: Add airflow field to Device --- docs/models/dcim/devicetype.md | 2 ++ docs/release-notes/version-3.1.md | 1 + netbox/dcim/api/serializers.py | 7 ++++--- netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_edit.py | 9 +++++++-- netbox/dcim/forms/bulk_import.py | 9 +++++++-- netbox/dcim/forms/filtersets.py | 7 ++++++- netbox/dcim/forms/models.py | 5 +++-- netbox/dcim/graphql/types.py | 3 +++ ...vicetype_airflow.py => 0136_device_airflow.py} | 7 +++++-- netbox/dcim/models/devices.py | 15 +++++++++++---- netbox/dcim/tables/devices.py | 4 ++-- netbox/dcim/tests/test_filtersets.py | 8 ++++++-- netbox/templates/dcim/device.html | 6 ++++++ netbox/templates/dcim/device_edit.html | 1 + netbox/templates/dcim/devicetype.html | 2 +- 16 files changed, 66 insertions(+), 22 deletions(-) rename netbox/dcim/migrations/{0136_devicetype_airflow.py => 0136_device_airflow.py} (67%) diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index a7e00dbc6..b919465c8 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -12,3 +12,5 @@ Some devices house child devices which share physical resources, like space and !!! note This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + +A device type may optionally specify an airflow direction, such as front-to-rear, rear-to-front, or passive. Airflow direction may also be set separately per device. If it is not defined for a device at the time of its creation, it will inherit the airflow setting of its device type. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 23567d68e..c49552edd 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces +* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index e42b0246b..9d261d9e8 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -465,6 +465,7 @@ class DeviceSerializer(PrimaryModelSerializer): rack = NestedRackSerializer(required=False, allow_null=True) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) + airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) @@ -476,9 +477,9 @@ class DeviceSerializer(PrimaryModelSerializer): model = Device fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', - 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'created', 'last_updated', + 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', + 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index ee7957a92..c3de7cb08 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -751,7 +751,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex class Meta: model = Device - fields = ['id', 'name', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority'] + fields = ['id', 'name', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 1cc79ee48..289057be9 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -342,7 +342,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel ) class Meta: - nullable_fields = [] + nullable_fields = ['airflow'] class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): @@ -434,6 +434,11 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk required=False, widget=StaticSelect() ) + airflow = forms.ChoiceField( + choices=add_blank_choice(DeviceAirflowChoices), + required=False, + widget=StaticSelect() + ) serial = forms.CharField( max_length=50, required=False, @@ -442,7 +447,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk class Meta: nullable_fields = [ - 'tenant', 'platform', 'serial', + 'tenant', 'platform', 'serial', 'airflow', ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index ff9ab6fff..bd9e8cd4a 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -369,12 +369,17 @@ class DeviceCSVForm(BaseDeviceCSVForm): required=False, help_text='Mounted rack face' ) + airflow = CSVChoiceField( + choices=DeviceAirflowChoices, + required=False, + help_text='Airflow direction' + ) class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', - 'comments', + 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', + 'cluster', 'comments', ] def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index e6b9ec8c4..94e7bce05 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -490,7 +490,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt field_groups = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], - ['status', 'role_id', 'serial', 'asset_tag', 'mac_address'], + ['status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address'], ['manufacturer_id', 'device_type_id', 'platform_id'], ['tenant_group_id', 'tenant_id'], [ @@ -579,6 +579,11 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, widget=StaticSelectMultiple() ) + airflow = forms.MultipleChoiceField( + choices=add_blank_choice(DeviceAirflowChoices), + required=False, + widget=StaticSelectMultiple() + ) serial = forms.CharField( required=False ) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index f0059e770..cb690840f 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -522,8 +522,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', - 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', - 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' + 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', + 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -534,6 +534,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): widgets = { 'face': StaticSelect(), 'status': StaticSelect(), + 'airflow': StaticSelect(), 'primary_ip4': StaticSelect(), 'primary_ip6': StaticSelect(), } diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 0f186c5d4..80c32e66d 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -144,6 +144,9 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, PrimaryObjectType): def resolve_face(self, info): return self.face or None + def resolve_airflow(self, info): + return self.airflow or None + class DeviceBayType(ComponentObjectType): diff --git a/netbox/dcim/migrations/0136_devicetype_airflow.py b/netbox/dcim/migrations/0136_device_airflow.py similarity index 67% rename from netbox/dcim/migrations/0136_devicetype_airflow.py rename to netbox/dcim/migrations/0136_device_airflow.py index 2b3bd215f..a0887a0b4 100644 --- a/netbox/dcim/migrations/0136_devicetype_airflow.py +++ b/netbox/dcim/migrations/0136_device_airflow.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.8 on 2021-10-14 19:29 - from django.db import migrations, models @@ -15,4 +13,9 @@ class Migration(migrations.Migration): name='airflow', field=models.CharField(blank=True, max_length=50), ), + migrations.AddField( + model_name='device', + name='airflow', + field=models.CharField(blank=True, max_length=50), + ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2a4f58d10..669f5cfbd 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -118,8 +118,7 @@ class DeviceType(PrimaryModel): airflow = models.CharField( max_length=50, choices=DeviceAirflowChoices, - blank=True, - verbose_name='Airflow direction' + blank=True ) front_image = models.ImageField( upload_to='devicetype-images', @@ -537,6 +536,11 @@ class Device(PrimaryModel, ConfigContextModel): choices=DeviceStatusChoices, default=DeviceStatusChoices.STATUS_ACTIVE ) + airflow = models.CharField( + max_length=50, + choices=DeviceAirflowChoices, + blank=True + ) primary_ip4 = models.OneToOneField( to='ipam.IPAddress', on_delete=models.SET_NULL, @@ -587,7 +591,7 @@ class Device(PrimaryModel, ConfigContextModel): objects = ConfigContextModelQuerySet.as_manager() clone_fields = [ - 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster', + 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'airflow', 'cluster', ] class Meta: @@ -748,9 +752,12 @@ class Device(PrimaryModel, ConfigContextModel): }) def save(self, *args, **kwargs): - is_new = not bool(self.pk) + # Inherit airflow attribute from DeviceType if not set + if is_new and not self.airflow: + self.airflow = self.device_type.airflow + super().save(*args, **kwargs) # If this is a new Device, instantiate all of the related components per the DeviceType definition diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c2b4b907b..a2d3f3da2 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -197,8 +197,8 @@ class DeviceTable(BaseTable): model = Device fields = ( 'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', - 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', + 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f9ecf103f..fcee2914b 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1239,8 +1239,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): devices = ( Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), - Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), - Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), ) Device.objects.bulk_create(devices) @@ -1394,6 +1394,10 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'is_full_depth': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_airflow(self): + params = {'airflow': DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_mac_address(self): params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 9d1868e1e..ec1ea3fa1 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -93,6 +93,12 @@ {{ object.device_type }} ({{ object.device_type.u_height }}U) + + Airflow + + {{ object.get_airflow_display|placeholder }} + + Serial Number {{ object.serial|placeholder }} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index fbafa197d..1be272d3a 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -19,6 +19,7 @@
{% render_field form.manufacturer %} {% render_field form.device_type %} + {% render_field form.airflow %} {% render_field form.serial %} {% render_field form.asset_tag %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 2db37121f..40955f5d6 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -91,7 +91,7 @@ - Airflow direction + Airflow {{ object.get_airflow_display|placeholder }} From 01d3c062f210bcb59bc126a19dfb41a3ca421348 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 10:00:03 -0400 Subject: [PATCH 026/289] Move wireless field choices to wireless app --- netbox/dcim/api/serializers.py | 1 + netbox/dcim/choices.py | 185 ------------------------ netbox/dcim/forms/bulk_import.py | 1 + netbox/dcim/forms/filtersets.py | 1 + netbox/dcim/forms/object_create.py | 1 + netbox/dcim/models/device_components.py | 1 + netbox/wireless/choices.py | 182 +++++++++++++++++++++++ 7 files changed, 187 insertions(+), 185 deletions(-) create mode 100644 netbox/wireless/choices.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f50733163..d7857d5e8 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -19,6 +19,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer +from wireless.choices import * from .nested_serializers import * diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index ae713d687..9f87dded0 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1154,191 +1154,6 @@ class CableLengthUnitChoices(ChoiceSet): ) -# -# Wireless -# - -class WirelessRoleChoices(ChoiceSet): - ROLE_AP = 'ap' - ROLE_STATION = 'station' - - CHOICES = ( - (ROLE_AP, 'Access point'), - (ROLE_STATION, 'Station'), - ) - - -class WirelessChannelChoices(ChoiceSet): - CHANNEL_AUTO = 'auto' - - # 2.4 GHz - CHANNEL_24G_1 = '2.4g-1' - CHANNEL_24G_2 = '2.4g-2' - CHANNEL_24G_3 = '2.4g-3' - CHANNEL_24G_4 = '2.4g-4' - CHANNEL_24G_5 = '2.4g-5' - CHANNEL_24G_6 = '2.4g-6' - CHANNEL_24G_7 = '2.4g-7' - CHANNEL_24G_8 = '2.4g-8' - CHANNEL_24G_9 = '2.4g-9' - CHANNEL_24G_10 = '2.4g-10' - CHANNEL_24G_11 = '2.4g-11' - CHANNEL_24G_12 = '2.4g-12' - CHANNEL_24G_13 = '2.4g-13' - - # 5 GHz - CHANNEL_5G_32 = '5g-32' - CHANNEL_5G_34 = '5g-34' - CHANNEL_5G_36 = '5g-36' - CHANNEL_5G_38 = '5g-38' - CHANNEL_5G_40 = '5g-40' - CHANNEL_5G_42 = '5g-42' - CHANNEL_5G_44 = '5g-44' - CHANNEL_5G_46 = '5g-46' - CHANNEL_5G_48 = '5g-48' - CHANNEL_5G_50 = '5g-50' - CHANNEL_5G_52 = '5g-52' - CHANNEL_5G_54 = '5g-54' - CHANNEL_5G_56 = '5g-56' - CHANNEL_5G_58 = '5g-58' - CHANNEL_5G_60 = '5g-60' - CHANNEL_5G_62 = '5g-62' - CHANNEL_5G_64 = '5g-64' - CHANNEL_5G_100 = '5g-100' - CHANNEL_5G_102 = '5g-102' - CHANNEL_5G_104 = '5g-104' - CHANNEL_5G_106 = '5g-106' - CHANNEL_5G_108 = '5g-108' - CHANNEL_5G_110 = '5g-110' - CHANNEL_5G_112 = '5g-112' - CHANNEL_5G_114 = '5g-114' - CHANNEL_5G_116 = '5g-116' - CHANNEL_5G_118 = '5g-118' - CHANNEL_5G_120 = '5g-120' - CHANNEL_5G_122 = '5g-122' - CHANNEL_5G_124 = '5g-124' - CHANNEL_5G_126 = '5g-126' - CHANNEL_5G_128 = '5g-128' - CHANNEL_5G_132 = '5g-132' - CHANNEL_5G_134 = '5g-134' - CHANNEL_5G_136 = '5g-136' - CHANNEL_5G_138 = '5g-138' - CHANNEL_5G_140 = '5g-140' - CHANNEL_5G_142 = '5g-142' - CHANNEL_5G_144 = '5g-144' - CHANNEL_5G_149 = '5g-149' - CHANNEL_5G_151 = '5g-151' - CHANNEL_5G_153 = '5g-153' - CHANNEL_5G_155 = '5g-155' - CHANNEL_5G_157 = '5g-157' - CHANNEL_5G_159 = '5g-159' - CHANNEL_5G_161 = '5g-161' - CHANNEL_5G_163 = '5g-163' - CHANNEL_5G_165 = '5g-165' - CHANNEL_5G_167 = '5g-167' - CHANNEL_5G_169 = '5g-169' - CHANNEL_5G_171 = '5g-171' - CHANNEL_5G_173 = '5g-173' - CHANNEL_5G_175 = '5g-175' - CHANNEL_5G_177 = '5g-177' - - CHOICES = ( - (CHANNEL_AUTO, 'Auto'), - ( - '2.4 GHz (802.11b/g/n/ax)', - ( - (CHANNEL_24G_1, '1 (2412 MHz)'), - (CHANNEL_24G_2, '2 (2417 MHz)'), - (CHANNEL_24G_3, '3 (2422 MHz)'), - (CHANNEL_24G_4, '4 (2427 MHz)'), - (CHANNEL_24G_5, '5 (2432 MHz)'), - (CHANNEL_24G_6, '6 (2437 MHz)'), - (CHANNEL_24G_7, '7 (2442 MHz)'), - (CHANNEL_24G_8, '8 (2447 MHz)'), - (CHANNEL_24G_9, '9 (2452 MHz)'), - (CHANNEL_24G_10, '10 (2457 MHz)'), - (CHANNEL_24G_11, '11 (2462 MHz)'), - (CHANNEL_24G_12, '12 (2467 MHz)'), - (CHANNEL_24G_13, '13 (2472 MHz)'), - ) - ), - ( - '5 GHz (802.11a/n/ac/ax)', - ( - (CHANNEL_5G_32, '32 (5160/20 MHz)'), - (CHANNEL_5G_34, '34 (5170/40 MHz)'), - (CHANNEL_5G_36, '36 (5180/20 MHz)'), - (CHANNEL_5G_38, '38 (5190/40 MHz)'), - (CHANNEL_5G_40, '40 (5200/20 MHz)'), - (CHANNEL_5G_42, '42 (5210/80 MHz)'), - (CHANNEL_5G_44, '44 (5220/20 MHz)'), - (CHANNEL_5G_46, '46 (5230/40 MHz)'), - (CHANNEL_5G_48, '48 (5240/20 MHz)'), - (CHANNEL_5G_50, '50 (5250/160 MHz)'), - (CHANNEL_5G_52, '52 (5260/20 MHz)'), - (CHANNEL_5G_54, '54 (5270/40 MHz)'), - (CHANNEL_5G_56, '56 (5280/20 MHz)'), - (CHANNEL_5G_58, '58 (5290/80 MHz)'), - (CHANNEL_5G_60, '60 (5300/20 MHz)'), - (CHANNEL_5G_62, '62 (5310/40 MHz)'), - (CHANNEL_5G_64, '64 (5320/20 MHz)'), - (CHANNEL_5G_100, '100 (5500/20 MHz)'), - (CHANNEL_5G_102, '102 (5510/40 MHz)'), - (CHANNEL_5G_104, '104 (5520/20 MHz)'), - (CHANNEL_5G_106, '106 (5530/80 MHz)'), - (CHANNEL_5G_108, '108 (5540/20 MHz)'), - (CHANNEL_5G_110, '110 (5550/40 MHz)'), - (CHANNEL_5G_112, '112 (5560/20 MHz)'), - (CHANNEL_5G_114, '114 (5570/160 MHz)'), - (CHANNEL_5G_116, '116 (5580/20 MHz)'), - (CHANNEL_5G_118, '118 (5590/40 MHz)'), - (CHANNEL_5G_120, '120 (5600/20 MHz)'), - (CHANNEL_5G_122, '122 (5610/80 MHz)'), - (CHANNEL_5G_124, '124 (5620/20 MHz)'), - (CHANNEL_5G_126, '126 (5630/40 MHz)'), - (CHANNEL_5G_128, '128 (5640/20 MHz)'), - (CHANNEL_5G_132, '132 (5660/20 MHz)'), - (CHANNEL_5G_134, '134 (5670/40 MHz)'), - (CHANNEL_5G_136, '136 (5680/20 MHz)'), - (CHANNEL_5G_138, '138 (5690/80 MHz)'), - (CHANNEL_5G_140, '140 (5700/20 MHz)'), - (CHANNEL_5G_142, '142 (5710/40 MHz)'), - (CHANNEL_5G_144, '144 (5720/20 MHz)'), - (CHANNEL_5G_149, '149 (5745/20 MHz)'), - (CHANNEL_5G_151, '151 (5755/40 MHz)'), - (CHANNEL_5G_153, '153 (5765/20 MHz)'), - (CHANNEL_5G_155, '155 (5775/80 MHz)'), - (CHANNEL_5G_157, '157 (5785/20 MHz)'), - (CHANNEL_5G_159, '159 (5795/40 MHz)'), - (CHANNEL_5G_161, '161 (5805/20 MHz)'), - (CHANNEL_5G_163, '163 (5815/160 MHz)'), - (CHANNEL_5G_165, '165 (5825/20 MHz)'), - (CHANNEL_5G_167, '167 (5835/40 MHz)'), - (CHANNEL_5G_169, '169 (5845/20 MHz)'), - (CHANNEL_5G_171, '171 (5855/80 MHz)'), - (CHANNEL_5G_173, '173 (5865/20 MHz)'), - (CHANNEL_5G_175, '175 (5875/40 MHz)'), - (CHANNEL_5G_177, '177 (5885/20 MHz)'), - ) - ), - ) - - -class WirelessChannelWidthChoices(ChoiceSet): - - CHANNEL_WIDTH_20 = 20 - CHANNEL_WIDTH_40 = 40 - CHANNEL_WIDTH_80 = 80 - CHANNEL_WIDTH_160 = 160 - - CHOICES = ( - (CHANNEL_WIDTH_20, '20 MHz'), - (CHANNEL_WIDTH_40, '40 MHz'), - (CHANNEL_WIDTH_80, '80 MHz'), - (CHANNEL_WIDTH_160, '160 MHz'), - ) - - # # PowerFeeds # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 4deda9df6..5ca009dee 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -11,6 +11,7 @@ from extras.forms import CustomFieldModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster +from wireless.choices import WirelessRoleChoices __all__ = ( 'CableCSVForm', diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 6334cbff6..50954e534 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -12,6 +12,7 @@ from utilities.forms import ( APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) +from wireless.choices import * __all__ = ( 'CableFilterForm', diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 3998dcbc1..ff8a19d47 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -10,6 +10,7 @@ from utilities.forms import ( add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, StaticSelect, ) +from wireless.choices import * from .common import InterfaceCommonForm __all__ = ( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 39c618f4d..381a2dcf6 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -18,6 +18,7 @@ from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar +from wireless.choices import * __all__ = ( diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py new file mode 100644 index 000000000..f33bf75a1 --- /dev/null +++ b/netbox/wireless/choices.py @@ -0,0 +1,182 @@ +from utilities.choices import ChoiceSet + + +class WirelessRoleChoices(ChoiceSet): + ROLE_AP = 'ap' + ROLE_STATION = 'station' + + CHOICES = ( + (ROLE_AP, 'Access point'), + (ROLE_STATION, 'Station'), + ) + + +class WirelessChannelChoices(ChoiceSet): + CHANNEL_AUTO = 'auto' + + # 2.4 GHz + CHANNEL_24G_1 = '2.4g-1' + CHANNEL_24G_2 = '2.4g-2' + CHANNEL_24G_3 = '2.4g-3' + CHANNEL_24G_4 = '2.4g-4' + CHANNEL_24G_5 = '2.4g-5' + CHANNEL_24G_6 = '2.4g-6' + CHANNEL_24G_7 = '2.4g-7' + CHANNEL_24G_8 = '2.4g-8' + CHANNEL_24G_9 = '2.4g-9' + CHANNEL_24G_10 = '2.4g-10' + CHANNEL_24G_11 = '2.4g-11' + CHANNEL_24G_12 = '2.4g-12' + CHANNEL_24G_13 = '2.4g-13' + + # 5 GHz + CHANNEL_5G_32 = '5g-32' + CHANNEL_5G_34 = '5g-34' + CHANNEL_5G_36 = '5g-36' + CHANNEL_5G_38 = '5g-38' + CHANNEL_5G_40 = '5g-40' + CHANNEL_5G_42 = '5g-42' + CHANNEL_5G_44 = '5g-44' + CHANNEL_5G_46 = '5g-46' + CHANNEL_5G_48 = '5g-48' + CHANNEL_5G_50 = '5g-50' + CHANNEL_5G_52 = '5g-52' + CHANNEL_5G_54 = '5g-54' + CHANNEL_5G_56 = '5g-56' + CHANNEL_5G_58 = '5g-58' + CHANNEL_5G_60 = '5g-60' + CHANNEL_5G_62 = '5g-62' + CHANNEL_5G_64 = '5g-64' + CHANNEL_5G_100 = '5g-100' + CHANNEL_5G_102 = '5g-102' + CHANNEL_5G_104 = '5g-104' + CHANNEL_5G_106 = '5g-106' + CHANNEL_5G_108 = '5g-108' + CHANNEL_5G_110 = '5g-110' + CHANNEL_5G_112 = '5g-112' + CHANNEL_5G_114 = '5g-114' + CHANNEL_5G_116 = '5g-116' + CHANNEL_5G_118 = '5g-118' + CHANNEL_5G_120 = '5g-120' + CHANNEL_5G_122 = '5g-122' + CHANNEL_5G_124 = '5g-124' + CHANNEL_5G_126 = '5g-126' + CHANNEL_5G_128 = '5g-128' + CHANNEL_5G_132 = '5g-132' + CHANNEL_5G_134 = '5g-134' + CHANNEL_5G_136 = '5g-136' + CHANNEL_5G_138 = '5g-138' + CHANNEL_5G_140 = '5g-140' + CHANNEL_5G_142 = '5g-142' + CHANNEL_5G_144 = '5g-144' + CHANNEL_5G_149 = '5g-149' + CHANNEL_5G_151 = '5g-151' + CHANNEL_5G_153 = '5g-153' + CHANNEL_5G_155 = '5g-155' + CHANNEL_5G_157 = '5g-157' + CHANNEL_5G_159 = '5g-159' + CHANNEL_5G_161 = '5g-161' + CHANNEL_5G_163 = '5g-163' + CHANNEL_5G_165 = '5g-165' + CHANNEL_5G_167 = '5g-167' + CHANNEL_5G_169 = '5g-169' + CHANNEL_5G_171 = '5g-171' + CHANNEL_5G_173 = '5g-173' + CHANNEL_5G_175 = '5g-175' + CHANNEL_5G_177 = '5g-177' + + CHOICES = ( + (CHANNEL_AUTO, 'Auto'), + ( + '2.4 GHz (802.11b/g/n/ax)', + ( + (CHANNEL_24G_1, '1 (2412 MHz)'), + (CHANNEL_24G_2, '2 (2417 MHz)'), + (CHANNEL_24G_3, '3 (2422 MHz)'), + (CHANNEL_24G_4, '4 (2427 MHz)'), + (CHANNEL_24G_5, '5 (2432 MHz)'), + (CHANNEL_24G_6, '6 (2437 MHz)'), + (CHANNEL_24G_7, '7 (2442 MHz)'), + (CHANNEL_24G_8, '8 (2447 MHz)'), + (CHANNEL_24G_9, '9 (2452 MHz)'), + (CHANNEL_24G_10, '10 (2457 MHz)'), + (CHANNEL_24G_11, '11 (2462 MHz)'), + (CHANNEL_24G_12, '12 (2467 MHz)'), + (CHANNEL_24G_13, '13 (2472 MHz)'), + ) + ), + ( + '5 GHz (802.11a/n/ac/ax)', + ( + (CHANNEL_5G_32, '32 (5160/20 MHz)'), + (CHANNEL_5G_34, '34 (5170/40 MHz)'), + (CHANNEL_5G_36, '36 (5180/20 MHz)'), + (CHANNEL_5G_38, '38 (5190/40 MHz)'), + (CHANNEL_5G_40, '40 (5200/20 MHz)'), + (CHANNEL_5G_42, '42 (5210/80 MHz)'), + (CHANNEL_5G_44, '44 (5220/20 MHz)'), + (CHANNEL_5G_46, '46 (5230/40 MHz)'), + (CHANNEL_5G_48, '48 (5240/20 MHz)'), + (CHANNEL_5G_50, '50 (5250/160 MHz)'), + (CHANNEL_5G_52, '52 (5260/20 MHz)'), + (CHANNEL_5G_54, '54 (5270/40 MHz)'), + (CHANNEL_5G_56, '56 (5280/20 MHz)'), + (CHANNEL_5G_58, '58 (5290/80 MHz)'), + (CHANNEL_5G_60, '60 (5300/20 MHz)'), + (CHANNEL_5G_62, '62 (5310/40 MHz)'), + (CHANNEL_5G_64, '64 (5320/20 MHz)'), + (CHANNEL_5G_100, '100 (5500/20 MHz)'), + (CHANNEL_5G_102, '102 (5510/40 MHz)'), + (CHANNEL_5G_104, '104 (5520/20 MHz)'), + (CHANNEL_5G_106, '106 (5530/80 MHz)'), + (CHANNEL_5G_108, '108 (5540/20 MHz)'), + (CHANNEL_5G_110, '110 (5550/40 MHz)'), + (CHANNEL_5G_112, '112 (5560/20 MHz)'), + (CHANNEL_5G_114, '114 (5570/160 MHz)'), + (CHANNEL_5G_116, '116 (5580/20 MHz)'), + (CHANNEL_5G_118, '118 (5590/40 MHz)'), + (CHANNEL_5G_120, '120 (5600/20 MHz)'), + (CHANNEL_5G_122, '122 (5610/80 MHz)'), + (CHANNEL_5G_124, '124 (5620/20 MHz)'), + (CHANNEL_5G_126, '126 (5630/40 MHz)'), + (CHANNEL_5G_128, '128 (5640/20 MHz)'), + (CHANNEL_5G_132, '132 (5660/20 MHz)'), + (CHANNEL_5G_134, '134 (5670/40 MHz)'), + (CHANNEL_5G_136, '136 (5680/20 MHz)'), + (CHANNEL_5G_138, '138 (5690/80 MHz)'), + (CHANNEL_5G_140, '140 (5700/20 MHz)'), + (CHANNEL_5G_142, '142 (5710/40 MHz)'), + (CHANNEL_5G_144, '144 (5720/20 MHz)'), + (CHANNEL_5G_149, '149 (5745/20 MHz)'), + (CHANNEL_5G_151, '151 (5755/40 MHz)'), + (CHANNEL_5G_153, '153 (5765/20 MHz)'), + (CHANNEL_5G_155, '155 (5775/80 MHz)'), + (CHANNEL_5G_157, '157 (5785/20 MHz)'), + (CHANNEL_5G_159, '159 (5795/40 MHz)'), + (CHANNEL_5G_161, '161 (5805/20 MHz)'), + (CHANNEL_5G_163, '163 (5815/160 MHz)'), + (CHANNEL_5G_165, '165 (5825/20 MHz)'), + (CHANNEL_5G_167, '167 (5835/40 MHz)'), + (CHANNEL_5G_169, '169 (5845/20 MHz)'), + (CHANNEL_5G_171, '171 (5855/80 MHz)'), + (CHANNEL_5G_173, '173 (5865/20 MHz)'), + (CHANNEL_5G_175, '175 (5875/40 MHz)'), + (CHANNEL_5G_177, '177 (5885/20 MHz)'), + ) + ), + ) + + +class WirelessChannelWidthChoices(ChoiceSet): + + CHANNEL_WIDTH_20 = 20 + CHANNEL_WIDTH_40 = 40 + CHANNEL_WIDTH_80 = 80 + CHANNEL_WIDTH_160 = 160 + + CHOICES = ( + (CHANNEL_WIDTH_20, '20 MHz'), + (CHANNEL_WIDTH_40, '40 MHz'), + (CHANNEL_WIDTH_80, '80 MHz'), + (CHANNEL_WIDTH_160, '160 MHz'), + ) From b7317bfe2911f792ead028db80d5797ba18ac166 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 10:06:49 -0400 Subject: [PATCH 027/289] Remove choices from rf_channel_width --- netbox/dcim/api/serializers.py | 1 - netbox/dcim/forms/filtersets.py | 6 ++---- netbox/dcim/forms/models.py | 1 - netbox/dcim/forms/object_create.py | 4 +--- netbox/dcim/models/device_components.py | 3 +-- netbox/templates/dcim/interface.html | 2 +- netbox/wireless/choices.py | 15 --------------- 7 files changed, 5 insertions(+), 27 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d7857d5e8..d2a9125c0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -637,7 +637,6 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) - rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 50954e534..fb9449d41 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1014,11 +1014,9 @@ class InterfaceFilterForm(DeviceComponentFilterForm): widget=StaticSelectMultiple(), label='Wireless channel' ) - rf_channel_width = forms.MultipleChoiceField( - choices=WirelessChannelWidthChoices, + rf_channel_width = forms.IntegerField( required=False, - widget=StaticSelectMultiple(), - label='Channel width' + label='Channel width (kHz)' ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 35b90291e..675319f11 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1117,7 +1117,6 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), - 'rf_channel_width': StaticSelect(), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index ff8a19d47..f924fb5d9 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -480,10 +480,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): widget=StaticSelect(), label='Wireless channel' ) - rf_channel_width = forms.ChoiceField( - choices=add_blank_choice(WirelessChannelWidthChoices), + rf_channel_width = forms.IntegerField( required=False, - widget=StaticSelect(), label='Channel width' ) untagged_vlan = DynamicModelChoiceField( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 381a2dcf6..bb18e0c26 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -538,10 +538,9 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): verbose_name='Wireless channel' ) rf_channel_width = models.PositiveSmallIntegerField( - choices=WirelessChannelWidthChoices, blank=True, null=True, - verbose_name='Channel width' + verbose_name='Channel width (kHz)' ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 90c9497ef..427ea8352 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -278,7 +278,7 @@ Channel Width - {{ object.get_rf_channel_width_display|placeholder }} + {{ object.rf_channel_width|placeholder }} diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index f33bf75a1..1369ee340 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -165,18 +165,3 @@ class WirelessChannelChoices(ChoiceSet): ) ), ) - - -class WirelessChannelWidthChoices(ChoiceSet): - - CHANNEL_WIDTH_20 = 20 - CHANNEL_WIDTH_40 = 40 - CHANNEL_WIDTH_80 = 80 - CHANNEL_WIDTH_160 = 160 - - CHOICES = ( - (CHANNEL_WIDTH_20, '20 MHz'), - (CHANNEL_WIDTH_40, '40 MHz'), - (CHANNEL_WIDTH_80, '80 MHz'), - (CHANNEL_WIDTH_160, '160 MHz'), - ) From 075f4907ef7e3da89c1e3f8aed418432857da9d3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 11:35:10 -0400 Subject: [PATCH 028/289] Store channel frequency & width as independent values --- netbox/dcim/api/serializers.py | 8 +- netbox/dcim/forms/bulk_edit.py | 4 +- netbox/dcim/forms/bulk_import.py | 3 +- netbox/dcim/forms/filtersets.py | 6 +- netbox/dcim/forms/models.py | 6 +- netbox/dcim/forms/object_create.py | 12 +- netbox/dcim/migrations/0138_wireless.py | 7 +- netbox/dcim/models/device_components.py | 40 +++++- netbox/dcim/tables/devices.py | 5 +- netbox/templates/dcim/interface.html | 18 ++- netbox/templates/dcim/interface_edit.html | 1 + .../wireless/inc/wirelesslink_interface.html | 20 +++ netbox/utilities/templatetags/helpers.py | 14 ++ netbox/wireless/choices.py | 136 +++++++++--------- netbox/wireless/forms/models.py | 10 +- netbox/wireless/utils.py | 27 ++++ 16 files changed, 223 insertions(+), 94 deletions(-) create mode 100644 netbox/wireless/utils.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d2a9125c0..4eeb717d7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -651,10 +651,10 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', '_occupied', + 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', + 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9c6a85885..e8e60a4f9 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -936,7 +936,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', + 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -988,7 +988,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', - 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', + 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 5ca009dee..4eb860836 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -595,7 +595,8 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', + 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index fb9449d41..e28714914 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1014,9 +1014,13 @@ class InterfaceFilterForm(DeviceComponentFilterForm): widget=StaticSelectMultiple(), label='Wireless channel' ) + rf_channel_frequency = forms.IntegerField( + required=False, + label='Channel frequency (MHz)' + ) rf_channel_width = forms.IntegerField( required=False, - label='Channel width (kHz)' + label='Channel width (MHz)' ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 675319f11..603767518 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1108,8 +1108,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1123,6 +1123,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): } 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)", } def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index f924fb5d9..547fe7e68 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -480,9 +480,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): widget=StaticSelect(), label='Wireless channel' ) - rf_channel_width = forms.IntegerField( + rf_channel_frequency = forms.DecimalField( required=False, - label='Channel width' + label='Channel frequency (MHz)' + ) + rf_channel_width = forms.DecimalField( + required=False, + label='Channel width (MHz)' ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -494,8 +498,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_width', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags' + 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/migrations/0138_wireless.py b/netbox/dcim/migrations/0138_wireless.py index faebcf268..bbdb28283 100644 --- a/netbox/dcim/migrations/0138_wireless.py +++ b/netbox/dcim/migrations/0138_wireless.py @@ -20,10 +20,15 @@ class Migration(migrations.Migration): name='rf_channel', field=models.CharField(blank=True, max_length=50), ), + migrations.AddField( + model_name='interface', + name='rf_channel_frequency', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True), + ), migrations.AddField( model_name='interface', name='rf_channel_width', - field=models.PositiveSmallIntegerField(blank=True, null=True), + field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True), ), migrations.AddField( model_name='interface', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index bb18e0c26..c2a37fcae 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -19,6 +19,7 @@ from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from wireless.choices import * +from wireless.utils import get_channel_attr __all__ = ( @@ -537,10 +538,19 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): blank=True, verbose_name='Wireless channel' ) - rf_channel_width = models.PositiveSmallIntegerField( + rf_channel_frequency = models.DecimalField( + max_digits=7, + decimal_places=2, blank=True, null=True, - verbose_name='Channel width (kHz)' + verbose_name='Channel frequency (MHz)' + ) + rf_channel_width = models.DecimalField( + max_digits=7, + decimal_places=3, + blank=True, + null=True, + verbose_name='Channel width (MHz)' ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', @@ -641,13 +651,33 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): if self.pk and self.lag_id == self.pk: raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) - # RF channel attributes may be set only for wireless interfaces + # RF role & channel may only be set for wireless interfaces if self.rf_role and not self.is_wireless: raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."}) if self.rf_channel and not self.is_wireless: raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) - if self.rf_channel_width and not self.is_wireless: - raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + + # Validate channel frequency against interface type and selected channel (if any) + if self.rf_channel_frequency: + if not self.is_wireless: + raise ValidationError({ + 'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.", + }) + if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'): + raise ValidationError({ + 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.", + }) + elif self.rf_channel: + self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency') + + # Validate channel width against interface type and selected channel (if any) + if self.rf_channel_width: + if not self.is_wireless: + raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'): + raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."}) + elif self.rf_channel: + self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b0ef9807e..3b0ec349e 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -496,8 +496,9 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mark_connected', + 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', + 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 427ea8352..6e01dee98 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -276,9 +276,25 @@ Channel {{ object.get_rf_channel_display|placeholder }} + + Channel Frequency + + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + Channel Width - {{ object.rf_channel_width|placeholder }} + + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index cb8d51828..de7d21269 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -36,6 +36,7 @@ {% render_field form.rf_role %} {% render_field form.rf_channel %} + {% render_field form.rf_channel_frequency %} {% render_field form.rf_channel_width %} {% render_field form.wireless_lans %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index 82f7cfd8d..e33047539 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -31,4 +31,24 @@ {{ interface.get_rf_channel_display|placeholder }} + + Channel Frequency + + {% if interface.rf_channel_frequency %} + {{ interface.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + + + Channel Width + + {% if interface.rf_channel_width %} + {{ interface.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index a900d59e2..668596c8e 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,4 +1,5 @@ import datetime +import decimal import json import re from typing import Dict, Any @@ -146,6 +147,19 @@ def humanize_megabytes(mb): return f'{mb} MB' +@register.filter() +def simplify_decimal(value): + """ + Return the simplest expression of a decimal value. Examples: + 1.00 => '1' + 1.20 => '1.2' + 1.23 => '1.23' + """ + if type(value) is not decimal.Decimal: + return value + return str(value).rstrip('0.') + + @register.filter() def tzoffset(value): """ diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 1369ee340..8a710b532 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -12,81 +12,79 @@ class WirelessRoleChoices(ChoiceSet): class WirelessChannelChoices(ChoiceSet): - CHANNEL_AUTO = 'auto' # 2.4 GHz - CHANNEL_24G_1 = '2.4g-1' - CHANNEL_24G_2 = '2.4g-2' - CHANNEL_24G_3 = '2.4g-3' - CHANNEL_24G_4 = '2.4g-4' - CHANNEL_24G_5 = '2.4g-5' - CHANNEL_24G_6 = '2.4g-6' - CHANNEL_24G_7 = '2.4g-7' - CHANNEL_24G_8 = '2.4g-8' - CHANNEL_24G_9 = '2.4g-9' - CHANNEL_24G_10 = '2.4g-10' - CHANNEL_24G_11 = '2.4g-11' - CHANNEL_24G_12 = '2.4g-12' - CHANNEL_24G_13 = '2.4g-13' + CHANNEL_24G_1 = '2.4g-1-2412-22' + CHANNEL_24G_2 = '2.4g-2-2417-22' + CHANNEL_24G_3 = '2.4g-3-2422-22' + CHANNEL_24G_4 = '2.4g-4-2427-22' + CHANNEL_24G_5 = '2.4g-5-2432-22' + CHANNEL_24G_6 = '2.4g-6-2437-22' + CHANNEL_24G_7 = '2.4g-7-2442-22' + CHANNEL_24G_8 = '2.4g-8-2447-22' + CHANNEL_24G_9 = '2.4g-9-2452-22' + CHANNEL_24G_10 = '2.4g-10-2457-22' + CHANNEL_24G_11 = '2.4g-11-2462-22' + CHANNEL_24G_12 = '2.4g-12-2467-22' + CHANNEL_24G_13 = '2.4g-13-2472-22' # 5 GHz - CHANNEL_5G_32 = '5g-32' - CHANNEL_5G_34 = '5g-34' - CHANNEL_5G_36 = '5g-36' - CHANNEL_5G_38 = '5g-38' - CHANNEL_5G_40 = '5g-40' - CHANNEL_5G_42 = '5g-42' - CHANNEL_5G_44 = '5g-44' - CHANNEL_5G_46 = '5g-46' - CHANNEL_5G_48 = '5g-48' - CHANNEL_5G_50 = '5g-50' - CHANNEL_5G_52 = '5g-52' - CHANNEL_5G_54 = '5g-54' - CHANNEL_5G_56 = '5g-56' - CHANNEL_5G_58 = '5g-58' - CHANNEL_5G_60 = '5g-60' - CHANNEL_5G_62 = '5g-62' - CHANNEL_5G_64 = '5g-64' - CHANNEL_5G_100 = '5g-100' - CHANNEL_5G_102 = '5g-102' - CHANNEL_5G_104 = '5g-104' - CHANNEL_5G_106 = '5g-106' - CHANNEL_5G_108 = '5g-108' - CHANNEL_5G_110 = '5g-110' - CHANNEL_5G_112 = '5g-112' - CHANNEL_5G_114 = '5g-114' - CHANNEL_5G_116 = '5g-116' - CHANNEL_5G_118 = '5g-118' - CHANNEL_5G_120 = '5g-120' - CHANNEL_5G_122 = '5g-122' - CHANNEL_5G_124 = '5g-124' - CHANNEL_5G_126 = '5g-126' - CHANNEL_5G_128 = '5g-128' - CHANNEL_5G_132 = '5g-132' - CHANNEL_5G_134 = '5g-134' - CHANNEL_5G_136 = '5g-136' - CHANNEL_5G_138 = '5g-138' - CHANNEL_5G_140 = '5g-140' - CHANNEL_5G_142 = '5g-142' - CHANNEL_5G_144 = '5g-144' - CHANNEL_5G_149 = '5g-149' - CHANNEL_5G_151 = '5g-151' - CHANNEL_5G_153 = '5g-153' - CHANNEL_5G_155 = '5g-155' - CHANNEL_5G_157 = '5g-157' - CHANNEL_5G_159 = '5g-159' - CHANNEL_5G_161 = '5g-161' - CHANNEL_5G_163 = '5g-163' - CHANNEL_5G_165 = '5g-165' - CHANNEL_5G_167 = '5g-167' - CHANNEL_5G_169 = '5g-169' - CHANNEL_5G_171 = '5g-171' - CHANNEL_5G_173 = '5g-173' - CHANNEL_5G_175 = '5g-175' - CHANNEL_5G_177 = '5g-177' + CHANNEL_5G_32 = '5g-32-5160-20' + CHANNEL_5G_34 = '5g-34-5170-40' + CHANNEL_5G_36 = '5g-36-5180-20' + CHANNEL_5G_38 = '5g-38-5190-40' + CHANNEL_5G_40 = '5g-40-5200-20' + CHANNEL_5G_42 = '5g-42-5210-80' + CHANNEL_5G_44 = '5g-44-5220-20' + CHANNEL_5G_46 = '5g-46-5230-40' + CHANNEL_5G_48 = '5g-48-5240-20' + CHANNEL_5G_50 = '5g-50-5250-160' + CHANNEL_5G_52 = '5g-52-5260-20' + CHANNEL_5G_54 = '5g-54-5270-40' + CHANNEL_5G_56 = '5g-56-5280-20' + CHANNEL_5G_58 = '5g-58-5290-80' + CHANNEL_5G_60 = '5g-60-5300-20' + CHANNEL_5G_62 = '5g-62-5310-40' + CHANNEL_5G_64 = '5g-64-5320-20' + CHANNEL_5G_100 = '5g-100-5500-20' + CHANNEL_5G_102 = '5g-102-5510-40' + CHANNEL_5G_104 = '5g-104-5520-20' + CHANNEL_5G_106 = '5g-106-5530-80' + CHANNEL_5G_108 = '5g-108-5540-20' + CHANNEL_5G_110 = '5g-110-5550-40' + CHANNEL_5G_112 = '5g-112-5560-20' + CHANNEL_5G_114 = '5g-114-5570-160' + CHANNEL_5G_116 = '5g-116-5580-20' + CHANNEL_5G_118 = '5g-118-5590-40' + CHANNEL_5G_120 = '5g-120-5600-20' + CHANNEL_5G_122 = '5g-122-5610-80' + CHANNEL_5G_124 = '5g-124-5620-20' + CHANNEL_5G_126 = '5g-126-5630-40' + CHANNEL_5G_128 = '5g-128-5640-20' + CHANNEL_5G_132 = '5g-132-5660-20' + CHANNEL_5G_134 = '5g-134-5670-40' + CHANNEL_5G_136 = '5g-136-5680-20' + CHANNEL_5G_138 = '5g-138-5690-80' + CHANNEL_5G_140 = '5g-140-5700-20' + CHANNEL_5G_142 = '5g-142-5710-40' + CHANNEL_5G_144 = '5g-144-5720-20' + CHANNEL_5G_149 = '5g-149-5745-20' + CHANNEL_5G_151 = '5g-151-5755-40' + CHANNEL_5G_153 = '5g-153-5765-20' + CHANNEL_5G_155 = '5g-155-5775-80' + CHANNEL_5G_157 = '5g-157-5785-20' + CHANNEL_5G_159 = '5g-159-5795-40' + CHANNEL_5G_161 = '5g-161-5805-20' + CHANNEL_5G_163 = '5g-163-5815-160' + CHANNEL_5G_165 = '5g-165-5825-20' + CHANNEL_5G_167 = '5g-167-5835-40' + CHANNEL_5G_169 = '5g-169-5845-20' + CHANNEL_5G_171 = '5g-171-5855-80' + CHANNEL_5G_173 = '5g-173-5865-20' + CHANNEL_5G_175 = '5g-175-5875-40' + CHANNEL_5G_177 = '5g-177-5885-20' CHOICES = ( - (CHANNEL_AUTO, 'Auto'), ( '2.4 GHz (802.11b/g/n/ax)', ( diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index a3454c79a..9a7b78b31 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -56,7 +56,10 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): device_a = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device A' + label='Device A', + initial_params={ + 'interfaces': '$interface_a' + } ) interface_a = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -69,7 +72,10 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): ) device_b = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device B' + label='Device B', + initial_params={ + 'interfaces': '$interface_b' + } ) interface_b = DynamicModelChoiceField( queryset=Interface.objects.all(), diff --git a/netbox/wireless/utils.py b/netbox/wireless/utils.py new file mode 100644 index 000000000..d98d6a853 --- /dev/null +++ b/netbox/wireless/utils.py @@ -0,0 +1,27 @@ +from decimal import Decimal + +from .choices import WirelessChannelChoices + +__all__ = ( + 'get_channel_attr', +) + + +def get_channel_attr(channel, attr): + """ + Return the specified attribute of a given WirelessChannelChoices value. + """ + if channel not in WirelessChannelChoices.values(): + raise ValueError(f"Invalid channel value: {channel}") + + channel_values = channel.split('-') + attrs = { + 'band': channel_values[0], + 'id': int(channel_values[1]), + 'frequency': Decimal(channel_values[2]), + 'width': Decimal(channel_values[3]), + } + if attr not in attrs: + raise ValueError(f"Invalid channel attribute: {attr}") + + return attrs[attr] From 717fd760df62dd9ad1d994c8fc1893169109988a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 12:24:07 -0400 Subject: [PATCH 029/289] #3979: UI cleanup --- netbox/dcim/forms/models.py | 12 +- netbox/dcim/tables/devices.py | 4 +- netbox/dcim/tables/template_code.py | 10 ++ netbox/templates/dcim/interface.html | 128 ++++++++++++++++------ netbox/templates/dcim/interface_edit.html | 1 + netbox/utilities/templatetags/helpers.py | 2 +- netbox/wireless/filtersets.py | 3 + 7 files changed, 124 insertions(+), 36 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 603767518..9ce0b54aa 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -16,7 +16,7 @@ from utilities.forms import ( SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup -from wireless.models import WirelessLAN +from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm __all__ = ( @@ -1073,10 +1073,18 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'type': 'lag', } ) + wireless_lan_group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + label='Wireless LAN group' + ) wireless_lans = DynamicModelMultipleChoiceField( queryset=WirelessLAN.objects.all(), required=False, - label='Wireless LANs' + label='Wireless LANs', + query_params={ + 'group_id': '$wireless_lan_group', + } ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b0ec349e..3b92efd76 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -505,8 +505,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable class DeviceInterfaceTable(InterfaceTable): name = tables.TemplateColumn( - template_code=' {{ value }}', order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index a5a4d9979..a948baffd 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -205,6 +205,12 @@ INTERFACE_BUTTONS = """ {% endif %} +{% elif record.wireless_link %} + {% if perms.wireless.delete_wirelesslink %} + + + + {% endif %} {% elif record.is_wired and perms.dcim.add_cable %} @@ -223,6 +229,10 @@ INTERFACE_BUTTONS = """ {% else %} {% endif %} +{% elif record.is_wireless and perms.wireless.add_wirelesslink %} + + + {% endif %} """ diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 6e01dee98..e9230fbf9 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -217,8 +217,29 @@ Wireless Link {{ object.wireless_link }} + + + + {% with peer_interface=object.connected_endpoint %} + + Device + + {{ peer_interface.device }} + + + + Name + + {{ peer_interface }} + + + + Type + {{ peer_interface.get_type_display }} + + {% endwith %} {% else %}
@@ -267,36 +288,73 @@
Wireless
- - - - - - - - - - - - - - - - - -
Role{{ object.get_rf_role_display|placeholder }}
Channel{{ object.get_rf_channel_display|placeholder }}
Channel Frequency - {% if object.rf_channel_frequency %} - {{ object.rf_channel_frequency|simplify_decimal }} MHz - {% else %} - - {% endif %} -
Channel Width - {% if object.rf_channel_width %} - {{ object.rf_channel_width|simplify_decimal }} MHz - {% else %} - - {% endif %} -
+ {% with peer=object.connected_endpoint %} + + + + + + {% if peer %} + + {% endif %} + + + + + + {% if peer %} + + {% endif %} + + + + + {% if peer %} + + {{ peer.get_rf_channel_display|placeholder }} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_frequency %} + {{ peer.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_width %} + {{ peer.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + +
LocalPeer
Role{{ object.get_rf_role_display|placeholder }}{{ peer.get_rf_role_display|placeholder }}
Channel{{ object.get_rf_channel_display|placeholder }}
Channel Frequency + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} +
Channel Width + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} +
+ {% endwith %}
@@ -305,12 +363,20 @@ + - {% for wlan in object.wlans.all %} + {% for wlan in object.wireless_lans.all %} + diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index de7d21269..aec88d25a 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -38,6 +38,7 @@ {% render_field form.rf_channel %} {% render_field form.rf_channel_frequency %} {% render_field form.rf_channel_width %} + {% render_field form.wireless_lan_group %} {% render_field form.wireless_lans %} {% endif %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 668596c8e..3318fe1e7 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -157,7 +157,7 @@ def simplify_decimal(value): """ if type(value) is not decimal.Decimal: return value - return str(value).rstrip('0.') + return str(value).rstrip('0').rstrip('.') @register.filter() diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index ac503e474..a5d9b7d75 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -33,6 +33,9 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): method='search', label='Search', ) + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all() + ) tag = TagFilter() class Meta: From 0c72c20d2aa1b3db087eb63613fc087528a2095b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 10:05:43 -0400 Subject: [PATCH 030/289] Add WirelessLANs column to interfaces table --- netbox/dcim/tables/devices.py | 19 ++++++++++--------- netbox/dcim/tables/template_code.py | 6 ++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b92efd76..343667d46 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -11,11 +11,7 @@ from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) -from .template_code import ( - LINKTERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, - FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, - POWERPORT_BUTTONS, REARPORT_BUTTONS, -) +from .template_code import * __all__ = ( 'BaseInterfaceTable', @@ -488,6 +484,11 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable wireless_link = tables.Column( linkify=True ) + wireless_lans = TemplateColumn( + template_code=INTERFACE_WIRELESS_LANS, + orderable=False, + verbose_name='Wireless LANs' + ) tags = TagColumn( url_name='dcim:interface_list' ) @@ -497,8 +498,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mark_connected', - 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', - 'tagged_vlans', + 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -530,8 +531,8 @@ class DeviceInterfaceTable(InterfaceTable): fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', - 'actions', + 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', + 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index a948baffd..aab15b5ef 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -64,6 +64,12 @@ INTERFACE_TAGGED_VLANS = """ {% endif %} """ +INTERFACE_WIRELESS_LANS = """ +{% for wlan in record.wireless_lans.all %} + {{ wlan }}
+{% endfor %} +""" + POWERFEED_CABLE = """ {{ value }} From 2e78568d4de3afe015cb50fd2a4461b30b7b6557 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 11:45:05 -0400 Subject: [PATCH 031/289] Initial work on contacts --- netbox/netbox/navigation_menu.py | 8 + netbox/templates/tenancy/contact.html | 66 ++++++++ netbox/templates/tenancy/contactgroup.html | 76 +++++++++ netbox/templates/tenancy/contactrole.html | 46 ++++++ netbox/tenancy/api/nested_serializers.py | 35 ++++- netbox/tenancy/api/serializers.py | 60 +++++++- netbox/tenancy/api/urls.py | 6 + netbox/tenancy/api/views.py | 46 +++++- netbox/tenancy/choices.py | 19 +++ netbox/tenancy/filtersets.py | 98 +++++++++++- netbox/tenancy/forms/bulk_edit.py | 59 ++++++- netbox/tenancy/forms/bulk_import.py | 49 +++++- netbox/tenancy/forms/filtersets.py | 66 +++++++- netbox/tenancy/forms/models.py | 59 ++++++- netbox/tenancy/graphql/schema.py | 12 ++ netbox/tenancy/graphql/types.py | 53 +++++++ netbox/tenancy/migrations/0003_contacts.py | 98 ++++++++++++ netbox/tenancy/models.py | 164 +++++++++++++++++++- netbox/tenancy/tables.py | 78 +++++++++- netbox/tenancy/tests/test_api.py | 111 ++++++++++++- netbox/tenancy/tests/test_filtersets.py | 102 +++++++++++- netbox/tenancy/tests/test_views.py | 104 ++++++++++++- netbox/tenancy/urls.py | 37 ++++- netbox/tenancy/views.py | 171 ++++++++++++++++++++- 24 files changed, 1594 insertions(+), 29 deletions(-) create mode 100644 netbox/templates/tenancy/contact.html create mode 100644 netbox/templates/tenancy/contactgroup.html create mode 100644 netbox/templates/tenancy/contactrole.html create mode 100644 netbox/tenancy/choices.py create mode 100644 netbox/tenancy/migrations/0003_contacts.py diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a3978f16e..de2c170a3 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -120,6 +120,14 @@ ORGANIZATION_MENU = Menu( get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'), ), ), + MenuGroup( + label='Contacts', + items=( + get_model_item('tenancy', 'contact', 'Contacts'), + get_model_item('tenancy', 'contactgroup', 'Contact Groups'), + get_model_item('tenancy', 'contactrole', 'Contact Roles'), + ), + ), ), ) diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html new file mode 100644 index 000000000..2ead52e5a --- /dev/null +++ b/netbox/templates/tenancy/contact.html @@ -0,0 +1,66 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + {% if object.group %} + + {% endif %} +{% endblock breadcrumbs %} + +{% block content %} +
+
+
+
Tenant
+
+
Group SSID
+ {% if wlan.group %} + {{ wlan.group }} + {% else %} + — + {% endif %} + {{ wlan.ssid }}
+ + + + + + + + + + + + + + + + + + + + + + + + +
Group + {% if object.group %} + {{ object.group }} + {% else %} + None + {% endif %} +
Name{{ object.name }}
Title{{ object.tile|placeholder }}
Phone{{ object.phone|placeholder }}
Email{{ object.email|placeholder }}
Address{{ object.address|linebreaksbr|placeholder }}
+
+
+ {% include 'inc/comments_panel.html' %} + {% plugin_left_page object %} + +
+ {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% plugin_right_page object %} +
+ +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html new file mode 100644 index 000000000..1511565c3 --- /dev/null +++ b/netbox/templates/tenancy/contactgroup.html @@ -0,0 +1,76 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + {% for contactgroup in object.get_ancestors %} + + {% endfor %} +{% endblock %} + +{% block content %} +
+
+
+
+ Contact Group +
+
+ + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
Contacts + {{ contacts_table.rows|length }} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
+ Tenants +
+
+ {% include 'inc/table.html' with table=contacts_table %} +
+ {% if perms.tenancy.add_contact %} + + {% endif %} +
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html new file mode 100644 index 000000000..688c58177 --- /dev/null +++ b/netbox/templates/tenancy/contactrole.html @@ -0,0 +1,46 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+
+
Contact Role
+
+ + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
Assigned Contacts
+
+ {% include 'inc/table.html' with table=contacts_table %} +
+
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 11225fa7a..a072331f5 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,9 +1,12 @@ from rest_framework import serializers from netbox.api import WritableNestedSerializer -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * __all__ = [ + 'NestedContactSerializer', + 'NestedContactGroupSerializer', + 'NestedContactRoleSerializer', 'NestedTenantGroupSerializer', 'NestedTenantSerializer', ] @@ -29,3 +32,33 @@ class NestedTenantSerializer(WritableNestedSerializer): class Meta: model = Tenant fields = ['id', 'url', 'display', 'name', 'slug'] + + +# +# Contacts +# + +class NestedContactGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') + contact_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = ContactGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'contact_count', '_depth'] + + +class NestedContactRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') + + class Meta: + model = ContactRole + fields = ['id', 'url', 'display', 'name', 'slug'] + + +class NestedContactSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail') + + class Meta: + model = Contact + fields = ['id', 'url', 'display', 'name'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 3136c811c..2dfb59455 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,7 +1,9 @@ +from django.contrib.auth.models import ContentType from rest_framework import serializers -from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer -from tenancy.models import Tenant, TenantGroup +from netbox.api import ContentTypeField +from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer +from tenancy.models import * from .nested_serializers import * @@ -43,3 +45,57 @@ class TenantSerializer(PrimaryModelSerializer): 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] + + +# +# Contacts +# + +class ContactGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') + parent = NestedContactGroupSerializer(required=False, allow_null=True) + contact_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ContactGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'contact_count', '_depth', + ] + + +class ContactRoleSerializer(OrganizationalModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') + + class Meta: + model = ContactRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + ] + + +class ContactSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail') + group = NestedContactGroupSerializer(required=False, allow_null=True) + + class Meta: + model = Contact + fields = [ + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + +class ContactAssignmentSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + content_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + contact = NestedContactSerializer() + role = NestedContactRoleSerializer(required=False, allow_null=True) + + class Meta: + model = ContactAssignment + fields = [ + 'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'created', 'last_updated', + ] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 32540879d..00e1a6469 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -9,5 +9,11 @@ router.APIRootView = views.TenancyRootView router.register('tenant-groups', views.TenantGroupViewSet) router.register('tenants', views.TenantViewSet) +# Contacts +router.register('contact-groups', views.ContactGroupViewSet) +router.register('contact-roles', views.ContactRoleViewSet) +router.register('contacts', views.ContactViewSet) +router.register('contact-assignments', views.ContactAssignmentViewSet) + app_name = 'tenancy-api' urlpatterns = router.urls diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 2e049135d..7ce16c143 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -5,7 +5,7 @@ from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filtersets -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers @@ -20,7 +20,7 @@ class TenancyRootView(APIRootView): # -# Tenant Groups +# Tenants # class TenantGroupViewSet(CustomFieldModelViewSet): @@ -35,10 +35,6 @@ class TenantGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.TenantGroupFilterSet -# -# Tenants -# - class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.prefetch_related( 'group', 'tags' @@ -55,3 +51,41 @@ class TenantViewSet(CustomFieldModelViewSet): ) serializer_class = serializers.TenantSerializer filterset_class = filtersets.TenantFilterSet + + +# +# Contacts +# + +class ContactGroupViewSet(CustomFieldModelViewSet): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + serializer_class = serializers.ContactGroupSerializer + filterset_class = filtersets.ContactGroupFilterSet + + +class ContactRoleViewSet(CustomFieldModelViewSet): + queryset = ContactRole.objects.all() + serializer_class = serializers.ContactRoleSerializer + filterset_class = filtersets.ContactRoleFilterSet + + +class ContactViewSet(CustomFieldModelViewSet): + queryset = Contact.objects.prefetch_related( + 'group', 'tags' + ) + serializer_class = serializers.ContactSerializer + filterset_class = filtersets.ContactFilterSet + + +class ContactAssignmentViewSet(CustomFieldModelViewSet): + queryset = ContactAssignment.objects.prefetch_related( + 'contact', 'role' + ) + serializer_class = serializers.ContactAssignmentSerializer + filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/choices.py b/netbox/tenancy/choices.py new file mode 100644 index 000000000..b59d2050d --- /dev/null +++ b/netbox/tenancy/choices.py @@ -0,0 +1,19 @@ +from utilities.choices import ChoiceSet + + +# +# Contacts +# + +class ContactPriorityChoices(ChoiceSet): + PRIORITY_PRIMARY = 'primary' + PRIORITY_SECONDARY = 'secondary' + PRIORITY_TERTIARY = 'tertiary' + PRIORITY_INACTIVE = 'inactive' + + CHOICES = ( + (PRIORITY_PRIMARY, 'Primary'), + (PRIORITY_SECONDARY, 'Secondary'), + (PRIORITY_TERTIARY, 'Tertiary'), + (PRIORITY_INACTIVE, 'Inactive'), + ) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index d00b78629..75f9e351d 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -4,16 +4,24 @@ from django.db.models import Q from extras.filters import TagFilter from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter -from .models import Tenant, TenantGroup +from .models import * __all__ = ( + 'ContactAssignmentFilterSet', + 'ContactFilterSet', + 'ContactGroupFilterSet', + 'ContactRoleFilterSet', 'TenancyFilterSet', 'TenantFilterSet', 'TenantGroupFilterSet', ) +# +# Tenancy +# + class TenantGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), @@ -23,7 +31,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): field_name='parent__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', - label='Tenant group group (slug)', + label='Tenant group (slug)', ) class Meta: @@ -93,3 +101,89 @@ class TenancyFilterSet(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + + +# +# Contacts +# + +class ContactGroupFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + label='Contact group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=ContactGroup.objects.all(), + to_field_name='slug', + label='Contact group (slug)', + ) + + class Meta: + model = ContactGroup + fields = ['id', 'name', 'slug', 'description'] + + +class ContactRoleFilterSet(OrganizationalModelFilterSet): + + class Meta: + model = ContactRole + fields = ['id', 'name', 'slug'] + + +class ContactFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='group', + lookup_expr='in', + label='Contact group (ID)', + ) + group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='group', + lookup_expr='in', + to_field_name='slug', + label='Contact group (slug)', + ) + tag = TagFilter() + + class Meta: + model = Contact + fields = ['id', 'name', 'title', 'phone', 'email', 'address'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(title__icontains=value) | + Q(phone__icontains=value) | + Q(email__icontains=value) | + Q(address__icontains=value) | + Q(comments__icontains=value) + ) + + +class ContactAssignmentFilterSet(OrganizationalModelFilterSet): + contact_id = django_filters.ModelMultipleChoiceFilter( + queryset=Contact.objects.all(), + label='Contact (ID)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContactRole.objects.all(), + label='Contact role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=ContactRole.objects.all(), + to_field_name='slug', + label='Contact role (slug)', + ) + + class Meta: + model = ContactAssignment + fields = ['id', 'priority'] diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index b2fc7dafd..0d414d2a5 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -1,15 +1,22 @@ from django import forms from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.forms import BootstrapMixin, DynamicModelChoiceField __all__ = ( + 'ContactBulkEditForm', + 'ContactGroupBulkEditForm', + 'ContactRoleBulkEditForm', 'TenantBulkEditForm', 'TenantGroupBulkEditForm', ) +# +# Tenants +# + class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=TenantGroup.objects.all(), @@ -42,3 +49,53 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk nullable_fields = [ 'group', ] + + +# +# Contacts +# + +class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ContactRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class ContactBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Contact.objects.all(), + widget=forms.MultipleHiddenInput() + ) + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + + class Meta: + nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments'] diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 335d71ef6..73e152a29 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,13 +1,20 @@ from extras.forms import CustomFieldModelCSVForm -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.forms import CSVModelChoiceField, SlugField __all__ = ( + 'ContactCSVForm', + 'ContactGroupCSVForm', + 'ContactRoleCSVForm', 'TenantCSVForm', 'TenantGroupCSVForm', ) +# +# Tenants +# + class TenantGroupCSVForm(CustomFieldModelCSVForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), @@ -34,3 +41,43 @@ class TenantCSVForm(CustomFieldModelCSVForm): class Meta: model = Tenant fields = ('name', 'slug', 'group', 'description', 'comments') + + +# +# Contacts +# + +class ContactGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent group' + ) + slug = SlugField() + + class Meta: + model = ContactGroup + fields = ('name', 'slug', 'parent', 'description') + + +class ContactRoleCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = ContactRole + fields = ('name', 'slug', 'description') + + +class ContactCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + group = CSVModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) + + class Meta: + model = Contact + fields = ('name', 'title', 'phone', 'email', 'address', 'group', 'comments') diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 6e2eb7fd1..69941701f 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -2,9 +2,21 @@ from django import forms from django.utils.translation import gettext as _ from extras.forms import CustomFieldModelFilterForm -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField +__all__ = ( + 'ContactFilterForm', + 'ContactGroupFilterForm', + 'ContactRoleFilterForm', + 'TenantFilterForm', + 'TenantGroupFilterForm', +) + + +# +# Tenants +# class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = TenantGroup @@ -40,3 +52,55 @@ class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): fetch_trigger='open' ) tag = TagFilterField(model) + + +# +# Contacts +# + +class ContactGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = ContactGroup + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + + +class ContactRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = ContactRole + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class ContactFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Contact + field_groups = ( + ('q', 'tag'), + ('group_id',), + ) + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + group_id = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + null_option='None', + label=_('Group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index de3a9e515..6c0640d53 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -1,16 +1,23 @@ from extras.forms import CustomFieldModelForm from extras.models import Tag -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.forms import ( - BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, + BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea, ) __all__ = ( + 'ContactForm', + 'ContactGroupForm', + 'ContactRoleForm', 'TenantForm', 'TenantGroupForm', ) +# +# Tenants +# + class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), @@ -45,3 +52,51 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): fieldsets = ( ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), ) + + +# +# Contacts +# + +class ContactGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = ContactGroup + fields = ['parent', 'name', 'slug', 'description'] + + +class ContactRoleForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = ContactRole + fields = ['name', 'slug', 'description'] + + +class ContactForm(BootstrapMixin, CustomFieldModelForm): + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Contact + fields = ( + 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + ) + fieldsets = ( + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')), + ) + widgets = { + 'address': SmallTextarea(attrs={'rows': 3}), + } diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py index f420eb787..de0a1781a 100644 --- a/netbox/tenancy/graphql/schema.py +++ b/netbox/tenancy/graphql/schema.py @@ -10,3 +10,15 @@ class TenancyQuery(graphene.ObjectType): tenant_group = ObjectField(TenantGroupType) tenant_group_list = ObjectListField(TenantGroupType) + + contact = ObjectField(ContactType) + contact_list = ObjectListField(ContactType) + + contact_role = ObjectField(ContactRoleType) + contact_role_list = ObjectListField(ContactRoleType) + + contact_group = ObjectField(ContactGroupType) + contact_group_list = ObjectListField(ContactGroupType) + + contact_assignment = ObjectField(ContactAssignmentType) + contact_assignment_list = ObjectListField(ContactAssignmentType) diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index 6f1e27274..a16d51081 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,12 +1,29 @@ +import graphene + from tenancy import filtersets, models from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( + 'ContactAssignmentType', + 'ContactGroupType', + 'ContactRoleType', + 'ContactType', 'TenantType', 'TenantGroupType', ) +class ContactAssignmentsMixin: + assignments = graphene.List('tenancy.graphql.types.ContactAssignmentType') + + def resolve_assignments(self, info): + return self.assignments.restrict(info.context.user, 'view') + + +# +# Tenants +# + class TenantType(PrimaryObjectType): class Meta: @@ -21,3 +38,39 @@ class TenantGroupType(OrganizationalObjectType): model = models.TenantGroup fields = '__all__' filterset_class = filtersets.TenantGroupFilterSet + + +# +# Contacts +# + +class ContactType(ContactAssignmentsMixin, PrimaryObjectType): + + class Meta: + model = models.Contact + fields = '__all__' + filterset_class = filtersets.ContactFilterSet + + +class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): + + class Meta: + model = models.ContactRole + fields = '__all__' + filterset_class = filtersets.ContactRoleFilterSet + + +class ContactGroupType(OrganizationalObjectType): + + class Meta: + model = models.ContactGroup + fields = '__all__' + filterset_class = filtersets.ContactGroupFilterSet + + +class ContactAssignmentType(OrganizationalObjectType): + + class Meta: + model = models.ContactAssignment + fields = '__all__' + filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py new file mode 100644 index 000000000..dc6f6c668 --- /dev/null +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -0,0 +1,98 @@ +# Generated by Django 3.2.8 on 2021-10-18 16:12 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0002_tenant_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('title', models.CharField(blank=True, max_length=100)), + ('phone', models.CharField(blank=True, max_length=50)), + ('email', models.EmailField(blank=True, max_length=254)), + ('address', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ContactRole', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ContactGroup', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.contactgroup')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ContactAssignment', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('object_id', models.PositiveIntegerField()), + ('priority', models.CharField(blank=True, max_length=50)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contact')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contactrole')), + ], + options={ + 'ordering': ('priority', 'contact'), + }, + ), + migrations.AddField( + model_name='contact', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup'), + ), + migrations.AddField( + model_name='contact', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 4a5b1967e..f5e66b753 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,19 +1,29 @@ -from django.core.exceptions import ValidationError +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from extras.utils import extras_features -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet +from .choices import * __all__ = ( + 'ContactAssignment', + 'Contact', + 'ContactGroup', + 'ContactRole', 'Tenant', 'TenantGroup', ) +# +# Tenants +# + @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class TenantGroup(NestedGroupModel): """ @@ -90,3 +100,153 @@ class Tenant(PrimaryModel): def get_absolute_url(self): return reverse('tenancy:tenant', args=[self.pk]) + + +# +# Contacts +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ContactGroup(NestedGroupModel): + """ + An arbitrary collection of Contacts. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ['name'] + + def get_absolute_url(self): + return reverse('tenancy:contactgroup', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ContactRole(OrganizationalModel): + """ + Functional role for a Contact assigned to an object. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True, + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:contactrole', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Contact(PrimaryModel): + """ + Contact information for a particular object(s) in NetBox. + """ + group = models.ForeignKey( + to='tenancy.ContactGroup', + on_delete=models.SET_NULL, + related_name='contacts', + blank=True, + null=True + ) + name = models.CharField( + max_length=100 + ) + title = models.CharField( + max_length=100, + blank=True + ) + phone = models.CharField( + max_length=50, + blank=True + ) + email = models.EmailField( + blank=True + ) + address = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'group', + ] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:contact', args=[self.pk]) + + +@extras_features('webhooks') +class ContactAssignment(ChangeLoggedModel): + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + contact = models.ForeignKey( + to='tenancy.Contact', + on_delete=models.PROTECT, + related_name='assignments' + ) + role = models.ForeignKey( + to='tenancy.ContactRole', + on_delete=models.PROTECT, + related_name='assignments' + ) + priority = models.CharField( + max_length=50, + choices=ContactPriorityChoices, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('priority', 'contact') diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index f39ca1b18..3401c8fe4 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -3,9 +3,13 @@ import django_tables2 as tables from utilities.tables import ( BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) -from .models import Tenant, TenantGroup +from .models import * __all__ = ( + 'ContactAssignmentTable', + 'ContactGroupTable', + 'ContactRoleTable', + 'ContactTable', 'TenantColumn', 'TenantGroupTable', 'TenantTable', @@ -38,7 +42,7 @@ class TenantColumn(tables.TemplateColumn): # -# Tenant groups +# Tenants # class TenantGroupTable(BaseTable): @@ -59,10 +63,6 @@ class TenantGroupTable(BaseTable): default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') -# -# Tenants -# - class TenantTable(BaseTable): pk = ToggleColumn() name = tables.Column( @@ -80,3 +80,69 @@ class TenantTable(BaseTable): model = Tenant fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags') default_columns = ('pk', 'name', 'group', 'description') + + +# +# Contacts +# + +class ContactGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + contact_count = LinkedCountColumn( + viewname='tenancy:contact_list', + url_params={'role_id': 'pk'}, + verbose_name='Contacts' + ) + actions = ButtonsColumn(ContactGroup) + + class Meta(BaseTable.Meta): + model = ContactGroup + fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') + + +class ContactRoleTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + actions = ButtonsColumn(ContactRole) + + class Meta(BaseTable.Meta): + model = ContactRole + fields = ('pk', 'name', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'description', 'actions') + + +class ContactTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + tags = TagColumn( + url_name='tenancy:tenant_list' + ) + + class Meta(BaseTable.Meta): + model = Contact + fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'tags') + default_columns = ('pk', 'name', 'group', 'title', 'phone', 'email') + + +class ContactAssignmentTable(BaseTable): + pk = ToggleColumn() + contact = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = ContactAssignment + fields = ('pk', 'contact', 'role', 'priority') + default_columns = ('pk', 'contact', 'role', 'priority') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 5a3c2c1b0..c7c6cf846 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,6 +1,6 @@ from django.urls import reverse -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.testing import APITestCase, APIViewTestCases @@ -92,3 +92,112 @@ class TenantTest(APIViewTestCases.APIViewTestCase): 'group': tenant_groups[1].pk, }, ] + + +class ContactGroupTest(APIViewTestCases.APIViewTestCase): + model = ContactGroup + brief_fields = ['_depth', 'contact_count', 'display', 'id', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + parent_contact_groups = ( + ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'), + ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'), + ) + + ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0]) + ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0]) + ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0]) + + cls.create_data = [ + { + 'name': 'Contact Group 4', + 'slug': 'contact-group-4', + 'parent': parent_contact_groups[1].pk, + }, + { + 'name': 'Contact Group 5', + 'slug': 'contact-group-5', + 'parent': parent_contact_groups[1].pk, + }, + { + 'name': 'Contact Group 6', + 'slug': 'contact-group-6', + 'parent': parent_contact_groups[1].pk, + }, + ] + + +class ContactRoleTest(APIViewTestCases.APIViewTestCase): + model = ContactRole + brief_fields = ['display', 'id', 'name', 'slug', 'url'] + create_data = [ + { + 'name': 'Contact Role 4', + 'slug': 'contact-role-4', + }, + { + 'name': 'Contact Role 5', + 'slug': 'contact-role-5', + }, + { + 'name': 'Contact Role 6', + 'slug': 'contact-role-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + contact_roles = ( + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ) + ContactRole.objects.bulk_create(contact_roles) + + +class ContactTest(APIViewTestCases.APIViewTestCase): + model = Contact + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'group': None, + 'comments': 'New comments', + } + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1'), + ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2'), + ) + + contacts = ( + Contact(name='Contact 1', group=contact_groups[0]), + Contact(name='Contact 2', group=contact_groups[0]), + Contact(name='Contact 3', group=contact_groups[0]), + ) + Contact.objects.bulk_create(contacts) + + cls.create_data = [ + { + 'name': 'Contact 4', + 'group': contact_groups[1].pk, + }, + { + 'name': 'Contact 5', + 'group': contact_groups[1].pk, + }, + { + 'name': 'Contact 6', + 'group': contact_groups[1].pk, + }, + ] diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index fd4a0bd76..86170734c 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase from tenancy.filtersets import * -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.testing import ChangeLoggedFilterSetTests @@ -84,3 +84,103 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'group': [group[0].slug, group[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ContactGroup.objects.all() + filterset = ContactGroupFilterSet + + @classmethod + def setUpTestData(cls): + + parent_contact_groups = ( + ContactGroup(name='Parent Contact Group 1', slug='parent-contact-group-1'), + ContactGroup(name='Parent Contact Group 2', slug='parent-contact-group-2'), + ContactGroup(name='Parent Contact Group 3', slug='parent-contact-group-3'), + ) + for contactgroup in parent_contact_groups: + contactgroup.save() + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0], description='A'), + ContactGroup(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[1], description='B'), + ContactGroup(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[2], description='C'), + ) + for contactgroup in contact_groups: + contactgroup.save() + + def test_name(self): + params = {'name': ['Contact Group 1', 'Contact Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['contact-group-1', 'contact-group-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_groups = ContactGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ContactRole.objects.all() + filterset = ContactRoleFilterSet + + @classmethod + def setUpTestData(cls): + + contact_roles = ( + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ) + ContactRole.objects.bulk_create(contact_roles) + + def test_name(self): + params = {'name': ['Contact Role 1', 'Contact Role 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['contact-role-1', 'contact-role-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = Contact.objects.all() + filterset = ContactFilterSet + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 3', slug='contact-group-3'), + ) + for contactgroup in contact_groups: + contactgroup.save() + + contacts = ( + Contact(name='Contact 1', group=contact_groups[0]), + Contact(name='Contact 2', group=contact_groups[1]), + Contact(name='Contact 3', group=contact_groups[2]), + ) + Contact.objects.bulk_create(contacts) + + def test_name(self): + params = {'name': ['Contact 1', 'Contact 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + group = ContactGroup.objects.all()[:2] + params = {'group_id': [group[0].pk, group[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [group[0].slug, group[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index f45afc302..fb7ff3ce3 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -1,4 +1,4 @@ -from tenancy.models import Tenant, TenantGroup +from tenancy.models import * from utilities.testing import ViewTestCases, create_tags @@ -74,3 +74,105 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'group': tenant_groups[1].pk, } + + +class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = ContactGroup + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 3', slug='contact-group-3'), + ) + for tenanantgroup in contact_groups: + tenanantgroup.save() + + cls.form_data = { + 'name': 'Contact Group X', + 'slug': 'contact-group-x', + 'description': 'A new contact group', + } + + cls.csv_data = ( + "name,slug,description", + "Contact Group 4,contact-group-4,Fourth contact group", + "Contact Group 5,contact-group-5,Fifth contact group", + "Contact Group 6,contact-group-6,Sixth contact group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = ContactRole + + @classmethod + def setUpTestData(cls): + + ContactRole.objects.bulk_create([ + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ]) + + cls.form_data = { + 'name': 'Devie Role X', + 'slug': 'contact-role-x', + 'description': 'New contact role', + } + + cls.csv_data = ( + "name,slug", + "Contact Role 4,contact-role-4", + "Contact Role 5,contact-role-5", + "Contact Role 6,contact-role-6", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Contact + + @classmethod + def setUpTestData(cls): + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ) + for contactgroup in contact_groups: + contactgroup.save() + + Contact.objects.bulk_create([ + Contact(name='Contact 1', group=contact_groups[0]), + Contact(name='Contact 2', group=contact_groups[0]), + Contact(name='Contact 3', group=contact_groups[0]), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Contact X', + 'group': contact_groups[1].pk, + 'comments': 'Some comments', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,slug", + "Contact 4,contact-4", + "Contact 5,contact-5", + "Contact 6,contact-6", + ) + + cls.bulk_edit_data = { + 'group': contact_groups[1].pk, + } diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index a1f46c7ec..807af161e 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -3,7 +3,7 @@ from django.urls import path from extras.views import ObjectChangeLogView, ObjectJournalView from utilities.views import SlugRedirectView from . import views -from .models import Tenant, TenantGroup +from .models import * app_name = 'tenancy' urlpatterns = [ @@ -32,4 +32,39 @@ urlpatterns = [ path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}), + # Contact groups + path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'), + path('contact-groups/add/', views.ContactGroupEditView.as_view(), name='contactgroup_add'), + path('contact-groups/import/', views.ContactGroupBulkImportView.as_view(), name='contactgroup_import'), + path('contact-groups/edit/', views.ContactGroupBulkEditView.as_view(), name='contactgroup_bulk_edit'), + path('contact-groups/delete/', views.ContactGroupBulkDeleteView.as_view(), name='contactgroup_bulk_delete'), + path('contact-groups//', views.ContactGroupView.as_view(), name='contactgroup'), + path('contact-groups//edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'), + path('contact-groups//delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'), + path('contact-groups//changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}), + + # Contact roles + path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'), + path('contact-roles/add/', views.ContactRoleEditView.as_view(), name='contactrole_add'), + path('contact-roles/import/', views.ContactRoleBulkImportView.as_view(), name='contactrole_import'), + path('contact-roles/edit/', views.ContactRoleBulkEditView.as_view(), name='contactrole_bulk_edit'), + path('contact-roles/delete/', views.ContactRoleBulkDeleteView.as_view(), name='contactrole_bulk_delete'), + path('contact-roles//', views.ContactRoleView.as_view(), name='contactrole'), + path('contact-roles//edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'), + path('contact-roles//delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'), + path('contact-roles//changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}), + + # Contacts + path('contacts/', views.ContactListView.as_view(), name='contact_list'), + path('contacts/add/', views.ContactEditView.as_view(), name='contact_add'), + path('contacts/import/', views.ContactBulkImportView.as_view(), name='contact_import'), + path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'), + path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'), + path('contacts//', views.ContactView.as_view(), name='contact'), + path('contacts//', SlugRedirectView.as_view(), kwargs={'model': Contact}), + path('contacts//edit/', views.ContactEditView.as_view(), name='contact_edit'), + path('contacts//delete/', views.ContactDeleteView.as_view(), name='contact_delete'), + path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}), + path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}), + ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 0b28a62d2..f4772b288 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,9 +3,10 @@ from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic from utilities.tables import paginate_table +from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables -from .models import Tenant, TenantGroup +from .models import * # @@ -140,3 +141,171 @@ class TenantBulkDeleteView(generic.BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filtersets.TenantFilterSet table = tables.TenantTable + + +# +# Contact groups +# + +class ContactGroupListView(generic.ObjectListView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + filterset = filtersets.ContactGroupFilterSet + filterset_form = forms.ContactGroupFilterForm + table = tables.ContactGroupTable + + +class ContactGroupView(generic.ObjectView): + queryset = ContactGroup.objects.all() + + def get_extra_context(self, request, instance): + contacts = Contact.objects.restrict(request.user, 'view').filter( + group=instance + ) + contacts_table = tables.ContactTable(contacts, exclude=('group',)) + paginate_table(contacts_table, request) + + return { + 'contacts_table': contacts_table, + } + + +class ContactGroupEditView(generic.ObjectEditView): + queryset = ContactGroup.objects.all() + model_form = forms.ContactGroupForm + + +class ContactGroupDeleteView(generic.ObjectDeleteView): + queryset = ContactGroup.objects.all() + + +class ContactGroupBulkImportView(generic.BulkImportView): + queryset = ContactGroup.objects.all() + model_form = forms.ContactGroupCSVForm + table = tables.ContactGroupTable + + +class ContactGroupBulkEditView(generic.BulkEditView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + filterset = filtersets.ContactGroupFilterSet + table = tables.ContactGroupTable + form = forms.ContactGroupBulkEditForm + + +class ContactGroupBulkDeleteView(generic.BulkDeleteView): + queryset = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ) + table = tables.ContactGroupTable + + +# +# Contact roles +# + +class ContactRoleListView(generic.ObjectListView): + queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet + filterset_form = forms.ContactRoleFilterForm + table = tables.ContactRoleTable + + +class ContactRoleView(generic.ObjectView): + queryset = ContactRole.objects.all() + + def get_extra_context(self, request, instance): + contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( + role=instance + ) + contacts_table = tables.ContactAssignmentTable(contact_assignments) + paginate_table(contacts_table, request) + + return { + 'contacts_table': contacts_table, + 'contact_count': ContactAssignment.objects.filter(role=instance).count(), + } + + +class ContactRoleEditView(generic.ObjectEditView): + queryset = ContactRole.objects.all() + model_form = forms.ContactRoleForm + + +class ContactRoleDeleteView(generic.ObjectDeleteView): + queryset = ContactRole.objects.all() + + +class ContactRoleBulkImportView(generic.BulkImportView): + queryset = ContactRole.objects.all() + model_form = forms.ContactRoleCSVForm + table = tables.ContactRoleTable + + +class ContactRoleBulkEditView(generic.BulkEditView): + queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet + table = tables.ContactRoleTable + form = forms.ContactRoleBulkEditForm + + +class ContactRoleBulkDeleteView(generic.BulkDeleteView): + queryset = ContactRole.objects.all() + table = tables.ContactRoleTable + + +# +# Contacts +# + +class ContactListView(generic.ObjectListView): + queryset = Contact.objects.all() + filterset = filtersets.ContactFilterSet + filterset_form = forms.ContactFilterForm + table = tables.ContactTable + + +class ContactView(generic.ObjectView): + queryset = Contact.objects.all() + + +class ContactEditView(generic.ObjectEditView): + queryset = Contact.objects.all() + model_form = forms.ContactForm + + +class ContactDeleteView(generic.ObjectDeleteView): + queryset = Contact.objects.all() + + +class ContactBulkImportView(generic.BulkImportView): + queryset = Contact.objects.all() + model_form = forms.ContactCSVForm + table = tables.ContactTable + + +class ContactBulkEditView(generic.BulkEditView): + queryset = Contact.objects.prefetch_related('group') + filterset = filtersets.ContactFilterSet + table = tables.ContactTable + form = forms.ContactBulkEditForm + + +class ContactBulkDeleteView(generic.BulkDeleteView): + queryset = Contact.objects.prefetch_related('group') + filterset = filtersets.ContactFilterSet + table = tables.ContactTable From f193f0d3f9d465872dbb01f9eaf976ded3e4fb3a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 15:09:57 -0400 Subject: [PATCH 032/289] Add contact assignments to models --- netbox/circuits/models.py | 10 +++ netbox/dcim/models/devices.py | 10 +++ netbox/dcim/models/power.py | 5 ++ netbox/dcim/models/racks.py | 5 ++ netbox/dcim/models/sites.py | 20 +++++ netbox/templates/circuits/circuit.html | 11 +-- netbox/templates/circuits/provider.html | 3 +- netbox/templates/dcim/device.html | 1 + netbox/templates/dcim/location.html | 1 + netbox/templates/dcim/manufacturer.html | 1 + netbox/templates/dcim/powerpanel.html | 1 + netbox/templates/dcim/rack.html | 1 + netbox/templates/dcim/region.html | 1 + netbox/templates/dcim/site.html | 89 +++++++++++-------- netbox/templates/dcim/sitegroup.html | 1 + netbox/templates/inc/contacts_panel.html | 49 ++++++++++ netbox/templates/tenancy/tenant.html | 1 + netbox/templates/virtualization/cluster.html | 1 + .../virtualization/clustergroup.html | 1 + .../virtualization/virtualmachine.html | 1 + netbox/tenancy/forms/models.py | 26 ++++++ netbox/tenancy/models.py | 7 +- netbox/tenancy/urls.py | 5 ++ netbox/tenancy/views.py | 35 +++++++- netbox/virtualization/models.py | 15 ++++ 25 files changed, 256 insertions(+), 45 deletions(-) create mode 100644 netbox/templates/inc/contacts_panel.html diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index bc7dcc219..3d213b48d 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -62,6 +62,11 @@ class Provider(PrimaryModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() clone_fields = [ @@ -203,6 +208,11 @@ class Circuit(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 669f5cfbd..308a094c3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -54,6 +54,11 @@ class Manufacturer(OrganizationalModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() class Meta: @@ -584,6 +589,11 @@ class Device(PrimaryModel, ConfigContextModel): comments = models.TextField( blank=True ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 0e9520b36..f4d0ce8df 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -40,6 +40,11 @@ class PowerPanel(PrimaryModel): name = models.CharField( max_length=100 ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c287d7d6c..47fcd42e4 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -175,12 +175,17 @@ class Rack(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='rack' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index b343f61f2..0d9816b0b 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -52,12 +52,17 @@ class Region(NestedGroupModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='region' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -100,12 +105,17 @@ class SiteGroup(NestedGroupModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='site_group' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -221,12 +231,17 @@ class Site(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='site' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) @@ -291,12 +306,17 @@ class Location(NestedGroupModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='location' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index b863a8a0e..3a8096351 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -70,11 +70,12 @@ {% plugin_left_page object %}
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} - {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/image_attachments_panel.html' %} - {% plugin_right_page object %} -
+ {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} + {% include 'inc/contacts_panel.html' %} + {% include 'inc/image_attachments_panel.html' %} + {% plugin_right_page object %} +
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 4d35da0e6..af883e56f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -47,12 +47,13 @@
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %} {% plugin_left_page object %}
{% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %} {% include 'inc/comments_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ec1ea3fa1..9ae9df7d4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -296,6 +296,7 @@
{% endif %} + {% include 'inc/contacts_panel.html' %} {% include 'inc/image_attachments_panel.html' %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index cd0f2a92a..459880ca8 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -72,6 +72,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 85d76f14f..2a56b57cc 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -38,6 +38,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b1367aa1e..10975fa1b 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -44,6 +44,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 5d44e2125..0196a9a18 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -332,6 +332,7 @@ {% endif %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index b46c905c3..1ee21a60e 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -46,6 +46,7 @@ {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1ee8cfce0..92023f8d6 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -76,6 +76,10 @@ Facility {{ object.facility|placeholder }} + + Description + {{ object.description|placeholder }} + AS Number {{ object.asn|placeholder }} @@ -91,19 +95,6 @@ {% endif %} - - Description - {{ object.description|placeholder }} - - -
- -
-
- Contact Info -
-
- - - - - - - - - - - - -
Physical Address @@ -138,33 +129,57 @@ {% endif %}
Contact Name{{ object.contact_name|placeholder }}
Contact Phone - {% if object.contact_phone %} - {{ object.contact_phone }} - {% else %} - - {% endif %} -
Contact E-Mail - {% if object.contact_email %} - {{ object.contact_email }} - {% else %} - - {% endif %} -
+ {% include 'inc/contacts_panel.html' %} +
+
Contact Info
+
+ {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %} + + + + + + + + + + + + + +
Contact Name + {% if object.contact_name %} +
+ +
+ {% endif %} + {{ object.contact_name|placeholder }} +
Contact Phone + {% if object.contact_phone %} +
+ +
+ {{ object.contact_phone }} + {% else %} + + {% endif %} +
Contact E-Mail + {% if object.contact_email %} +
+ +
+ {{ object.contact_email }} + {% else %} + + {% endif %} +
+ {% endwith %} +
+
{% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} {% include 'inc/comments_panel.html' %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 856a86d64..610917078 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -46,6 +46,7 @@ {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/inc/contacts_panel.html b/netbox/templates/inc/contacts_panel.html new file mode 100644 index 000000000..33788a561 --- /dev/null +++ b/netbox/templates/inc/contacts_panel.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
+
Contacts
+
+ {% with contacts=object.contacts.all %} + {% if contacts.exists %} + + + + + + + + {% for contact in contacts %} + + + + + + + {% endfor %} +
NameRolePriority
+ {{ contact.contact }} + {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} + {% if perms.tenancy.change_contactassignment %} + + + + {% endif %} + {% if perms.tenancy.delete_contactassignment %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.tenancy.add_contactassignment %} + + {% endif %} +
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index dee7f7ce7..54b29e946 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -38,6 +38,7 @@ {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %} {% include 'inc/comments_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 769ae431f..fa8cad039 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -62,6 +62,7 @@
{% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index f7e8cbe5b..fd83c10f3 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -32,6 +32,7 @@
{% include 'inc/custom_fields_panel.html' %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 249ef91e4..0ef590112 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -173,6 +173,7 @@ {% endif %} + {% include 'inc/contacts_panel.html' %} {% plugin_right_page object %} diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 6c0640d53..c0aec0aa8 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -1,11 +1,15 @@ +from django import forms + from extras.forms import CustomFieldModelForm from extras.models import Tag from tenancy.models import * from utilities.forms import ( BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea, + StaticSelect, ) __all__ = ( + 'ContactAssignmentForm', 'ContactForm', 'ContactGroupForm', 'ContactRoleForm', @@ -100,3 +104,25 @@ class ContactForm(BootstrapMixin, CustomFieldModelForm): widgets = { 'address': SmallTextarea(attrs={'rows': 3}), } + + +class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): + group = DynamicModelChoiceField( + queryset=ContactGroup.objects.all(), + required=False + ) + contact = DynamicModelChoiceField( + queryset=Contact.objects.all() + ) + role = DynamicModelChoiceField( + queryset=ContactRole.objects.all() + ) + + class Meta: + model = ContactAssignment + fields = ( + 'group', 'contact', 'role', 'priority', + ) + widgets = { + 'priority': StaticSelect(), + } diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index f5e66b753..f416d55b5 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse @@ -86,6 +86,11 @@ class Tenant(PrimaryModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() clone_fields = [ diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 807af161e..14047603d 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -67,4 +67,9 @@ urlpatterns = [ path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}), path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}), + # Contact assignments + path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), + path('contact-assignments//edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'), + path('contact-assignments//delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'), + ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index f4772b288..e7034ed5f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,9 +1,12 @@ +from django.contrib.contenttypes.models import ContentType +from django.http import Http404 +from django.shortcuts import get_object_or_404 + from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic from utilities.tables import paginate_table -from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables from .models import * @@ -309,3 +312,33 @@ class ContactBulkDeleteView(generic.BulkDeleteView): queryset = Contact.objects.prefetch_related('group') filterset = filtersets.ContactFilterSet table = tables.ContactTable + + +# +# Contact assignments +# + +class ContactAssignmentEditView(generic.ObjectEditView): + queryset = ContactAssignment.objects.all() + model_form = forms.ContactAssignmentForm + + def alter_obj(self, instance, request, args, kwargs): + if not instance.pk: + # Assign the object based on URL kwargs + try: + app_label, model = request.GET.get('content_type').split('.') + except (AttributeError, ValueError): + raise Http404("Content type not specified") + content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + return instance + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) + + +class ContactAssignmentDeleteView(generic.ObjectDeleteView): + queryset = ContactAssignment.objects.all() + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3408cedbc..d91a39549 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -81,12 +81,17 @@ class ClusterGroup(OrganizationalModel): max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='cluster_group' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) objects = RestrictedQuerySet.as_manager() @@ -142,12 +147,17 @@ class Cluster(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='cluster' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) objects = RestrictedQuerySet.as_manager() @@ -268,6 +278,11 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): blank=True ) + # Generic relation + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = ConfigContextModelQuerySet.as_manager() clone_fields = [ From faf1e6a43d75cf826c9b460356a70814eb8427d2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 15:30:28 -0400 Subject: [PATCH 033/289] Add contact/role assignment tables --- netbox/templates/tenancy/contact.html | 13 +++++++++++++ netbox/templates/tenancy/contactrole.html | 6 ++++++ netbox/tenancy/tables.py | 23 ++++++++++++++++++----- netbox/tenancy/views.py | 21 +++++++++++++++++++-- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 2ead52e5a..ca46fdb31 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -46,6 +46,12 @@ Address {{ object.address|linebreaksbr|placeholder }} + + Assignments + + {{ assignment_count }} + + @@ -60,6 +66,13 @@
+
+
Assignments
+
+ {% include 'inc/table.html' with table=contacts_table %} +
+
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %} {% plugin_full_width_page object %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html index 688c58177..f081afc34 100644 --- a/netbox/templates/tenancy/contactrole.html +++ b/netbox/templates/tenancy/contactrole.html @@ -21,6 +21,12 @@ Description {{ object.description|placeholder }} + + Assignments + + {{ assignment_count }} + + diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 3401c8fe4..5b254842b 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from utilities.tables import ( - BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) from .models import * @@ -126,23 +126,36 @@ class ContactTable(BaseTable): linkify=True ) comments = MarkdownColumn() + assignment_count = tables.Column( + verbose_name='Assignments' + ) tags = TagColumn( url_name='tenancy:tenant_list' ) class Meta(BaseTable.Meta): model = Contact - fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'tags') - default_columns = ('pk', 'name', 'group', 'title', 'phone', 'email') + fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags') + default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') class ContactAssignmentTable(BaseTable): pk = ToggleColumn() + content_type = ContentTypeColumn( + verbose_name='Object Type' + ) + object = tables.Column( + linkify=True, + orderable=False + ) contact = tables.Column( linkify=True ) + role = tables.Column( + linkify=True + ) class Meta(BaseTable.Meta): model = ContactAssignment - fields = ('pk', 'contact', 'role', 'priority') - default_columns = ('pk', 'contact', 'role', 'priority') + fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') + default_columns = ('pk', 'object', 'contact', 'role', 'priority') diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index e7034ed5f..cdbaebdb1 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -7,6 +7,7 @@ from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic from utilities.tables import paginate_table +from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables from .models import * @@ -236,11 +237,12 @@ class ContactRoleView(generic.ObjectView): role=instance ) contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table.columns.hide('role') paginate_table(contacts_table, request) return { 'contacts_table': contacts_table, - 'contact_count': ContactAssignment.objects.filter(role=instance).count(), + 'assignment_count': ContactAssignment.objects.filter(role=instance).count(), } @@ -276,7 +278,9 @@ class ContactRoleBulkDeleteView(generic.BulkDeleteView): # class ContactListView(generic.ObjectListView): - queryset = Contact.objects.all() + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) filterset = filtersets.ContactFilterSet filterset_form = forms.ContactFilterForm table = tables.ContactTable @@ -285,6 +289,19 @@ class ContactListView(generic.ObjectListView): class ContactView(generic.ObjectView): queryset = Contact.objects.all() + def get_extra_context(self, request, instance): + contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( + contact=instance + ) + contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table.columns.hide('contact') + paginate_table(contacts_table, request) + + return { + 'contacts_table': contacts_table, + 'assignment_count': ContactAssignment.objects.filter(contact=instance).count(), + } + class ContactEditView(generic.ObjectEditView): queryset = Contact.objects.all() From f485a47b4849c1e756f3d3c5c56c3ccb49688a03 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 15:41:29 -0400 Subject: [PATCH 034/289] Tweak uniqueness constraints --- netbox/tenancy/migrations/0003_contacts.py | 11 +++++++---- netbox/tenancy/models.py | 12 ++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index dc6f6c668..6f77810f3 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.8 on 2021-10-18 16:12 - import django.core.serializers.json from django.db import migrations, models import django.db.models.deletion @@ -56,8 +54,8 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=100, unique=True)), - ('slug', models.SlugField(max_length=100, unique=True)), + ('name', models.CharField(max_length=100)), + ('slug', models.SlugField(max_length=100)), ('description', models.CharField(blank=True, max_length=200)), ('lft', models.PositiveIntegerField(editable=False)), ('rght', models.PositiveIntegerField(editable=False)), @@ -67,6 +65,7 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['name'], + 'unique_together': {('parent', 'name')}, }, ), migrations.CreateModel( @@ -95,4 +94,8 @@ class Migration(migrations.Migration): name='tags', field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), ), + migrations.AlterUniqueTogether( + name='contact', + unique_together={('group', 'name')}, + ), ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index f416d55b5..f53f7d0e6 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -117,12 +117,10 @@ class ContactGroup(NestedGroupModel): An arbitrary collection of Contacts. """ name = models.CharField( - max_length=100, - unique=True + max_length=100 ) slug = models.SlugField( - max_length=100, - unique=True + max_length=100 ) parent = TreeForeignKey( to='self', @@ -139,6 +137,9 @@ class ContactGroup(NestedGroupModel): class Meta: ordering = ['name'] + unique_together = ( + ('parent', 'name') + ) def get_absolute_url(self): return reverse('tenancy:contactgroup', args=[self.pk]) @@ -216,6 +217,9 @@ class Contact(PrimaryModel): class Meta: ordering = ['name'] + unique_together = ( + ('group', 'name') + ) def __str__(self): return self.name From 487d67768bf6a52c9e96e8a1a3aebda17640f0fe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 16:20:31 -0400 Subject: [PATCH 035/289] Cleanup and documentation for #1344 --- docs/core-functionality/contacts.md | 5 +++++ docs/models/tenancy/contact.md | 31 +++++++++++++++++++++++++++++ docs/models/tenancy/contactgroup.md | 3 +++ docs/models/tenancy/contactrole.md | 3 +++ mkdocs.yml | 1 + netbox/tenancy/api/serializers.py | 7 +++++-- netbox/tenancy/filtersets.py | 9 +++++---- netbox/tenancy/forms/bulk_edit.py | 15 ++++++++++++++ netbox/tenancy/forms/models.py | 10 ++++++++-- netbox/tenancy/models.py | 3 +++ 10 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 docs/core-functionality/contacts.md create mode 100644 docs/models/tenancy/contact.md create mode 100644 docs/models/tenancy/contactgroup.md create mode 100644 docs/models/tenancy/contactrole.md diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md new file mode 100644 index 000000000..76a005fc0 --- /dev/null +++ b/docs/core-functionality/contacts.md @@ -0,0 +1,5 @@ +# Contacts + +{!models/tenancy/contact.md!} +{!models/tenancy/contactgroup.md!} +{!models/tenancy/contactrole.md!} diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md new file mode 100644 index 000000000..9d81e2d85 --- /dev/null +++ b/docs/models/tenancy/contact.md @@ -0,0 +1,31 @@ +# Contacts + +A contact represent an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. Contacts can be arranged within nested contact groups. + +Each contact must include a name, which is unique to its parent group (if any). The following optional descriptors are also available: + +* Title +* Phone +* Email +* Address + +## Contact Assignment + +Each contact can be assigned to one or more objects, allowing for the efficient reuse of contact information. When assigning a contact to an object, the user may optionally specify a role and/or priority (primary, secondary, tertiary, or inactive) to better convey the nature of the contact's relationship to the assigned object. + +The following models support the assignment of contacts: + +* circuits.Circuit +* circuits.Provider +* dcim.Device +* dcim.Location +* dcim.Manufacturer +* dcim.PowerPanel +* dcim.Rack +* dcim.Region +* dcim.Site +* dcim.SiteGroup +* tenancy.Tenant +* virtualization.Cluster +* virtualization.ClusterGroup +* virtualization.VirtualMachine diff --git a/docs/models/tenancy/contactgroup.md b/docs/models/tenancy/contactgroup.md new file mode 100644 index 000000000..ea566c58a --- /dev/null +++ b/docs/models/tenancy/contactgroup.md @@ -0,0 +1,3 @@ +# Contact Groups + +Contacts can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated. diff --git a/docs/models/tenancy/contactrole.md b/docs/models/tenancy/contactrole.md new file mode 100644 index 000000000..23642ca03 --- /dev/null +++ b/docs/models/tenancy/contactrole.md @@ -0,0 +1,3 @@ +# Contact Roles + +Contacts can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts. diff --git a/mkdocs.yml b/mkdocs.yml index 7244c36d6..72750d6f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - Circuits: 'core-functionality/circuits.md' - Power Tracking: 'core-functionality/power.md' - Tenancy: 'core-functionality/tenancy.md' + - Contacts: 'core-functionality/contacts.md' - Customization: - Custom Fields: 'customization/custom-fields.md' - Custom Validation: 'customization/custom-validation.md' diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 2dfb59455..27a14b350 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,8 +1,9 @@ from django.contrib.auth.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField +from netbox.api import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer +from tenancy.choices import ContactPriorityChoices from tenancy.models import * from .nested_serializers import * @@ -93,9 +94,11 @@ class ContactAssignmentSerializer(PrimaryModelSerializer): ) contact = NestedContactSerializer() role = NestedContactRoleSerializer(required=False, allow_null=True) + priority = ChoiceField(choices=ContactPriorityChoices, required=False) class Meta: model = ContactAssignment fields = [ - 'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'created', 'last_updated', + 'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'priority', 'created', + 'last_updated', ] diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 75f9e351d..f6d0ac72e 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -2,8 +2,8 @@ import django_filters from django.db.models import Q from extras.filters import TagFilter -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet -from utilities.filters import TreeNodeMultipleChoiceFilter +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -168,7 +168,8 @@ class ContactFilterSet(PrimaryModelFilterSet): ) -class ContactAssignmentFilterSet(OrganizationalModelFilterSet): +class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): + content_type = ContentTypeFilter() contact_id = django_filters.ModelMultipleChoiceFilter( queryset=Contact.objects.all(), label='Contact (ID)', @@ -186,4 +187,4 @@ class ContactAssignmentFilterSet(OrganizationalModelFilterSet): class Meta: model = ContactAssignment - fields = ['id', 'priority'] + fields = ['id', 'content_type_id', 'priority'] diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 0d414d2a5..a34b8def1 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -96,6 +96,21 @@ class ContactBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul queryset=ContactGroup.objects.all(), required=False ) + title = forms.CharField( + max_length=100, + required=False + ) + phone = forms.CharField( + max_length=50, + required=False + ) + email = forms.EmailField( + required=False + ) + address = forms.CharField( + max_length=200, + required=False + ) class Meta: nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments'] diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index c0aec0aa8..b15065705 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -109,10 +109,16 @@ class ContactForm(BootstrapMixin, CustomFieldModelForm): class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): group = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), - required=False + required=False, + initial_params={ + 'contacts': '$contact' + } ) contact = DynamicModelChoiceField( - queryset=Contact.objects.all() + queryset=Contact.objects.all(), + query_params={ + 'group_id': '$group' + } ) role = DynamicModelChoiceField( queryset=ContactRole.objects.all() diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index f53f7d0e6..20708f74a 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -259,3 +259,6 @@ class ContactAssignment(ChangeLoggedModel): class Meta: ordering = ('priority', 'contact') + + def __str__(self): + return f"{self.contact} ({self.get_priority_display()})" if self.priority else self.name From b44a5ea60956f63a5514f7b221a9914e3f510012 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 16:33:31 -0400 Subject: [PATCH 036/289] Prevent duplicate contact assignments --- netbox/tenancy/migrations/0003_contacts.py | 54 +++++++++------------- netbox/tenancy/models.py | 1 + 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index 6f77810f3..35e568ab1 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -14,24 +14,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='Contact', - fields=[ - ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=100)), - ('title', models.CharField(blank=True, max_length=100)), - ('phone', models.CharField(blank=True, max_length=50)), - ('email', models.EmailField(blank=True, max_length=254)), - ('address', models.CharField(blank=True, max_length=200)), - ('comments', models.TextField(blank=True)), - ], - options={ - 'ordering': ['name'], - }, - ), migrations.CreateModel( name='ContactRole', fields=[ @@ -68,6 +50,27 @@ class Migration(migrations.Migration): 'unique_together': {('parent', 'name')}, }, ), + migrations.CreateModel( + name='Contact', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('title', models.CharField(blank=True, max_length=100)), + ('phone', models.CharField(blank=True, max_length=50)), + ('email', models.EmailField(blank=True, max_length=254)), + ('address', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('group', 'name')}, + }, + ), migrations.CreateModel( name='ContactAssignment', fields=[ @@ -82,20 +85,7 @@ class Migration(migrations.Migration): ], options={ 'ordering': ('priority', 'contact'), + 'unique_together': {('content_type', 'object_id', 'contact', 'role', 'priority')}, }, ), - migrations.AddField( - model_name='contact', - name='group', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup'), - ), - migrations.AddField( - model_name='contact', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - migrations.AlterUniqueTogether( - name='contact', - unique_together={('group', 'name')}, - ), ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 20708f74a..35c10938b 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -259,6 +259,7 @@ class ContactAssignment(ChangeLoggedModel): class Meta: ordering = ('priority', 'contact') + unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority') def __str__(self): return f"{self.contact} ({self.get_priority_display()})" if self.priority else self.name From 554b44b9f2dfc8d10ddc2e4b3dd492c57bc81d4b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 16:47:49 -0400 Subject: [PATCH 037/289] Fix string repr for ContactAssignment --- netbox/tenancy/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 35c10938b..c709236e2 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -262,4 +262,6 @@ class ContactAssignment(ChangeLoggedModel): unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority') def __str__(self): - return f"{self.contact} ({self.get_priority_display()})" if self.priority else self.name + if self.priority: + return f"{self.contact} ({self.get_priority_display()})" + return str(self.contact) From 0afd3e61894aaf792e4d09c17c493138f097ee54 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 19 Oct 2021 12:33:17 -0400 Subject: [PATCH 038/289] Closes #6715: Add tenant assignment for cables --- docs/release-notes/version-3.1.md | 5 ++++ netbox/dcim/api/serializers.py | 5 ++-- netbox/dcim/filtersets.py | 10 +------- netbox/dcim/forms/bulk_edit.py | 6 ++++- netbox/dcim/forms/bulk_import.py | 8 ++++++- netbox/dcim/forms/connections.py | 17 ++++++++------ netbox/dcim/forms/filtersets.py | 10 ++------ netbox/dcim/forms/models.py | 4 ++-- ...n_tenant.py => 0135_tenancy_extensions.py} | 5 ++++ netbox/dcim/migrations/0136_device_airflow.py | 2 +- netbox/dcim/models/cables.py | 7 ++++++ netbox/dcim/tables/cables.py | 4 +++- netbox/dcim/tests/test_filtersets.py | 23 ++++++++++--------- netbox/templates/dcim/cable.html | 13 +++++++++++ netbox/templates/dcim/inc/cable_form.html | 4 +++- 15 files changed, 79 insertions(+), 44 deletions(-) rename netbox/dcim/migrations/{0135_location_tenant.py => 0135_tenancy_extensions.py} (67%) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index c49552edd..630e46b5b 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,11 +3,16 @@ !!! warning "PostgreSQL 10 Required" NetBox v3.1 requires PostgreSQL 10 or later. +### Breaking Changes + +* The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. + ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support +* [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations ### Other Changes diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9d261d9e8..14a1af8f0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -758,14 +758,15 @@ class CableSerializer(PrimaryModelSerializer): termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CableStatusChoices, required=False) + tenant = NestedTenantSerializer(required=False, allow_null=True) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: model = Cable fields = [ 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', - 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', - 'custom_fields', + 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', + 'tags', 'custom_fields', ] def _get_termination(self, obj, side): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c3de7cb08..c66397029 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1189,7 +1189,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() -class CableFilterSet(PrimaryModelFilterSet): +class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1230,14 +1230,6 @@ class CableFilterSet(PrimaryModelFilterSet): method='filter_device', field_name='device__site__slug' ) - tenant_id = MultiValueNumberFilter( - method='filter_device', - field_name='device__tenant_id' - ) - tenant = MultiValueNumberFilter( - method='filter_device', - field_name='device__tenant__slug' - ) tag = TagFilter() class Meta: diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 289057be9..06ccc958c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -468,6 +468,10 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE widget=StaticSelect(), initial='' ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) label = forms.CharField( max_length=100, required=False @@ -488,7 +492,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE class Meta: nullable_fields = [ - 'type', 'status', 'label', 'color', 'length', + 'type', 'status', 'tenant', 'label', 'color', 'length', ] def clean(self): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index bd9e8cd4a..720ea8dbd 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -821,6 +821,12 @@ class CableCSVForm(CustomFieldModelCSVForm): required=False, help_text='Physical medium classification' ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) length_unit = CSVChoiceField( choices=CableLengthUnitChoices, required=False, @@ -831,7 +837,7 @@ class CableCSVForm(CustomFieldModelCSVForm): model = Cable fields = [ 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'label', 'color', 'length', 'length_unit', + 'status', 'tenant', 'label', 'color', 'length', 'length_unit', ] help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index a2ceea6cf..770dc211b 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -2,6 +2,7 @@ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * from extras.forms import CustomFieldModelForm from extras.models import Tag +from tenancy.forms import TenancyForm from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect __all__ = ( @@ -17,7 +18,7 @@ __all__ = ( ) -class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): """ Base form for connecting a Cable to a Device component """ @@ -78,7 +79,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): model = Cable fields = [ 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', - 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', + 'tags', ] widgets = { 'status': StaticSelect, @@ -169,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm): +class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', @@ -219,7 +221,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) model = Cable fields = [ 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', - 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', + 'tags', ] def clean_termination_b_id(self): @@ -227,7 +230,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) return getattr(self.cleaned_data['termination_b_id'], 'pk', None) -class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): +class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): termination_b_region = DynamicModelChoiceField( queryset=Region.objects.all(), label='Region', @@ -280,8 +283,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', - 'color', 'length', 'length_unit', 'tags', + 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', + 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', ] def clean_termination_b_id(self): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 94e7bce05..501e78b18 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -691,13 +691,13 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod tag = TagFilterField(model) -class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): model = Cable field_groups = [ ['q', 'tag'], ['site_id', 'rack_id', 'device_id'], ['type', 'status', 'color'], - ['tenant_id'], + ['tenant_group_id', 'tenant_id'], ] q = forms.CharField( required=False, @@ -719,12 +719,6 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Site'), fetch_trigger='open' ) - tenant_id = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - required=False, - label=_('Tenant'), - fetch_trigger='open' - ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index cb690840f..8236b1a97 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -601,7 +601,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.fields['position'].widget.choices = [(position, f'U{position}')] -class CableForm(BootstrapMixin, CustomFieldModelForm): +class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -610,7 +610,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/dcim/migrations/0135_location_tenant.py b/netbox/dcim/migrations/0135_tenancy_extensions.py similarity index 67% rename from netbox/dcim/migrations/0135_location_tenant.py rename to netbox/dcim/migrations/0135_tenancy_extensions.py index 0b1f429f9..673b5027f 100644 --- a/netbox/dcim/migrations/0135_location_tenant.py +++ b/netbox/dcim/migrations/0135_tenancy_extensions.py @@ -15,4 +15,9 @@ class Migration(migrations.Migration): name='tenant', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'), ), + migrations.AddField( + model_name='cable', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cables', to='tenancy.tenant'), + ), ] diff --git a/netbox/dcim/migrations/0136_device_airflow.py b/netbox/dcim/migrations/0136_device_airflow.py index a0887a0b4..94cc89f3f 100644 --- a/netbox/dcim/migrations/0136_device_airflow.py +++ b/netbox/dcim/migrations/0136_device_airflow.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0135_location_tenant'), + ('dcim', '0135_tenancy_extensions'), ] operations = [ diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c3f8cac3f..bddce93b9 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -67,6 +67,13 @@ class Cable(PrimaryModel): choices=CableStatusChoices, default=CableStatusChoices.STATUS_CONNECTED ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='cables', + blank=True, + null=True + ) label = models.CharField( max_length=100, blank=True diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 14cf34505..87913cbfd 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,6 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable +from tenancy.tables import TenantColumn from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT @@ -45,6 +46,7 @@ class CableTable(BaseTable): verbose_name='Termination B' ) status = ChoiceFieldColumn() + tenant = TenantColumn() length = TemplateColumn( template_code=CABLE_LENGTH, order_by='_abs_length' @@ -58,7 +60,7 @@ class CableTable(BaseTable): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', 'color', 'length', 'tags', + 'status', 'type', 'tenant', 'color', 'length', 'tags', ) default_columns = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fcee2914b..ce78e0470 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2819,6 +2819,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), ) Tenant.objects.bulk_create(tenants) @@ -2834,9 +2835,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1, tenant=tenants[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1, tenant=tenants[1]), + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1), Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2), Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1), Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2), @@ -2863,12 +2864,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') # Cables - Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() def test_label(self): @@ -2921,9 +2922,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): def test_tenant(self): tenant = Tenant.objects.all()[:2] params = {'tenant_id': [tenant[0].pk, tenant[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'tenant': [tenant[0].slug, tenant[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_termination_types(self): params = {'termination_a_type': 'dcim.consoleport'} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index c7cd71b65..e9cde6e00 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -23,6 +23,19 @@ {{ object.get_status_display }} + + Tenant + + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} + + Label {{ object.label|placeholder }} diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html index 05929821c..0f11ac3cb 100644 --- a/netbox/templates/dcim/inc/cable_form.html +++ b/netbox/templates/dcim/inc/cable_form.html @@ -2,6 +2,8 @@ {% render_field form.status %} {% render_field form.type %} +{% render_field form.tenant_group %} +{% render_field form.tenant %} {% render_field form.label %} {% render_field form.color %}
@@ -17,7 +19,7 @@ {% render_field form.tags %} {% if form.custom_fields %}
-
+
Custom Fields
{% render_custom_fields form %} From 8375995680947986900f9f9abefd56e54513d15c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 19 Oct 2021 13:06:41 -0400 Subject: [PATCH 039/289] Closes #1943: Relax uniqueness constraint on cluster names --- docs/models/virtualization/cluster.md | 2 +- docs/release-notes/version-3.1.md | 1 + netbox/virtualization/api/serializers.py | 4 ++-- .../0024_cluster_relax_uniqueness.py | 21 +++++++++++++++++++ netbox/virtualization/models.py | 7 +++++-- 5 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 netbox/virtualization/migrations/0024_cluster_relax_uniqueness.py diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 3311ad42d..7fc9bfc06 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,5 @@ # Clusters -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 630e46b5b..172fd3ed8 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -10,6 +10,7 @@ ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces +* [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index adad9bf4d..1928960a9 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -44,9 +44,9 @@ class ClusterGroupSerializer(OrganizationalModelSerializer): class ClusterSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() - group = NestedClusterGroupSerializer(required=False, allow_null=True) + group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) - site = NestedSiteSerializer(required=False, allow_null=True) + site = NestedSiteSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) diff --git a/netbox/virtualization/migrations/0024_cluster_relax_uniqueness.py b/netbox/virtualization/migrations/0024_cluster_relax_uniqueness.py new file mode 100644 index 000000000..5ff214d29 --- /dev/null +++ b/netbox/virtualization/migrations/0024_cluster_relax_uniqueness.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0136_device_airflow'), + ('virtualization', '0023_virtualmachine_natural_ordering'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterUniqueTogether( + name='cluster', + unique_together={('site', 'name'), ('group', 'name')}, + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index d91a39549..11792944a 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -115,8 +115,7 @@ class Cluster(PrimaryModel): A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ name = models.CharField( - max_length=100, - unique=True + max_length=100 ) type = models.ForeignKey( to=ClusterType, @@ -167,6 +166,10 @@ class Cluster(PrimaryModel): class Meta: ordering = ['name'] + unique_together = ( + ('group', 'name'), + ('site', 'name'), + ) def __str__(self): return self.name From 8d0ed99bcd9fba25c4469a789b1a80a2c1b22383 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 19 Oct 2021 13:32:43 -0400 Subject: [PATCH 040/289] Clean up UniqueTogetherValidator workarounds --- netbox/dcim/api/serializers.py | 44 +++++++--------------------------- netbox/ipam/api/serializers.py | 34 ++++---------------------- 2 files changed, 12 insertions(+), 66 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 14a1af8f0..22ea903bb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,7 +2,6 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from rest_framework.validators import UniqueTogetherValidator from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * @@ -170,6 +169,8 @@ class RackSerializer(PrimaryModelSerializer): status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) + facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label='Facility ID', + default=None) width = ChoiceField(choices=RackWidthChoices, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) @@ -182,23 +183,6 @@ class RackSerializer(PrimaryModelSerializer): 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', ] - # Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This - # prevents facility_id from being interpreted as a required field. - validators = [ - UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'name')) - ] - - def validate(self, data): - - # Validate uniqueness of (location, facility_id) since we omitted the automatically-created validator from Meta. - if data.get('facility_id', None): - validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'facility_id')) - validator(data, self) - - # Enforce model validation - super().validate(data) - - return data class RackUnitSerializer(serializers.Serializer): @@ -458,12 +442,13 @@ class DeviceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() location = NestedLocationSerializer(required=False, allow_null=True, default=None) - rack = NestedRackSerializer(required=False, allow_null=True) - face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) + rack = NestedRackSerializer(required=False, allow_null=True, default=None) + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') + position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None) status = ChoiceField(choices=DeviceStatusChoices, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) @@ -471,7 +456,8 @@ class DeviceSerializer(PrimaryModelSerializer): primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) - virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True) + virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) + vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) class Meta: model = Device @@ -481,19 +467,6 @@ class DeviceSerializer(PrimaryModelSerializer): 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] - validators = [] - - def validate(self, data): - - # Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta. - if data.get('rack') and data.get('position') and data.get('face'): - validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face')) - validator(data, self) - - # Enforce model validation - super().validate(data) - - return data @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) def get_parent_device(self, obj): @@ -730,7 +703,6 @@ class DeviceBaySerializer(PrimaryModelSerializer): class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() - # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) _depth = serializers.IntegerField(source='level', read_only=True) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9f3139793..183c45b2a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,7 +3,6 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from ipam.choices import * @@ -117,8 +116,10 @@ class VLANGroupSerializer(OrganizationalModelSerializer): queryset=ContentType.objects.filter( model__in=VLANGROUP_SCOPE_TYPES ), - required=False + required=False, + default=None ) + scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope = serializers.SerializerMethodField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) @@ -130,19 +131,6 @@ class VLANGroupSerializer(OrganizationalModelSerializer): ] validators = [] - def validate(self, data): - - # Validate uniqueness of name and slug if a site has been assigned. - if data.get('site', None): - for field in ['name', 'slug']: - validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field)) - validator(data, self) - - # Enforce model validation - super().validate(data) - - return data - def get_scope(self, obj): if obj.scope_id is None: return None @@ -155,7 +143,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer): class VLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') site = NestedSiteSerializer(required=False, allow_null=True) - group = NestedVLANGroupSerializer(required=False, allow_null=True) + group = NestedVLANGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) @@ -167,20 +155,6 @@ class VLANSerializer(PrimaryModelSerializer): 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] - validators = [] - - def validate(self, data): - - # Validate uniqueness of vid and name if a group has been assigned. - if data.get('group', None): - for field in ['vid', 'name']: - validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field)) - validator(data, self) - - # Enforce model validation - super().validate(data) - - return data # From 7c56b21095689667cc25125f5888526803449471 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 19 Oct 2021 13:46:35 -0400 Subject: [PATCH 041/289] Closes #7354: Relax uniqueness constraints on region, site group, and location names --- docs/models/dcim/location.md | 3 +- docs/models/dcim/rack.md | 2 +- docs/models/dcim/region.md | 2 + docs/models/dcim/sitegroup.md | 2 + docs/release-notes/version-3.1.md | 1 + netbox/dcim/api/serializers.py | 4 +- .../0137_relax_uniqueness_constraints.py | 45 +++++++++++++++++++ netbox/dcim/models/sites.py | 32 ++++++++----- 8 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 netbox/dcim/migrations/0137_relax_uniqueness_constraints.py diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 16df208ac..901a68acf 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -2,4 +2,5 @@ Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. -The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.) +Each location must have a name that is unique within its parent site and location, if any. + diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 90c9cfe6e..9465a828c 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -1,6 +1,6 @@ # Racks -The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles. +The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order. diff --git a/docs/models/dcim/region.md b/docs/models/dcim/region.md index 734467500..bac186264 100644 --- a/docs/models/dcim/region.md +++ b/docs/models/dcim/region.md @@ -1,3 +1,5 @@ # Regions Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. + +Each region must have a name that is unique within its parent region, if any. diff --git a/docs/models/dcim/sitegroup.md b/docs/models/dcim/sitegroup.md index 3c1ed11bd..04ebcc1a5 100644 --- a/docs/models/dcim/sitegroup.md +++ b/docs/models/dcim/sitegroup.md @@ -1,3 +1,5 @@ # Site Groups Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups. + +Each site group must have a name that is unique within its parent group, if any. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 172fd3ed8..a94f749c7 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -15,6 +15,7 @@ * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations +* [#7354](https://github.com/netbox-community/netbox/issues/7354) - Relax uniqueness constraints on region, site group, and location names ### Other Changes diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 22ea903bb..9b0e7f5b3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -81,7 +81,7 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer): class RegionSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') - parent = NestedRegionSerializer(required=False, allow_null=True) + parent = NestedRegionSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True) class Meta: @@ -94,7 +94,7 @@ class RegionSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') - parent = NestedSiteGroupSerializer(required=False, allow_null=True) + parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True) class Meta: diff --git a/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py new file mode 100644 index 000000000..8f7d40026 --- /dev/null +++ b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.8 on 2021-10-19 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0136_device_airflow'), + ] + + operations = [ + migrations.AlterField( + model_name='region', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='region', + name='slug', + field=models.SlugField(max_length=100), + ), + migrations.AlterField( + model_name='sitegroup', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='sitegroup', + name='slug', + field=models.SlugField(max_length=100), + ), + migrations.AlterUniqueTogether( + name='location', + unique_together={('site', 'parent', 'name'), ('site', 'parent', 'slug')}, + ), + migrations.AlterUniqueTogether( + name='region', + unique_together={('parent', 'slug'), ('parent', 'name')}, + ), + migrations.AlterUniqueTogether( + name='sitegroup', + unique_together={('parent', 'slug'), ('parent', 'name')}, + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 0d9816b0b..ab9d8e82d 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -41,12 +41,10 @@ class Region(NestedGroupModel): db_index=True ) name = models.CharField( - max_length=100, - unique=True + max_length=100 ) slug = models.SlugField( - max_length=100, - unique=True + max_length=100 ) description = models.CharField( max_length=200, @@ -64,6 +62,12 @@ class Region(NestedGroupModel): to='tenancy.ContactAssignment' ) + class Meta: + unique_together = ( + ('parent', 'name'), + ('parent', 'slug'), + ) + def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -94,12 +98,10 @@ class SiteGroup(NestedGroupModel): db_index=True ) name = models.CharField( - max_length=100, - unique=True + max_length=100 ) slug = models.SlugField( - max_length=100, - unique=True + max_length=100 ) description = models.CharField( max_length=200, @@ -117,6 +119,12 @@ class SiteGroup(NestedGroupModel): to='tenancy.ContactAssignment' ) + class Meta: + unique_together = ( + ('parent', 'name'), + ('parent', 'slug'), + ) + def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -325,10 +333,10 @@ class Location(NestedGroupModel): class Meta: ordering = ['site', 'name'] - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] + unique_together = ([ + ('site', 'parent', 'name'), + ('site', 'parent', 'slug'), + ]) def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) From 38bc5de3e8047363dc4342841d50d4e88b7b394d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 19 Oct 2021 13:56:33 -0400 Subject: [PATCH 042/289] Changelog for #1344 --- docs/release-notes/version-3.1.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index a94f749c7..abf9c7d25 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -7,6 +7,14 @@ * The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. +#### Contacts ([#1344](https://github.com/netbox-community/netbox/issues/1344)) + +A set of new models for tracking contact information has been introduced within the tenancy app. Users may now create individual contact objects to be associated with various models within NetBox. Each contact has a name, title, email address, etc. Contacts can be arranged in hierarchical groups for ease of management. + +When assigning a contact to an object, the user must select a predefined role (e.g. "billing" or "technical") and may optionally indicate a priority relative to other contacts associated with the object. There is no limit on how many contacts can be assigned to an object, nor on how many objects to which a contact can be assigned. + +#### + ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces @@ -20,3 +28,21 @@ ### Other Changes * [#7318](https://github.com/netbox-community/netbox/issues/7318) - Raise minimum required PostgreSQL version from 9.6 to 10 + +### REST API Changes + +* Added the following endpoints for contacts: + * `/api/tenancy/contact-assignments/` + * `/api/tenancy/contact-groups/` + * `/api/tenancy/contact-roles/` + * `/api/tenancy/contacts/` +* dcim.Cable + * Added `tenant` field +* dcim.Device + * Added `airflow` field +* dcim.DeviceType + * Added `airflow` field +* dcim.Interface + * Added `wwn` field +* dcim.Location + * Added `tenant` field From f04dc5503014f12f77ba0cc3af00e6edc3ea7cbb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 19 Oct 2021 14:21:31 -0400 Subject: [PATCH 043/289] Reorganize panel inclusion templates --- netbox/templates/circuits/circuit.html | 10 +-- netbox/templates/circuits/circuittype.html | 2 +- netbox/templates/circuits/provider.html | 8 +- .../templates/circuits/providernetwork.html | 6 +- netbox/templates/dcim/cable.html | 4 +- netbox/templates/dcim/consoleport.html | 4 +- netbox/templates/dcim/consoleserverport.html | 4 +- netbox/templates/dcim/device.html | 10 +-- netbox/templates/dcim/devicebay.html | 4 +- netbox/templates/dcim/devicerole.html | 2 +- netbox/templates/dcim/devicetype.html | 6 +- netbox/templates/dcim/frontport.html | 4 +- netbox/templates/dcim/interface.html | 4 +- netbox/templates/dcim/inventoryitem.html | 4 +- netbox/templates/dcim/location.html | 6 +- netbox/templates/dcim/manufacturer.html | 4 +- netbox/templates/dcim/platform.html | 2 +- netbox/templates/dcim/powerfeed.html | 6 +- netbox/templates/dcim/poweroutlet.html | 4 +- netbox/templates/dcim/powerpanel.html | 8 +- netbox/templates/dcim/powerport.html | 4 +- netbox/templates/dcim/rack.html | 10 +-- netbox/templates/dcim/rackreservation.html | 4 +- netbox/templates/dcim/rackrole.html | 2 +- netbox/templates/dcim/rearport.html | 4 +- netbox/templates/dcim/region.html | 4 +- netbox/templates/dcim/site.html | 10 +-- netbox/templates/dcim/sitegroup.html | 4 +- netbox/templates/dcim/virtualchassis.html | 4 +- netbox/templates/extras/journalentry.html | 2 +- .../comments.html} | 0 .../contacts.html} | 0 .../custom_fields.html} | 0 .../image_attachments.html} | 0 .../tags_panel.html => inc/panels/tags.html} | 0 netbox/templates/ipam/aggregate.html | 4 +- netbox/templates/ipam/ipaddress.html | 4 +- netbox/templates/ipam/iprange.html | 4 +- netbox/templates/ipam/prefix.html | 4 +- netbox/templates/ipam/rir.html | 2 +- netbox/templates/ipam/role.html | 2 +- netbox/templates/ipam/routetarget.html | 80 +++++++++---------- netbox/templates/ipam/service.html | 4 +- netbox/templates/ipam/vlan.html | 4 +- netbox/templates/ipam/vlangroup.html | 2 +- netbox/templates/ipam/vrf.html | 4 +- netbox/templates/tenancy/contact.html | 6 +- netbox/templates/tenancy/contactgroup.html | 2 +- netbox/templates/tenancy/contactrole.html | 2 +- netbox/templates/tenancy/tenant.html | 8 +- netbox/templates/tenancy/tenantgroup.html | 2 +- netbox/templates/virtualization/cluster.html | 8 +- .../virtualization/clustergroup.html | 4 +- .../templates/virtualization/clustertype.html | 2 +- .../virtualization/virtualmachine.html | 8 +- .../templates/virtualization/vminterface.html | 4 +- 56 files changed, 154 insertions(+), 156 deletions(-) rename netbox/templates/inc/{comments_panel.html => panels/comments.html} (100%) rename netbox/templates/inc/{contacts_panel.html => panels/contacts.html} (100%) rename netbox/templates/inc/{custom_fields_panel.html => panels/custom_fields.html} (100%) rename netbox/templates/inc/{image_attachments_panel.html => panels/image_attachments.html} (100%) rename netbox/templates/{extras/inc/tags_panel.html => inc/panels/tags.html} (100%) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 3a8096351..b61dac6fc 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -64,16 +64,16 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:circuit_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/contacts_panel.html' %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index 899ba83c3..ad81de7e1 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -31,7 +31,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index af883e56f..d353e4f37 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -47,13 +47,13 @@ - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:provider_list' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/comments_panel.html' %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index a5eac1f78..18a11e115 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -37,9 +37,9 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:providernetwork_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:providernetwork_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index e9cde6e00..c5d1f7906 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -63,8 +63,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:cable_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:cable_list' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index ee8b56980..c340cbc5c 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -40,8 +40,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 8eb84993c..91de60252 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -40,8 +40,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 9ae9df7d4..869ab1ec7 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -220,9 +220,9 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:device_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:device_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
@@ -296,8 +296,8 @@
{% endif %} - {% include 'inc/contacts_panel.html' %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %}
Related Devices diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index cc19413b1..918b6b022 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -32,8 +32,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 382cbc4ee..2c2d7fe6f 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -61,7 +61,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 40955f5d6..4239f9eb2 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -130,9 +130,9 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:devicetype_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:devicetype_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 43ded0c6a..c6b6cea48 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -52,8 +52,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index f9a9b0425..0715bec58 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -102,8 +102,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 545e8f1e4..e55d441d4 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -64,8 +64,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 459880ca8..eeb891daf 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -71,9 +71,9 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/contacts_panel.html' %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 2a56b57cc..792a3e127 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -37,8 +37,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index 7229d8078..bbdf809dd 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -66,7 +66,7 @@
{{ object.napalm_args }}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index b4fb06081..f29a127e3 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -107,8 +107,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerfeed_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerfeed_list' %} {% plugin_left_page object %}
@@ -182,7 +182,7 @@
{% endif %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index f8973c79b..1f960e0d5 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -44,8 +44,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 10975fa1b..a99aabf32 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -39,13 +39,13 @@
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerpanel_list' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/contacts_panel.html' %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index db367df1f..74ad9603b 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -44,8 +44,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 0196a9a18..586d31771 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -162,9 +162,9 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:rack_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rack_list' %} + {% include 'inc/panels/comments.html' %} {% if power_feeds %}
@@ -206,7 +206,7 @@
{% endif %} - {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/image_attachments.html' %}
Reservations @@ -332,7 +332,7 @@
{% endif %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 9d1b4deea..07ca55f7c 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -83,8 +83,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:rackreservation_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rackreservation_list' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 703e7e3d2..2668905f4 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -37,7 +37,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 1104bd988..b60e04882 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -46,8 +46,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 1ee21a60e..c03b11e7d 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -45,8 +45,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 92023f8d6..8442ae41e 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -132,7 +132,7 @@
- {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/contacts.html' %}
Contact Info
@@ -180,9 +180,9 @@ {% endwith %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:site_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
@@ -257,7 +257,7 @@ {% endif %}
- {% include 'inc/image_attachments_panel.html' %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 610917078..dbee2c835 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -45,8 +45,8 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 12088e892..fd31be60d 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -38,8 +38,8 @@
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:virtualchassis_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:virtualchassis_list' %} {% plugin_left_page object %}
diff --git a/netbox/templates/extras/journalentry.html b/netbox/templates/extras/journalentry.html index 925d98b41..2e7fcbbf5 100644 --- a/netbox/templates/extras/journalentry.html +++ b/netbox/templates/extras/journalentry.html @@ -45,7 +45,7 @@
- {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/comments.html' %}
{% endblock %} diff --git a/netbox/templates/inc/comments_panel.html b/netbox/templates/inc/panels/comments.html similarity index 100% rename from netbox/templates/inc/comments_panel.html rename to netbox/templates/inc/panels/comments.html diff --git a/netbox/templates/inc/contacts_panel.html b/netbox/templates/inc/panels/contacts.html similarity index 100% rename from netbox/templates/inc/contacts_panel.html rename to netbox/templates/inc/panels/contacts.html diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/panels/custom_fields.html similarity index 100% rename from netbox/templates/inc/custom_fields_panel.html rename to netbox/templates/inc/panels/custom_fields.html diff --git a/netbox/templates/inc/image_attachments_panel.html b/netbox/templates/inc/panels/image_attachments.html similarity index 100% rename from netbox/templates/inc/image_attachments_panel.html rename to netbox/templates/inc/panels/image_attachments.html diff --git a/netbox/templates/extras/inc/tags_panel.html b/netbox/templates/inc/panels/tags.html similarity index 100% rename from netbox/templates/extras/inc/tags_panel.html rename to netbox/templates/inc/panels/tags.html diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index c254d9d63..202b6e41c 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -64,8 +64,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:aggregate_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:aggregate_list' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 668290458..d98544de4 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -107,7 +107,7 @@ - {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %} @@ -145,7 +145,7 @@
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:ipaddress_list' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:ipaddress_list' %}
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index 729f1ed42..e3d37a87a 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -82,8 +82,8 @@ {% plugin_left_page object %}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:prefix_list' %} - {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 4e3fd2edf..877ed49e0 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -121,8 +121,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/rir.html b/netbox/templates/ipam/rir.html index d9d13e110..26d5e71da 100644 --- a/netbox/templates/ipam/rir.html +++ b/netbox/templates/ipam/rir.html @@ -41,7 +41,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 72a4767c9..7fc967047 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -35,7 +35,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index 94eec6a15..f615d2d50 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -3,50 +3,48 @@ {% load plugins %} {% block content %} -
-
-
-
- Route Target -
-
- - - - - - - - - - - - - -
Name{{ object.name }}
Tenant - {% if object.tenant %} - {{ object.tenant }} - {% else %} - None - {% endif %} -
Description{{ object.description|placeholder }}
-
+
+
+
+
Route Target
+
+ + + + + + + + + + + + + +
Name{{ object.name }}
Tenant + {% if object.tenant %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:routetarget_list' %} - {% include 'inc/custom_fields_panel.html' %} - {% plugin_left_page object %} -
-
-
+
+ {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:routetarget_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
+
+
{% include 'inc/panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %} -
- {% include 'inc/panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} - {% plugin_right_page object %} +
+ {% include 'inc/panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} + {% plugin_right_page object %}
-
-
+
+
- {% plugin_full_width_page object %} + {% plugin_full_width_page object %}
-
+
{% endblock %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 6083d1b5e..7609a280b 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -60,8 +60,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:service_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:service_list' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 5ecd6efed..e8c514cca 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -82,8 +82,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vlan_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vlan_list' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index a46bef3b0..2d31feb22 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -57,7 +57,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 863753c0d..b320fe6b8 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -60,8 +60,8 @@ {% plugin_left_page object %}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vrf_list' %} - {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vrf_list' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index ca46fdb31..8bdf6c030 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -55,12 +55,12 @@ - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index 1511565c3..0eef750eb 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -48,7 +48,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html index f081afc34..4ddde3624 100644 --- a/netbox/templates/tenancy/contactrole.html +++ b/netbox/templates/tenancy/contactrole.html @@ -33,7 +33,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 54b29e946..dc51b48c5 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -35,10 +35,10 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %} - {% include 'inc/comments_panel.html' %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index 06fd07522..31a756d9e 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -48,7 +48,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index fa8cad039..84b8235ad 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -56,13 +56,13 @@ - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:cluster_list' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index fd83c10f3..b367d97f7 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -31,8 +31,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html index 9ef1abb8e..e3c050a1b 100644 --- a/netbox/templates/virtualization/clustertype.html +++ b/netbox/templates/virtualization/clustertype.html @@ -31,7 +31,7 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0ef590112..0d9ea4a22 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -89,9 +89,9 @@ - {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %} - {% include 'inc/comments_panel.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
@@ -173,7 +173,7 @@
{% endif %} - {% include 'inc/contacts_panel.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 6a618a1be..ef12b63a1 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -69,8 +69,8 @@ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all %} {% plugin_right_page object %}
From a66501250e10f09641e5b0bef927db10014323aa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 10:58:15 -0400 Subject: [PATCH 044/289] Add wireless authentication attributes --- .../wireless/inc/authentication_attrs.html | 21 ++++++++++ netbox/templates/wireless/wirelesslan.html | 3 +- netbox/templates/wireless/wirelesslink.html | 7 +++- netbox/wireless/api/serializers.py | 10 ++++- netbox/wireless/choices.py | 26 ++++++++++++ netbox/wireless/constants.py | 1 + netbox/wireless/filtersets.py | 17 +++++++- netbox/wireless/forms/bulk_edit.py | 27 +++++++++++- netbox/wireless/forms/bulk_import.py | 25 ++++++++++- netbox/wireless/forms/filtersets.py | 27 ++++++++++++ netbox/wireless/forms/models.py | 19 +++++++-- .../wireless/migrations/0002_wireless_auth.py | 41 +++++++++++++++++++ netbox/wireless/models.py | 34 +++++++++++++-- netbox/wireless/tables.py | 15 +++++-- 14 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 netbox/templates/wireless/inc/authentication_attrs.html create mode 100644 netbox/wireless/migrations/0002_wireless_auth.py diff --git a/netbox/templates/wireless/inc/authentication_attrs.html b/netbox/templates/wireless/inc/authentication_attrs.html new file mode 100644 index 000000000..ed4c7546c --- /dev/null +++ b/netbox/templates/wireless/inc/authentication_attrs.html @@ -0,0 +1,21 @@ +{% load helpers %} + +
+
Authentication
+
+ + + + + + + + + + + + + +
Type{{ object.get_auth_type_display|placeholder }}
Cipher{{ object.get_auth_cipher_display|placeholder }}
PSK{{ object.auth_psk|placeholder }}
+
+
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index cfe13ca45..5c6784de4 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -40,10 +40,11 @@ - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslan_list' %} + {% include 'wireless/inc/authentication_attrs.html' %} {% plugin_left_page object %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslan_list' %} {% include 'inc/custom_fields_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 45ec6b0c9..afdeff357 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -17,7 +17,9 @@ - + @@ -30,6 +32,7 @@
Status{{ object.get_status_display }} + {{ object.get_status_display }} +
SSID
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslink_list' %} {% plugin_left_page object %}
@@ -39,8 +42,8 @@ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %}
+ {% include 'wireless/inc/authentication_attrs.html' %} {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslink_list' %} {% plugin_right_page object %} diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 24395b77c..e9be35618 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -5,6 +5,7 @@ from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api import ChoiceField from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer +from wireless.choices import * from wireless.models import * from .nested_serializers import * @@ -30,11 +31,13 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) + auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) + auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'vlan', + 'id', 'url', 'display', 'ssid', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', ] @@ -43,9 +46,12 @@ class WirelessLinkSerializer(PrimaryModelSerializer): status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = NestedInterfaceSerializer() interface_b = NestedInterfaceSerializer() + auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) + auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLink fields = [ - 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type', + 'auth_cipher', 'auth_psk', ] diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 8a710b532..c8e7fd09f 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -163,3 +163,29 @@ class WirelessChannelChoices(ChoiceSet): ) ), ) + + +class WirelessAuthTypeChoices(ChoiceSet): + TYPE_OPEN = 'open' + TYPE_WEP = 'wep' + TYPE_WPA_PERSONAL = 'wpa-personal' + TYPE_WPA_ENTERPRISE = 'wpa-enterprise' + + CHOICES = ( + (TYPE_OPEN, 'Open'), + (TYPE_WEP, 'WEP'), + (TYPE_WPA_PERSONAL, 'WPA Personal (PSK)'), + (TYPE_WPA_ENTERPRISE, 'WPA Enterprise'), + ) + + +class WirelessAuthCipherChoices(ChoiceSet): + CIPHER_AUTO = 'auto' + CIPHER_TKIP = 'tkip' + CIPHER_AES = 'aes' + + CHOICES = ( + (CIPHER_AUTO, 'Auto'), + (CIPHER_TKIP, 'TKIP'), + (CIPHER_AES, 'AES'), + ) diff --git a/netbox/wireless/constants.py b/netbox/wireless/constants.py index 188c4abd9..63de2b136 100644 --- a/netbox/wireless/constants.py +++ b/netbox/wireless/constants.py @@ -1 +1,2 @@ SSID_MAX_LENGTH = 32 # Per IEEE 802.11-2007 +PSK_MAX_LENGTH = 64 diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index a5d9b7d75..cc67c1fc3 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from extras.filters import TagFilter from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from .choices import * from .models import * __all__ = ( @@ -36,11 +37,17 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): group_id = django_filters.ModelMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all() ) + auth_type = django_filters.MultipleChoiceFilter( + choices=WirelessAuthTypeChoices + ) + auth_cipher = django_filters.MultipleChoiceFilter( + choices=WirelessAuthCipherChoices + ) tag = TagFilter() class Meta: model = WirelessLAN - fields = ['id', 'ssid'] + fields = ['id', 'ssid', 'auth_psk'] def search(self, queryset, name, value): if not value.strip(): @@ -60,11 +67,17 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=LinkStatusChoices ) + auth_type = django_filters.MultipleChoiceFilter( + choices=WirelessAuthTypeChoices + ) + auth_cipher = django_filters.MultipleChoiceFilter( + choices=WirelessAuthCipherChoices + ) tag = TagFilter() class Meta: model = WirelessLink - fields = ['id', 'ssid'] + fields = ['id', 'ssid', 'auth_psk'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index c0d5a925e..1da98026c 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -4,6 +4,7 @@ from dcim.choices import LinkStatusChoices from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField +from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -52,9 +53,20 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode description = forms.CharField( required=False ) + auth_type = forms.ChoiceField( + choices=WirelessAuthTypeChoices, + required=False + ) + auth_cipher = forms.ChoiceField( + choices=WirelessAuthCipherChoices, + required=False + ) + auth_psk = forms.CharField( + required=False + ) class Meta: - nullable_fields = ['ssid', 'group', 'vlan', 'description'] + nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): @@ -73,6 +85,17 @@ class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMod description = forms.CharField( required=False ) + auth_type = forms.ChoiceField( + choices=WirelessAuthTypeChoices, + required=False + ) + auth_cipher = forms.ChoiceField( + choices=WirelessAuthCipherChoices, + required=False + ) + auth_psk = forms.CharField( + required=False + ) class Meta: - nullable_fields = ['ssid', 'description'] + nullable_fields = ['ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 6b22728f6..e9e9afed6 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -3,6 +3,7 @@ from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField +from wireless.choices import * from wireless.models import * __all__ = ( @@ -38,10 +39,20 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Bridged VLAN' ) + auth_type = CSVChoiceField( + choices=WirelessAuthTypeChoices, + required=False, + help_text='Authentication type' + ) + auth_cipher = CSVChoiceField( + choices=WirelessAuthCipherChoices, + required=False, + help_text='Authentication cipher' + ) class Meta: model = WirelessLAN - fields = ('ssid', 'group', 'description', 'vlan') + fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk') class WirelessLinkCSVForm(CustomFieldModelCSVForm): @@ -55,7 +66,17 @@ class WirelessLinkCSVForm(CustomFieldModelCSVForm): interface_b = CSVModelChoiceField( queryset=Interface.objects.all() ) + auth_type = CSVChoiceField( + choices=WirelessAuthTypeChoices, + required=False, + help_text='Authentication type' + ) + auth_cipher = CSVChoiceField( + choices=WirelessAuthCipherChoices, + required=False, + help_text='Authentication cipher' + ) class Meta: model = WirelessLink - fields = ('interface_a', 'interface_b', 'ssid', 'description') + fields = ('interface_a', 'interface_b', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 13aae99a5..483d74a7c 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -6,6 +6,7 @@ from extras.forms import CustomFieldModelFilterForm from utilities.forms import ( add_blank_choice, BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField, ) +from wireless.choices import * from wireless.models import * __all__ = ( @@ -52,6 +53,19 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Group'), fetch_trigger='open' ) + auth_type = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthTypeChoices), + widget=StaticSelect() + ) + auth_cipher = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthCipherChoices), + widget=StaticSelect() + ) + auth_psk = forms.CharField( + required=False + ) tag = TagFilterField(model) @@ -74,4 +88,17 @@ class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): choices=add_blank_choice(LinkStatusChoices), widget=StaticSelect() ) + auth_type = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthTypeChoices), + widget=StaticSelect() + ) + auth_cipher = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthCipherChoices), + widget=StaticSelect() + ) + auth_psk = forms.CharField( + required=False + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 9a7b78b31..aa453ba64 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -35,7 +35,8 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + label='VLAN' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -45,12 +46,17 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'vlan', 'tags', + 'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), ('VLAN', ('vlan',)), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) + widgets = { + 'auth_type': StaticSelect, + 'auth_cipher': StaticSelect, + } class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): @@ -94,8 +100,15 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags', + 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'auth_type', + 'auth_cipher', 'auth_psk', 'tags', ] + fieldsets = ( + ('Link', ('device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) widgets = { 'status': StaticSelect, + 'auth_type': StaticSelect, + 'auth_cipher': StaticSelect, } diff --git a/netbox/wireless/migrations/0002_wireless_auth.py b/netbox/wireless/migrations/0002_wireless_auth.py new file mode 100644 index 000000000..9ca4e351c --- /dev/null +++ b/netbox/wireless/migrations/0002_wireless_auth.py @@ -0,0 +1,41 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='auth_cipher', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslan', + name='auth_psk', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='wirelesslan', + name='auth_type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_cipher', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_psk', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index b0cacde15..43818279e 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -8,7 +8,8 @@ from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel from utilities.querysets import RestrictedQuerySet -from .constants import SSID_MAX_LENGTH +from .choices import * +from .constants import * __all__ = ( 'WirelessLAN', @@ -17,6 +18,30 @@ __all__ = ( ) +class WirelessAuthenticationBase(models.Model): + """ + Abstract model for attaching attributes related to wireless authentication. + """ + auth_type = models.CharField( + max_length=50, + choices=WirelessAuthTypeChoices, + blank=True + ) + auth_cipher = models.CharField( + max_length=50, + choices=WirelessAuthCipherChoices, + blank=True + ) + auth_psk = models.CharField( + max_length=PSK_MAX_LENGTH, + blank=True, + verbose_name='Pre-shared key' + ) + + class Meta: + abstract = True + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class WirelessLANGroup(NestedGroupModel): """ @@ -49,12 +74,15 @@ class WirelessLANGroup(NestedGroupModel): ('parent', 'name') ) + def __str__(self): + return self.name + def get_absolute_url(self): return reverse('wireless:wirelesslangroup', args=[self.pk]) @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class WirelessLAN(PrimaryModel): +class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -95,7 +123,7 @@ class WirelessLAN(PrimaryModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class WirelessLink(PrimaryModel): +class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. """ diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 486fa2a71..ec8f3ddd2 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -48,8 +48,11 @@ class WirelessLANTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLAN - fields = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'tags') - default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count') + fields = ( + 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', + 'tags', + ) + default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') class WirelessLANInterfacesTable(BaseTable): @@ -94,7 +97,11 @@ class WirelessLinkTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLink - fields = ('pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description') - default_columns = ( + fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', + 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + ) + default_columns = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', + 'description', ) From 4a7159389ee7cb5713e5749cc92bd0f133206ffc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 11:22:56 -0400 Subject: [PATCH 045/289] Add wireless documentation --- docs/core-functionality/wireless.md | 8 ++++++++ docs/models/dcim/interface.md | 11 +++++++++++ docs/models/wireless/wirelesslan.md | 11 +++++++++++ docs/models/wireless/wirelesslangroup.md | 3 +++ docs/models/wireless/wirelesslink.md | 9 +++++++++ mkdocs.yml | 1 + 6 files changed, 43 insertions(+) create mode 100644 docs/core-functionality/wireless.md create mode 100644 docs/models/wireless/wirelesslan.md create mode 100644 docs/models/wireless/wirelesslangroup.md create mode 100644 docs/models/wireless/wirelesslink.md diff --git a/docs/core-functionality/wireless.md b/docs/core-functionality/wireless.md new file mode 100644 index 000000000..57133f756 --- /dev/null +++ b/docs/core-functionality/wireless.md @@ -0,0 +1,8 @@ +# Wireless Networks + +{!models/wireless/wirelesslan.md!} +{!models/wireless/wirelesslangroup.md!} + +--- + +{!models/wireless/wirelesslink.md!} diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index bd9975a72..585674de1 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -11,6 +11,17 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. +### Wireless Interfaces + +Wireless interfaces may additionally track the following attributes: + +* **Role** - AP or station +* **Channel** - One of several standard wireless channels +* **Channel Frequency** - The transmit frequency +* **Channel Width** - Channel bandwidth + +If a predefined channel is selected, the frequency and width attributes will be assigned automatically. If no channel is selected, these attributes may be defined manually. + ### IP Address Assignment IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md new file mode 100644 index 000000000..80a3a40b0 --- /dev/null +++ b/docs/models/wireless/wirelesslan.md @@ -0,0 +1,11 @@ +# Wireless LANs + +A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups. + +An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs. + +Each wireless LAN may have authentication attributes associated with it, including: + +* Authentication type +* Cipher +* Pre-shared key diff --git a/docs/models/wireless/wirelesslangroup.md b/docs/models/wireless/wirelesslangroup.md new file mode 100644 index 000000000..e477abd0b --- /dev/null +++ b/docs/models/wireless/wirelesslangroup.md @@ -0,0 +1,3 @@ +# Wireless LAN Groups + +Wireless LAN groups can be used to organize and classify wireless LANs. These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may assigned only to one group. diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md new file mode 100644 index 000000000..85cdbd6d9 --- /dev/null +++ b/docs/models/wireless/wirelesslink.md @@ -0,0 +1,9 @@ +# Wireless Links + +A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. + +Each wireless link may have authentication attributes associated with it, including: + +* Authentication type +* Cipher +* Pre-shared key diff --git a/mkdocs.yml b/mkdocs.yml index 7244c36d6..ac394d704 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,7 @@ nav: - Virtualization: 'core-functionality/virtualization.md' - Service Mapping: 'core-functionality/services.md' - Circuits: 'core-functionality/circuits.md' + - Wireless: 'core-functionality/wireless.md' - Power Tracking: 'core-functionality/power.md' - Tenancy: 'core-functionality/tenancy.md' - Customization: From 6a4becfb4602675429fc75a1c511c35d7415ae68 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 13:34:39 -0400 Subject: [PATCH 046/289] Add tests for wireless --- netbox/dcim/filtersets.py | 9 +- netbox/dcim/tests/test_filtersets.py | 27 +++- netbox/wireless/api/serializers.py | 6 +- netbox/wireless/filtersets.py | 17 +- netbox/wireless/forms/bulk_import.py | 1 + netbox/wireless/forms/models.py | 2 + netbox/wireless/graphql/schema.py | 12 +- netbox/wireless/graphql/types.py | 18 ++- netbox/wireless/signals.py | 1 - netbox/wireless/tests/__init__.py | 0 netbox/wireless/tests/test_api.py | 141 ++++++++++++++++ netbox/wireless/tests/test_filtersets.py | 194 +++++++++++++++++++++++ netbox/wireless/tests/test_views.py | 120 ++++++++++++++ 13 files changed, 529 insertions(+), 19 deletions(-) create mode 100644 netbox/wireless/tests/__init__.py create mode 100644 netbox/wireless/tests/test_api.py create mode 100644 netbox/wireless/tests/test_filtersets.py create mode 100644 netbox/wireless/tests/test_views.py diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index aa525e8e1..f6d6ed8dc 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -14,6 +14,7 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster +from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from .choices import * from .constants import * from .models import * @@ -987,12 +988,18 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT choices=InterfaceTypeChoices, null_value=None ) + rf_role = django_filters.MultipleChoiceFilter( + choices=WirelessRoleChoices + ) + rf_channel = django_filters.MultipleChoiceFilter( + choices=WirelessChannelChoices + ) class Meta: model = Interface fields = [ 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_width', 'description', + 'rf_channel_frequency', 'rf_channel_width', 'description', ] def filter_device(self, queryset, name, value): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 8d8be324e..62bdaed82 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -9,6 +9,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests from virtualization.models import Cluster, ClusterType +from wireless.choices import WirelessChannelChoices, WirelessRoleChoices class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -2063,6 +2064,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), + Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), + Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20), ) Interface.objects.bulk_create(interfaces) @@ -2083,11 +2086,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_enabled(self): params = {'enabled': 'true'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'enabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2099,7 +2102,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'mgmt_only': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_mode(self): params = {'mode': InterfaceModeChoices.MODE_ACCESS} @@ -2176,7 +2179,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'cabled': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): params = {'kind': 'physical'} @@ -2192,6 +2195,22 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_rf_role(self): + params = {'rf_role': [WirelessRoleChoices.ROLE_AP, WirelessRoleChoices.ROLE_STATION]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel(self): + params = {'rf_channel': [WirelessChannelChoices.CHANNEL_24G_1, WirelessChannelChoices.CHANNEL_5G_32]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel_frequency(self): + params = {'rf_channel_frequency': [2412, 5160]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel_width(self): + params = {'rf_channel_width': [22, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index e9be35618..12986dcaf 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -10,6 +10,7 @@ from wireless.models import * from .nested_serializers import * __all__ = ( + 'WirelessLANGroupSerializer', 'WirelessLANSerializer', 'WirelessLinkSerializer', ) @@ -17,7 +18,7 @@ __all__ = ( class WirelessLANGroupSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') - parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True) + parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True, default=None) wirelesslan_count = serializers.IntegerField(read_only=True) class Meta: @@ -30,6 +31,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') + group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) @@ -37,7 +39,7 @@ class WirelessLANSerializer(PrimaryModelSerializer): class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', + 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', ] diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index cc67c1fc3..cffdcf046 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -3,7 +3,9 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from extras.filters import TagFilter +from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -34,8 +36,19 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): method='search', label='Search', ) - group_id = django_filters.ModelMultipleChoiceFilter( - queryset=WirelessLANGroup.objects.all() + group_id = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='group', + lookup_expr='in' + ) + group = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='group', + lookup_expr='in', + to_field_name='slug' + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + queryset=VLAN.objects.all() ) auth_type = django_filters.MultipleChoiceFilter( choices=WirelessAuthTypeChoices diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index e9e9afed6..aa79e1fc7 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -36,6 +36,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), + required=False, to_field_name='name', help_text='Bridged VLAN' ) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index aa453ba64..26bcd2260 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -62,6 +62,7 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): device_a = DynamicModelChoiceField( queryset=Device.objects.all(), + required=False, label='Device A', initial_params={ 'interfaces': '$interface_a' @@ -78,6 +79,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): ) device_b = DynamicModelChoiceField( queryset=Device.objects.all(), + required=False, label='Device B', initial_params={ 'interfaces': '$interface_b' diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index 05fc57c4d..cd8fd9f52 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -5,11 +5,11 @@ from .types import * class WirelessQuery(graphene.ObjectType): - wirelesslan = ObjectField(WirelessLANType) - wirelesslan_list = ObjectListField(WirelessLANType) + wireless_lan = ObjectField(WirelessLANType) + wireless_lan_list = ObjectListField(WirelessLANType) - wirelesslangroup = ObjectField(WirelessLANGroupType) - wirelesslangroup_list = ObjectListField(WirelessLANGroupType) + wireless_lan_group = ObjectField(WirelessLANGroupType) + wireless_lan_group_list = ObjectListField(WirelessLANGroupType) - wirelesslink = ObjectField(WirelessLinkType) - wirelesslink_list = ObjectListField(WirelessLinkType) + wireless_link = ObjectField(WirelessLinkType) + wireless_link_list = ObjectListField(WirelessLinkType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 4697cc44b..be0b2f7aa 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -1,5 +1,5 @@ from wireless import filtersets, models -from netbox.graphql.types import ObjectType +from netbox.graphql.types import ObjectType, PrimaryObjectType __all__ = ( 'WirelessLANType', @@ -16,17 +16,29 @@ class WirelessLANGroupType(ObjectType): filterset_class = filtersets.WirelessLANGroupFilterSet -class WirelessLANType(ObjectType): +class WirelessLANType(PrimaryObjectType): class Meta: model = models.WirelessLAN fields = '__all__' filterset_class = filtersets.WirelessLANFilterSet + def resolve_auth_type(self, info): + return self.auth_type or None -class WirelessLinkType(ObjectType): + def resolve_auth_cipher(self, info): + return self.auth_cipher or None + + +class WirelessLinkType(PrimaryObjectType): class Meta: model = models.WirelessLink fields = '__all__' filterset_class = filtersets.WirelessLinkFilterSet + + def resolve_auth_type(self, info): + return self.auth_type or None + + def resolve_auth_cipher(self, info): + return self.auth_cipher or None diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index 935e11677..3b4831a8d 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -63,5 +63,4 @@ def nullify_connected_interfaces(instance, **kwargs): # Delete and retrace any dependent cable paths for cablepath in CablePath.objects.filter(path__contains=instance): - print(f'Deleting cable path {cablepath.pk}') cablepath.delete() diff --git a/netbox/wireless/tests/__init__.py b/netbox/wireless/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py new file mode 100644 index 000000000..917b7b320 --- /dev/null +++ b/netbox/wireless/tests/test_api.py @@ -0,0 +1,141 @@ +from django.urls import reverse + +from wireless.choices import * +from wireless.models import * +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface +from utilities.testing import APITestCase, APIViewTestCases, create_test_device + + +class AppTest(APITestCase): + + def test_root(self): + url = reverse('wireless-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): + model = WirelessLANGroup + brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'url', 'wirelesslan_count'] + create_data = [ + { + 'name': 'Wireless LAN Group 4', + 'slug': 'wireless-lan-group-4', + }, + { + 'name': 'Wireless LAN Group 5', + 'slug': 'wireless-lan-group-5', + }, + { + 'name': 'Wireless LAN Group 6', + 'slug': 'wireless-lan-group-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + WirelessLANGroup.objects.create(name='Wireless LAN Group 1', slug='wireless-lan-group-1') + WirelessLANGroup.objects.create(name='Wireless LAN Group 2', slug='wireless-lan-group-2') + WirelessLANGroup.objects.create(name='Wireless LAN Group 3', slug='wireless-lan-group-3') + + +class WirelessLANTest(APIViewTestCases.APIViewTestCase): + model = WirelessLAN + brief_fields = ['display', 'id', 'ssid', 'url'] + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Group 1', slug='group-1'), + WirelessLANGroup(name='Group 2', slug='group-2'), + WirelessLANGroup(name='Group 3', slug='group-3'), + ) + for group in groups: + group.save() + + wireless_lans = ( + WirelessLAN(ssid='WLAN1'), + WirelessLAN(ssid='WLAN2'), + WirelessLAN(ssid='WLAN3'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + + cls.create_data = [ + { + 'ssid': 'WLAN4', + 'group': groups[0].pk, + 'auth_type': WirelessAuthTypeChoices.TYPE_OPEN, + }, + { + 'ssid': 'WLAN5', + 'group': groups[1].pk, + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + }, + { + 'ssid': 'WLAN6', + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, + }, + ] + + cls.bulk_update_data = { + 'group': groups[2].pk, + 'description': 'New description', + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + 'auth_cipher': WirelessAuthCipherChoices.CIPHER_AES, + 'auth_psk': 'abc123def456', + } + + +class WirelessLinkTest(APIViewTestCases.APIViewTestCase): + model = WirelessLink + brief_fields = ['display', 'id', 'ssid', 'url'] + bulk_update_data = { + 'status': 'planned', + } + + @classmethod + def setUpTestData(cls): + device = create_test_device('test-device') + interfaces = [ + Interface( + device=device, + name=f'radio{i}', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ) for i in range(12) + ] + Interface.objects.bulk_create(interfaces) + + wireless_links = ( + WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1]), + WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3]), + WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5]), + ) + WirelessLink.objects.bulk_create(wireless_links) + + cls.create_data = [ + { + 'interface_a': interfaces[6].pk, + 'interface_b': interfaces[7].pk, + 'ssid': 'LINK4', + }, + { + 'interface_a': interfaces[8].pk, + 'interface_b': interfaces[9].pk, + 'ssid': 'LINK5', + }, + { + 'interface_a': interfaces[10].pk, + 'interface_b': interfaces[11].pk, + 'ssid': 'LINK6', + }, + ] diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py new file mode 100644 index 000000000..50f89c4d6 --- /dev/null +++ b/netbox/wireless/tests/test_filtersets.py @@ -0,0 +1,194 @@ +from django.test import TestCase + +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import Interface +from ipam.models import VLAN +from wireless.choices import * +from wireless.filtersets import * +from wireless.models import * +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device + + +class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLANGroup.objects.all() + filterset = WirelessLANGroupFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), + ) + for group in groups: + group.save() + + child_groups = ( + WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0]), + WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0]), + WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]), + WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]), + WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]), + WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=groups[2]), + ) + for group in child_groups: + group.save() + + def test_name(self): + params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['wireless-lan-group-1', 'wireless-lan-group-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + +class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLAN.objects.all() + filterset = WirelessLANFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + ) + for group in groups: + group.save() + + vlans = ( + VLAN(name='VLAN1', vid=1), + VLAN(name='VLAN2', vid=2), + VLAN(name='VLAN3', vid=3), + ) + VLAN.objects.bulk_create(vlans) + + wireless_lans = ( + WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), + WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), + WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + + def test_ssid(self): + params = {'ssid': ['WLAN1', 'WLAN2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + groups = WirelessLANGroup.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].slug, groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlan(self): + vlans = VLAN.objects.all()[:2] + params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_cipher(self): + params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_psk(self): + params = {'auth_psk': ['PSK1', 'PSK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLink.objects.all() + filterset = WirelessLinkFilterSet + + @classmethod + def setUpTestData(cls): + + devices = ( + create_test_device('device1'), + create_test_device('device2'), + create_test_device('device3'), + create_test_device('device4'), + ) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC), + ) + Interface.objects.bulk_create(interfaces) + + # Wireless links + WirelessLink( + interface_a=interfaces[0], + interface_b=interfaces[2], + ssid='LINK1', + status=LinkStatusChoices.STATUS_CONNECTED, + auth_type=WirelessAuthTypeChoices.TYPE_OPEN, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, + auth_psk='PSK1' + ).save() + WirelessLink( + interface_a=interfaces[1], + interface_b=interfaces[3], + ssid='LINK2', + status=LinkStatusChoices.STATUS_PLANNED, + auth_type=WirelessAuthTypeChoices.TYPE_WEP, + auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, + auth_psk='PSK2' + ).save() + WirelessLink( + interface_a=interfaces[4], + interface_b=interfaces[6], + ssid='LINK3', + status=LinkStatusChoices.STATUS_DECOMMISSIONING, + auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, + auth_psk='PSK3' + ).save() + WirelessLink( + interface_a=interfaces[5], + interface_b=interfaces[7], + ssid='LINK4' + ).save() + + def test_ssid(self): + params = {'ssid': ['LINK1', 'LINK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_status(self): + params = {'status': [LinkStatusChoices.STATUS_PLANNED, LinkStatusChoices.STATUS_DECOMMISSIONING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_cipher(self): + params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_psk(self): + params = {'auth_psk': ['PSK1', 'PSK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py new file mode 100644 index 000000000..d4422e7e3 --- /dev/null +++ b/netbox/wireless/tests/test_views.py @@ -0,0 +1,120 @@ +from wireless.choices import * +from wireless.models import * +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import Interface +from utilities.testing import ViewTestCases, create_tags, create_test_device + + +class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = WirelessLANGroup + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + ) + for group in groups: + group.save() + + cls.form_data = { + 'name': 'Wireless LAN Group X', + 'slug': 'wireless-lan-group-x', + 'parent': groups[2].pk, + 'description': 'A new wireless LAN group', + } + + cls.csv_data = ( + "name,slug,description", + "Wireles sLAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", + "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", + "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = WirelessLAN + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + ) + for group in groups: + group.save() + + WirelessLAN.objects.bulk_create([ + WirelessLAN(group=groups[0], ssid='WLAN1'), + WirelessLAN(group=groups[0], ssid='WLAN2'), + WirelessLAN(group=groups[0], ssid='WLAN3'), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'ssid': 'WLAN2', + 'group': groups[1].pk, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "group,ssid", + "Wireless LAN Group 2,WLAN4", + "Wireless LAN Group 2,WLAN5", + "Wireless LAN Group 2,WLAN6", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = WirelessLink + + @classmethod + def setUpTestData(cls): + device = create_test_device('test-device') + interfaces = [ + Interface( + device=device, + name=f'radio{i}', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ) for i in range(12) + ] + Interface.objects.bulk_create(interfaces) + + WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1').save() + WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2').save() + WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3').save() + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'interface_a': interfaces[6].pk, + 'interface_b': interfaces[7].pk, + 'status': LinkStatusChoices.STATUS_PLANNED, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "interface_a,interface_b,status", + f"{interfaces[6].pk},{interfaces[7].pk},connected", + f"{interfaces[8].pk},{interfaces[9].pk},connected", + f"{interfaces[10].pk},{interfaces[11].pk},connected", + ) + + cls.bulk_edit_data = { + 'status': LinkStatusChoices.STATUS_PLANNED, + } From 7b70129974213a03aa53e8a9219007677a99d9b3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 14:22:11 -0400 Subject: [PATCH 047/289] Refactor device component views --- netbox/dcim/views.py | 224 ++++++++++--------------------------------- 1 file changed, 53 insertions(+), 171 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 16f88b9c3..9b48e0bd3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -36,6 +36,29 @@ from .models import ( ) +class DeviceComponentsView(generic.ObjectView): + queryset = Device.objects.all() + model = None + table = None + + def get_components(self, request, instance): + return self.model.objects.restrict(request.user, 'view').filter(device=instance) + + def get_extra_context(self, request, instance): + components = self.get_components(request, instance) + table = self.table(data=components, user=request.user) + change_perm = f'{self.model._meta.app_label}.change_{self.model._meta.model_name}' + delete_perm = f'{self.model._meta.app_label}.delete_{self.model._meta.model_name}' + if request.user.has_perm(change_perm) or request.user.has_perm(delete_perm): + table.columns.show('pk') + paginate_table(table, request) + + return { + f'{self.model._meta.model_name}_table': table, + 'active_tab': f"{self.model._meta.verbose_name_plural.replace(' ', '-')}", + } + + class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. @@ -1306,206 +1329,65 @@ class DeviceView(generic.ObjectView): } -class DeviceConsolePortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceConsolePortsView(DeviceComponentsView): + model = ConsolePort + table = tables.DeviceConsolePortTable template_name = 'dcim/device/consoleports.html' - def get_extra_context(self, request, instance): - consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'cable', '_path__destination', - ) - consoleport_table = tables.DeviceConsolePortTable( - data=consoleports, - user=request.user - ) - if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'): - consoleport_table.columns.show('pk') - paginate_table(consoleport_table, request) - return { - 'consoleport_table': consoleport_table, - 'active_tab': 'console-ports', - } - - -class DeviceConsoleServerPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceConsoleServerPortsView(DeviceComponentsView): + model = ConsoleServerPort + table = tables.DeviceConsoleServerPortTable template_name = 'dcim/device/consoleserverports.html' - def get_extra_context(self, request, instance): - consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( - device=instance - ).prefetch_related( - 'cable', '_path__destination', - ) - consoleserverport_table = tables.DeviceConsoleServerPortTable( - data=consoleserverports, - user=request.user - ) - if request.user.has_perm('dcim.change_consoleserverport') or \ - request.user.has_perm('dcim.delete_consoleserverport'): - consoleserverport_table.columns.show('pk') - paginate_table(consoleserverport_table, request) - return { - 'consoleserverport_table': consoleserverport_table, - 'active_tab': 'console-server-ports', - } - - -class DevicePowerPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DevicePowerPortsView(DeviceComponentsView): + model = PowerPort + table = tables.DevicePowerPortTable template_name = 'dcim/device/powerports.html' - def get_extra_context(self, request, instance): - powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'cable', '_path__destination', - ) - powerport_table = tables.DevicePowerPortTable( - data=powerports, - user=request.user - ) - if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'): - powerport_table.columns.show('pk') - paginate_table(powerport_table, request) - return { - 'powerport_table': powerport_table, - 'active_tab': 'power-ports', - } - - -class DevicePowerOutletsView(generic.ObjectView): - queryset = Device.objects.all() +class DevicePowerOutletsView(DeviceComponentsView): + model = PowerOutlet + table = tables.DevicePowerOutletTable template_name = 'dcim/device/poweroutlets.html' - def get_extra_context(self, request, instance): - poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'cable', 'power_port', '_path__destination', - ) - poweroutlet_table = tables.DevicePowerOutletTable( - data=poweroutlets, - user=request.user - ) - if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'): - poweroutlet_table.columns.show('pk') - paginate_table(poweroutlet_table, request) - return { - 'poweroutlet_table': poweroutlet_table, - 'active_tab': 'power-outlets', - } - - -class DeviceInterfacesView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceInterfacesView(DeviceComponentsView): + model = Interface + table = tables.DeviceInterfaceTable template_name = 'dcim/device/interfaces.html' - def get_extra_context(self, request, instance): - interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( + def get_components(self, request, instance): + return instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), - Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), - 'lag', 'cable', '_path__destination', 'tags', + Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)) ) - interface_table = tables.DeviceInterfaceTable( - data=interfaces, - user=request.user - ) - if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'): - interface_table.columns.show('pk') - paginate_table(interface_table, request) - - return { - 'interface_table': interface_table, - 'active_tab': 'interfaces', - } -class DeviceFrontPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceFrontPortsView(DeviceComponentsView): + model = FrontPort + table = tables.DeviceFrontPortTable template_name = 'dcim/device/frontports.html' - def get_extra_context(self, request, instance): - frontports = FrontPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'rear_port', 'cable', - ) - frontport_table = tables.DeviceFrontPortTable( - data=frontports, - user=request.user - ) - if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'): - frontport_table.columns.show('pk') - paginate_table(frontport_table, request) - return { - 'frontport_table': frontport_table, - 'active_tab': 'front-ports', - } - - -class DeviceRearPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceRearPortsView(DeviceComponentsView): + model = RearPort + table = tables.DeviceRearPortTable template_name = 'dcim/device/rearports.html' - def get_extra_context(self, request, instance): - rearports = RearPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related('cable') - rearport_table = tables.DeviceRearPortTable( - data=rearports, - user=request.user - ) - if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'): - rearport_table.columns.show('pk') - paginate_table(rearport_table, request) - return { - 'rearport_table': rearport_table, - 'active_tab': 'rear-ports', - } - - -class DeviceDeviceBaysView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceDeviceBaysView(DeviceComponentsView): + model = DeviceBay + table = tables.DeviceDeviceBayTable template_name = 'dcim/device/devicebays.html' - def get_extra_context(self, request, instance): - devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'installed_device__device_type__manufacturer', - ) - devicebay_table = tables.DeviceDeviceBayTable( - data=devicebays, - user=request.user - ) - if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'): - devicebay_table.columns.show('pk') - paginate_table(devicebay_table, request) - return { - 'devicebay_table': devicebay_table, - 'active_tab': 'device-bays', - } - - -class DeviceInventoryView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceInventoryView(DeviceComponentsView): + model = InventoryItem + table = tables.DeviceInventoryItemTable template_name = 'dcim/device/inventory.html' - def get_extra_context(self, request, instance): - inventoryitems = InventoryItem.objects.restrict(request.user, 'view').filter( - device=instance - ).prefetch_related('manufacturer') - inventoryitem_table = tables.DeviceInventoryItemTable( - data=inventoryitems, - user=request.user - ) - if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'): - inventoryitem_table.columns.show('pk') - paginate_table(inventoryitem_table, request) - - return { - 'inventoryitem_table': inventoryitem_table, - 'active_tab': 'inventory', - } - class DeviceStatusView(generic.ObjectView): additional_permissions = ['dcim.napalm_read_device'] From 8c058dcd45004362ef69b2f3b0393ed408d4dbf2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 15:04:40 -0400 Subject: [PATCH 048/289] Closes #7530: Move device type component lists to separate views --- docs/release-notes/version-3.1.md | 1 + netbox/dcim/tests/test_views.py | 110 ++++++++++++++++ netbox/dcim/urls.py | 8 ++ netbox/dcim/views.py | 102 ++++++++------- .../templates/dcim/device/consoleports.html | 6 +- .../dcim/device/consoleserverports.html | 6 +- netbox/templates/dcim/device/devicebays.html | 6 +- netbox/templates/dcim/device/frontports.html | 6 +- netbox/templates/dcim/device/interfaces.html | 6 +- netbox/templates/dcim/device/inventory.html | 6 +- .../templates/dcim/device/poweroutlets.html | 6 +- netbox/templates/dcim/device/powerports.html | 6 +- netbox/templates/dcim/device/rearports.html | 6 +- netbox/templates/dcim/devicetype.html | 117 +---------------- netbox/templates/dcim/devicetype/base.html | 119 ++++++++++++++++++ .../component_templates.html} | 11 +- .../dcim/inc/device_component_table.html | 42 ------- 17 files changed, 323 insertions(+), 241 deletions(-) create mode 100644 netbox/templates/dcim/devicetype/base.html rename netbox/templates/dcim/{inc/devicetype_component_table.html => devicetype/component_templates.html} (93%) delete mode 100644 netbox/templates/dcim/inc/device_component_table.html diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index abf9c7d25..291831500 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -24,6 +24,7 @@ When assigning a contact to an object, the user must select a predefined role (e * [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations * [#7354](https://github.com/netbox-community/netbox/issues/7354) - Relax uniqueness constraints on region, site group, and location names +* [#7530](https://github.com/netbox-community/netbox/issues/7530) - Move device type component lists to separate views ### Other Changes diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 545a56f81..a9c191679 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -435,6 +435,116 @@ class DeviceTypeTestCase( 'is_full_depth': False, } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_consoleports(self): + devicetype = DeviceType.objects.first() + console_ports = ( + ConsolePortTemplate(device_type=devicetype, name='Console Port 1'), + ConsolePortTemplate(device_type=devicetype, name='Console Port 2'), + ConsolePortTemplate(device_type=devicetype, name='Console Port 3'), + ) + ConsolePortTemplate.objects.bulk_create(console_ports) + + url = reverse('dcim:devicetype_consoleports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_consoleserverports(self): + devicetype = DeviceType.objects.first() + console_server_ports = ( + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 1'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 2'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 3'), + ) + ConsoleServerPortTemplate.objects.bulk_create(console_server_ports) + + url = reverse('dcim:devicetype_consoleserverports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_powerports(self): + devicetype = DeviceType.objects.first() + power_ports = ( + PowerPortTemplate(device_type=devicetype, name='Power Port 1'), + PowerPortTemplate(device_type=devicetype, name='Power Port 2'), + PowerPortTemplate(device_type=devicetype, name='Power Port 3'), + ) + PowerPortTemplate.objects.bulk_create(power_ports) + + url = reverse('dcim:devicetype_powerports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_poweroutlets(self): + devicetype = DeviceType.objects.first() + power_outlets = ( + PowerOutletTemplate(device_type=devicetype, name='Power Outlet 1'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet 2'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet 3'), + ) + PowerOutletTemplate.objects.bulk_create(power_outlets) + + url = reverse('dcim:devicetype_poweroutlets', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_interfaces(self): + devicetype = DeviceType.objects.first() + interfaces = ( + InterfaceTemplate(device_type=devicetype, name='Interface 1'), + InterfaceTemplate(device_type=devicetype, name='Interface 2'), + InterfaceTemplate(device_type=devicetype, name='Interface 3'), + ) + InterfaceTemplate.objects.bulk_create(interfaces) + + url = reverse('dcim:devicetype_interfaces', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_rearports(self): + devicetype = DeviceType.objects.first() + rear_ports = ( + RearPortTemplate(device_type=devicetype, name='Rear Port 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port 3'), + ) + RearPortTemplate.objects.bulk_create(rear_ports) + + url = reverse('dcim:devicetype_rearports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_frontports(self): + devicetype = DeviceType.objects.first() + rear_ports = ( + RearPortTemplate(device_type=devicetype, name='Rear Port 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port 3'), + ) + RearPortTemplate.objects.bulk_create(rear_ports) + front_ports = ( + FrontPortTemplate(device_type=devicetype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1), + ) + FrontPortTemplate.objects.bulk_create(front_ports) + + url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_devicebays(self): + devicetype = DeviceType.objects.first() + device_bays = ( + DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 2'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 3'), + ) + DeviceBayTemplate.objects.bulk_create(device_bays) + + url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_import_objects(self): """ diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 01e470e5c..dd81ca2ba 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -109,6 +109,14 @@ urlpatterns = [ path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'), + path('device-types//console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'), + path('device-types//console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'), + path('device-types//power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'), + path('device-types//power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'), + path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'), + path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'), + path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), + path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9b48e0bd3..5079e01a5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -54,11 +54,19 @@ class DeviceComponentsView(generic.ObjectView): paginate_table(table, request) return { - f'{self.model._meta.model_name}_table': table, + 'table': table, 'active_tab': f"{self.model._meta.verbose_name_plural.replace(' ', '-')}", } +class DeviceTypeComponentsView(DeviceComponentsView): + queryset = DeviceType.objects.all() + template_name = 'dcim/devicetype/component_templates.html' + + def get_components(self, request, instance): + return self.model.objects.restrict(request.user, 'view').filter(device_type=instance) + + class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. @@ -782,62 +790,52 @@ class DeviceTypeView(generic.ObjectView): def get_extra_context(self, request, instance): instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count() - # Component tables - consoleport_table = tables.ConsolePortTemplateTable( - ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - consoleserverport_table = tables.ConsoleServerPortTemplateTable( - ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - powerport_table = tables.PowerPortTemplateTable( - PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - poweroutlet_table = tables.PowerOutletTemplateTable( - PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=instance)), - orderable=False - ) - front_port_table = tables.FrontPortTemplateTable( - FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - rear_port_table = tables.RearPortTemplateTable( - RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - devicebay_table = tables.DeviceBayTemplateTable( - DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=instance), - orderable=False - ) - if request.user.has_perm('dcim.change_devicetype'): - consoleport_table.columns.show('pk') - consoleserverport_table.columns.show('pk') - powerport_table.columns.show('pk') - poweroutlet_table.columns.show('pk') - interface_table.columns.show('pk') - front_port_table.columns.show('pk') - rear_port_table.columns.show('pk') - devicebay_table.columns.show('pk') - return { 'instance_count': instance_count, - 'consoleport_table': consoleport_table, - 'consoleserverport_table': consoleserverport_table, - 'powerport_table': powerport_table, - 'poweroutlet_table': poweroutlet_table, - 'interface_table': interface_table, - 'front_port_table': front_port_table, - 'rear_port_table': rear_port_table, - 'devicebay_table': devicebay_table, + 'active_tab': 'devicetype', } +class DeviceTypeConsolePortsView(DeviceTypeComponentsView): + model = ConsolePortTemplate + table = tables.ConsolePortTemplateTable + + +class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView): + model = ConsoleServerPortTemplate + table = tables.ConsoleServerPortTemplateTable + + +class DeviceTypePowerPortsView(DeviceTypeComponentsView): + model = PowerPortTemplate + table = tables.PowerPortTemplateTable + + +class DeviceTypePowerOutletsView(DeviceTypeComponentsView): + model = PowerOutletTemplate + table = tables.PowerOutletTemplateTable + + +class DeviceTypeInterfacesView(DeviceTypeComponentsView): + model = InterfaceTemplate + table = tables.InterfaceTemplateTable + + +class DeviceTypeFrontPortsView(DeviceTypeComponentsView): + model = FrontPortTemplate + table = tables.FrontPortTemplateTable + + +class DeviceTypeRearPortsView(DeviceTypeComponentsView): + model = RearPortTemplate + table = tables.RearPortTemplateTable + + +class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): + model = DeviceBayTemplate + table = tables.DeviceBayTemplateTable + + class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index 4a7bab4d4..6cf736523 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceConsolePortTable_config" %} - {% render_table consoleport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_consoleport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=consoleport_table.paginator page=consoleport_table.page %} - {% table_config_form consoleport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 4e97039f3..ca159029e 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %} - {% render_table consoleserverport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_consoleserverport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=consoleserverport_table.paginator page=consoleserverport_table.page %} - {% table_config_form consoleserverport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 31ea9b249..b72625005 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %} - {% render_table devicebay_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_devicebay %} @@ -33,6 +33,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=devicebay_table.paginator page=devicebay_table.page %} - {% table_config_form devicebay_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 4d15dde1b..5833a1c78 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %} - {% render_table frontport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_frontport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=frontport_table.paginator page=frontport_table.page %} - {% table_config_form frontport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 03c8a8913..1d1e7e81b 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -34,7 +34,7 @@
- {% render_table interface_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_interface %} @@ -63,6 +63,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=interface_table.paginator page=interface_table.page %} - {% table_config_form interface_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 6c9fdb17b..2aad68984 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %} - {% render_table inventoryitem_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_inventoryitem %} @@ -33,6 +33,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=inventoryitem_table.paginator page=inventoryitem_table.page %} - {% table_config_form inventoryitem_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index f9937bf27..df936742e 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %} - {% render_table poweroutlet_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_powerport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=poweroutlet_table.paginator page=poweroutlet_table.page %} - {% table_config_form poweroutlet_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index 7d219979c..5a502dc57 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DevicePowerPortTable_config" %} - {% render_table powerport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_powerport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=powerport_table.paginator page=powerport_table.page %} - {% table_config_form powerport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index f0ec37b80..d0ff55ec9 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -7,7 +7,7 @@
{% csrf_token %} {% include 'inc/table_controls.html' with table_modal="DeviceRearPortTable_config" %} - {% render_table rearport_table 'inc/table.html' %} + {% render_table table 'inc/table.html' %}
{% if perms.dcim.change_rearport %} @@ -36,6 +36,6 @@ {% endif %}
- {% include 'inc/paginator.html' with paginator=rearport_table.paginator page=rearport_table.page %} - {% table_config_form rearport_table %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% table_config_form table %} {% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 77db7ed18..74a3e73d7 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,51 +1,8 @@ -{% extends 'generic/object.html' %} +{% extends 'dcim/devicetype/base.html' %} {% load buttons %} {% load helpers %} {% load plugins %} -{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} - -{% block extra_controls %} - {% if perms.dcim.change_devicetype %} - - {% endif %} -{% endblock %} - {% block content %}
@@ -141,76 +98,4 @@ {% plugin_full_width_page object %}
-
-
- -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' tab='interfaces' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=front_port_table title='Front Ports' tab='frontports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=rear_port_table title='Rear Ports' tab='rearports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' tab='consoleports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' tab='consoleserverports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' tab='powerports' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' tab='poweroutlets' %} -
-
- {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' tab='devicebays' %} -
-
-
-
{% endblock %} diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html new file mode 100644 index 000000000..a06886de5 --- /dev/null +++ b/netbox/templates/dcim/devicetype/base.html @@ -0,0 +1,119 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.change_devicetype %} + + {% endif %} +{% endblock %} + +{% block tab_items %} + + + {% with interface_count=object.interfacetemplates.count %} + {% if interface_count %} + + {% endif %} + {% endwith %} + + {% with frontport_count=object.frontporttemplates.count %} + {% if frontport_count %} + + {% endif %} + {% endwith %} + + {% with rearport_count=object.rearporttemplates.count %} + {% if rearport_count %} + + {% endif %} + {% endwith %} + + {% with consoleport_count=object.consoleporttemplates.count %} + {% if consoleport_count %} + + {% endif %} + {% endwith %} + + {% with consoleserverport_count=object.consoleserverporttemplates.count %} + {% if consoleserverport_count %} + + {% endif %} + {% endwith %} + + {% with powerport_count=object.powerporttemplates.count %} + {% if powerport_count %} + + {% endif %} + {% endwith %} + + {% with poweroutlet_count=object.poweroutlettemplates.count %} + {% if poweroutlet_count %} + + {% endif %} + {% endwith %} + + {% with devicebay_count=object.devicebaytemplates.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} +{% endblock %} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/devicetype/component_templates.html similarity index 93% rename from netbox/templates/dcim/inc/devicetype_component_table.html rename to netbox/templates/dcim/devicetype/component_templates.html index 900e0f818..d83a232cd 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/devicetype/component_templates.html @@ -1,7 +1,9 @@ -{% load helpers %} +{% extends 'dcim/devicetype/base.html' %} {% load render_table from django_tables2 %} +{% load helpers %} -{% if perms.dcim.change_devicetype %} +{% block content %} + {% if perms.dcim.change_devicetype %}
{% csrf_token %}
@@ -33,7 +35,7 @@
-{% else %} + {% else %}
{{ title }} @@ -42,4 +44,5 @@ {% render_table table 'inc/table.html' %}
-{% endif %} + {% endif %} +{% endblock content %} diff --git a/netbox/templates/dcim/inc/device_component_table.html b/netbox/templates/dcim/inc/device_component_table.html deleted file mode 100644 index b272e2731..000000000 --- a/netbox/templates/dcim/inc/device_component_table.html +++ /dev/null @@ -1,42 +0,0 @@ -{% load helpers %} -{% load perms %} -
- {% csrf_token %} -
-
- {{ title }} -
-
- - {% for obj in components %} - {% include component_template %} - {% endfor %} -
-
- {% if components and perms.dcim.change_consoleport %} - - {% endif %} -
-
From cfb3897047ff6fc38586466f56468eb9bb3ccba4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 10:51:02 -0400 Subject: [PATCH 049/289] Add tags to organizational & nested group models --- docs/development/models.md | 4 +- docs/models/extras/tag.md | 3 -- netbox/circuits/api/serializers.py | 8 ++- netbox/circuits/api/views.py | 2 +- netbox/circuits/forms/bulk_edit.py | 2 +- netbox/circuits/forms/models.py | 6 ++- .../migrations/0003_extend_tag_support.py | 20 ++++++++ netbox/circuits/models.py | 2 +- netbox/circuits/tables.py | 5 +- netbox/circuits/tests/test_views.py | 3 ++ netbox/dcim/api/serializers.py | 33 ++++++------ netbox/dcim/api/views.py | 14 +++--- netbox/dcim/forms/bulk_edit.py | 14 +++--- netbox/dcim/forms/models.py | 44 +++++++++++++--- .../migrations/0138_extend_tag_support.py | 50 +++++++++++++++++++ netbox/dcim/models/devices.py | 6 +-- netbox/dcim/models/racks.py | 2 +- netbox/dcim/models/sites.py | 6 +-- netbox/dcim/tables/devices.py | 12 ++++- netbox/dcim/tables/devicetypes.py | 6 ++- netbox/dcim/tables/racks.py | 5 +- netbox/dcim/tables/sites.py | 17 +++++-- netbox/dcim/tests/test_views.py | 21 ++++++++ netbox/ipam/api/serializers.py | 17 +++---- netbox/ipam/api/views.py | 6 +-- netbox/ipam/forms/bulk_edit.py | 6 +-- netbox/ipam/forms/models.py | 20 ++++++-- .../migrations/0051_extend_tag_support.py | 30 +++++++++++ netbox/ipam/models/ip.py | 4 +- netbox/ipam/models/vlans.py | 2 +- netbox/ipam/tables/ip.py | 10 +++- netbox/ipam/tables/vlans.py | 5 +- netbox/ipam/tests/test_views.py | 9 ++++ netbox/netbox/api/serializers.py | 11 +--- netbox/netbox/graphql/types.py | 1 + netbox/netbox/models.py | 21 +++++--- netbox/tenancy/api/serializers.py | 14 +++--- netbox/tenancy/api/views.py | 14 ++---- netbox/tenancy/forms/bulk_edit.py | 6 +-- netbox/tenancy/forms/models.py | 18 +++++-- .../migrations/0004_extend_tag_support.py | 30 +++++++++++ netbox/tenancy/models.py | 6 +-- netbox/tenancy/tables.py | 10 +++- netbox/tenancy/tests/test_views.py | 9 ++++ netbox/virtualization/api/serializers.py | 10 ++-- netbox/virtualization/api/views.py | 4 +- netbox/virtualization/forms/bulk_edit.py | 4 +- netbox/virtualization/forms/models.py | 20 +++++--- .../migrations/0025_extend_tag_support.py | 25 ++++++++++ netbox/virtualization/models.py | 4 +- netbox/virtualization/tables.py | 10 +++- netbox/virtualization/tests/test_views.py | 6 +++ 52 files changed, 463 insertions(+), 154 deletions(-) create mode 100644 netbox/circuits/migrations/0003_extend_tag_support.py create mode 100644 netbox/dcim/migrations/0138_extend_tag_support.py create mode 100644 netbox/ipam/migrations/0051_extend_tag_support.py create mode 100644 netbox/tenancy/migrations/0004_extend_tag_support.py create mode 100644 netbox/virtualization/migrations/0025_extend_tag_support.py diff --git a/docs/development/models.md b/docs/development/models.md index 93a10fff6..59e795cf7 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -19,8 +19,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ | Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | | Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | -| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | | -| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: | +| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | +| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: | | Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | Component Template | :material-check: | :material-check: | :material-check: | | | | | diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 29cc8b757..fe6a1ef36 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav ```no-highlight GET /api/dcim/devices/?tag=monitored&tag=deprecated ``` - -!!! note - Tags have changed substantially in NetBox v2.9. They are no longer created on-demand when editing an object, and their representation in the REST API now includes a complete depiction of the tag rather than only its label. diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ac6285610..0033e1425 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -5,9 +5,7 @@ from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer from netbox.api import ChoiceField -from netbox.api.serializers import ( - OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer -) +from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer): # Circuits # -class CircuitTypeSerializer(OrganizationalModelSerializer): +class CircuitTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 3bceb2de0..2b3e3b122 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet): # class CircuitTypeViewSet(CustomFieldModelViewSet): - queryset = CircuitType.objects.annotate( + queryset = CircuitType.objects.prefetch_related('tags').annotate( circuit_count=count_related(Circuit, 'type') ) serializer_class = serializers.CircuitTypeSerializer diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 638426a5e..7bf5644b9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -79,7 +79,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField ] -class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CircuitType.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 659939293..5679dbc94 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = CircuitType fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] diff --git a/netbox/circuits/migrations/0003_extend_tag_support.py b/netbox/circuits/migrations/0003_extend_tag_support.py new file mode 100644 index 000000000..e5e6ee262 --- /dev/null +++ b/netbox/circuits/migrations/0003_extend_tag_support.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('circuits', '0002_squashed_0029'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3d213b48d..e6e03052d 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -128,7 +128,7 @@ class ProviderNetwork(PrimaryModel): return reverse('circuits:providernetwork', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 2e31237b6..d0b0797e2 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -82,6 +82,9 @@ class CircuitTypeTable(BaseTable): name = tables.Column( linkify=True ) + tags = TagColumn( + url_name='circuits:circuittype_list' + ) circuit_count = tables.Column( verbose_name='Circuits' ) @@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable): class Meta(BaseTable.Meta): model = CircuitType - fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index ccb4a869a..851d52ae8 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -64,10 +64,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): CircuitType(name='Circuit Type 3', slug='circuit-type-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Circuit Type X', 'slug': 'circuit-type-x', 'description': 'A new circuit type', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9b0e7f5b3..ef4f49247 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -11,8 +11,7 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer from ipam.models import VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( - NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, - WritableNestedSerializer, + NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer @@ -87,8 +86,8 @@ class RegionSerializer(NestedGroupModelSerializer): class Meta: model = Region fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'site_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', ] @@ -100,8 +99,8 @@ class SiteGroupSerializer(NestedGroupModelSerializer): class Meta: model = SiteGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'site_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', ] @@ -144,20 +143,20 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'custom_fields', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] -class RackRoleSerializer(OrganizationalModelSerializer): +class RackRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', - 'rack_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'rack_count', ] @@ -254,7 +253,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # Device types # -class ManufacturerSerializer(OrganizationalModelSerializer): +class ManufacturerSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -263,7 +262,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer): class Meta: model = Manufacturer fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count', ] @@ -411,7 +410,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # Devices # -class DeviceRoleSerializer(OrganizationalModelSerializer): +class DeviceRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -419,12 +418,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', - 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] -class PlatformSerializer(OrganizationalModelSerializer): +class PlatformSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -434,7 +433,7 @@ class PlatformSerializer(OrganizationalModelSerializer): model = Platform fields = [ 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', - 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2b9d9734c..799a5e703 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -110,7 +110,7 @@ class RegionViewSet(CustomFieldModelViewSet): 'region', 'site_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.RegionSerializer filterset_class = filtersets.RegionFilterSet @@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet): 'group', 'site_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.SiteGroupSerializer filterset_class = filtersets.SiteGroupFilterSet @@ -167,7 +167,7 @@ class LocationViewSet(CustomFieldModelViewSet): 'location', 'rack_count', cumulative=True - ).prefetch_related('site') + ).prefetch_related('site', 'tags') serializer_class = serializers.LocationSerializer filterset_class = filtersets.LocationFilterSet @@ -177,7 +177,7 @@ class LocationViewSet(CustomFieldModelViewSet): # class RackRoleViewSet(CustomFieldModelViewSet): - queryset = RackRole.objects.annotate( + queryset = RackRole.objects.prefetch_related('tags').annotate( rack_count=count_related(Rack, 'role') ) serializer_class = serializers.RackRoleSerializer @@ -261,7 +261,7 @@ class RackReservationViewSet(ModelViewSet): # class ManufacturerViewSet(CustomFieldModelViewSet): - queryset = Manufacturer.objects.annotate( + queryset = Manufacturer.objects.prefetch_related('tags').annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), platform_count=count_related(Platform, 'manufacturer') @@ -340,7 +340,7 @@ class DeviceBayTemplateViewSet(ModelViewSet): # class DeviceRoleViewSet(CustomFieldModelViewSet): - queryset = DeviceRole.objects.annotate( + queryset = DeviceRole.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') ) @@ -353,7 +353,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet): # class PlatformViewSet(CustomFieldModelViewSet): - queryset = Platform.objects.annotate( + queryset = Platform.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 06ccc958c..d08692c26 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -51,7 +51,7 @@ __all__ = ( ) -class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Region.objects.all(), widget=forms.MultipleHiddenInput @@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=SiteGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -132,7 +132,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd ] -class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Location.objects.all(), widget=forms.MultipleHiddenInput @@ -161,7 +161,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'tenant', 'description'] -class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackRole.objects.all(), widget=forms.MultipleHiddenInput @@ -303,7 +303,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField nullable_fields = [] -class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Manufacturer.objects.all(), widget=forms.MultipleHiddenInput @@ -345,7 +345,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel nullable_fields = ['airflow'] -class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceRole.objects.all(), widget=forms.MultipleHiddenInput @@ -367,7 +367,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['color', 'description'] -class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Platform.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 8236b1a97..a3dac09dd 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -70,11 +70,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Region fields = ( - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ) @@ -84,11 +88,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = SiteGroup fields = ( - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ) @@ -187,15 +195,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', ) fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -203,11 +215,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RackRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = RackRole fields = [ - 'name', 'slug', 'color', 'description', + 'name', 'slug', 'color', 'description', 'tags', ] @@ -343,11 +359,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Manufacturer fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] @@ -392,11 +412,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = DeviceRole fields = [ - 'name', 'slug', 'color', 'vm_role', 'description', + 'name', 'slug', 'color', 'vm_role', 'description', 'tags', ] @@ -408,11 +432,15 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField( max_length=64 ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Platform fields = [ - 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', ] widgets = { 'napalm_args': SmallTextarea(), diff --git a/netbox/dcim/migrations/0138_extend_tag_support.py b/netbox/dcim/migrations/0138_extend_tag_support.py new file mode 100644 index 000000000..763b53c50 --- /dev/null +++ b/netbox/dcim/migrations/0138_extend_tag_support.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('dcim', '0137_relax_uniqueness_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='location', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='manufacturer', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='platform', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='rackrole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='region', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='sitegroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 308a094c3..2b3b80d24 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -36,7 +36,7 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -351,7 +351,7 @@ class DeviceType(PrimaryModel): # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -391,7 +391,7 @@ class DeviceRole(OrganizationalModel): return reverse('dcim:devicerole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 47fcd42e4..a6be069b6 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -35,7 +35,7 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index ab9d8e82d..a978e69e6 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -25,7 +25,7 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ A region represents a geographic collection of sites. For example, you might create regions representing countries, @@ -82,7 +82,7 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and @@ -278,7 +278,7 @@ class Site(PrimaryModel): # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Location(NestedGroupModel): """ A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a2d3f3da2..f47073848 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -84,11 +84,16 @@ class DeviceRoleTable(BaseTable): ) color = ColorColumn() vm_role = BooleanColumn() + tags = TagColumn( + url_name='dcim:devicerole_list' + ) actions = ButtonsColumn(DeviceRole) class Meta(BaseTable.Meta): model = DeviceRole - fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') + fields = ( + 'pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', + ) default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') @@ -111,13 +116,16 @@ class PlatformTable(BaseTable): url_params={'platform_id': 'pk'}, verbose_name='VMs' ) + tags = TagColumn( + url_name='dcim:platform_list' + ) actions = ButtonsColumn(Platform) class Meta(BaseTable.Meta): model = Platform fields = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'actions', + 'description', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index b3310d5d2..9631b5709 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -41,12 +41,16 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() + tags = TagColumn( + url_name='dcim:manufacturer_list' + ) actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer fields = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags', + 'actions', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index fcc3ed4d2..bdc5ae713 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -24,11 +24,14 @@ class RackRoleTable(BaseTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') color = ColorColumn() + tags = TagColumn( + url_name='dcim:rackrole_list' + ) actions = ButtonsColumn(RackRole) class Meta(BaseTable.Meta): model = RackRole - fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 3ff6ab75b..65419e9c8 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -29,11 +29,14 @@ class RegionTable(BaseTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:region_list' + ) actions = ButtonsColumn(Region) class Meta(BaseTable.Meta): model = Region - fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions') @@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:sitegroup_list' + ) actions = ButtonsColumn(SiteGroup) class Meta(BaseTable.Meta): model = SiteGroup - fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions') @@ -114,6 +120,9 @@ class LocationTable(BaseTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) + tags = TagColumn( + url_name='dcim:location_list' + ) actions = ButtonsColumn( model=Location, prepend_template=LOCATION_ELEVATIONS @@ -121,5 +130,7 @@ class LocationTable(BaseTable): class Meta(BaseTable.Meta): model = Location - fields = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'actions') + fields = ( + 'pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', 'actions', + ) default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a9c191679..4565c898b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -31,11 +31,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for region in regions: region.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Region X', 'slug': 'region-x', 'parent': regions[2].pk, 'description': 'A new region', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -65,11 +68,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for sitegroup in sitegroups: sitegroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Site Group X', 'slug': 'site-group-x', 'parent': sitegroups[2].pk, 'description': 'A new site group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -169,12 +175,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for location in locations: location.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, 'tenant': tenant.pk, 'description': 'A new location', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -201,11 +210,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RackRole(name='Rack Role 3', slug='rack-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Rack Role X', 'slug': 'rack-role-x', 'color': 'c0c0c0', 'description': 'New role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -368,10 +380,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Manufacturer X', 'slug': 'manufacturer-x', 'description': 'A new manufacturer', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -1034,12 +1049,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): DeviceRole(name='Device Role 3', slug='device-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Devie Role X', 'slug': 'device-role-x', 'color': 'c0c0c0', 'vm_role': False, 'description': 'New device role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -1069,6 +1087,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Platform X', 'slug': 'platform-x', @@ -1076,6 +1096,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'napalm_driver': 'junos', 'napalm_args': None, 'description': 'A new platform', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 183c45b2a..2b221fdab 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -9,7 +9,6 @@ from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.models import * from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model @@ -66,14 +65,14 @@ class RouteTargetSerializer(PrimaryModelSerializer): # RIRs/aggregates # -class RIRSerializer(OrganizationalModelSerializer): +class RIRSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR fields = [ - 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', + 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'aggregate_count', ] @@ -97,7 +96,7 @@ class AggregateSerializer(PrimaryModelSerializer): # VLANs # -class RoleSerializer(OrganizationalModelSerializer): +class RoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) @@ -105,12 +104,12 @@ class RoleSerializer(OrganizationalModelSerializer): class Meta: model = Role fields = [ - 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', - 'prefix_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'prefix_count', 'vlan_count', ] -class VLANGroupSerializer(OrganizationalModelSerializer): +class VLANGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') scope_type = ContentTypeField( queryset=ContentType.objects.filter( @@ -126,8 +125,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer): class Meta: model = VLANGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', - 'created', 'last_updated', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'vlan_count', ] validators = [] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69b6d97f0..a043bd88c 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -48,7 +48,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') - ) + ).prefetch_related('tags') serializer_class = serializers.RIRSerializer filterset_class = filtersets.RIRFilterSet @@ -71,7 +71,7 @@ class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), vlan_count=count_related(VLAN, 'role') - ) + ).prefetch_related('tags') serializer_class = serializers.RoleSerializer filterset_class = filtersets.RoleFilterSet @@ -126,7 +126,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet): queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') - ) + ).prefetch_related('tags') serializer_class = serializers.VLANGroupSerializer filterset_class = filtersets.VLANGroupFilterSet diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 895dbe200..43bf40f88 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -71,7 +71,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode ] -class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RIR.objects.all(), widget=forms.MultipleHiddenInput @@ -120,7 +120,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB } -class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Role.objects.all(), widget=forms.MultipleHiddenInput @@ -280,7 +280,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB ] -class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d28f7b3ae..a9c8a0910 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -82,11 +82,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RIRForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = RIR fields = [ - 'name', 'slug', 'is_private', 'description', + 'name', 'slug', 'is_private', 'description', 'tags', ] @@ -120,11 +124,15 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Role fields = [ - 'name', 'slug', 'weight', 'description', + 'name', 'slug', 'weight', 'description', 'tags', ] @@ -530,15 +538,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VLANGroup fields = [ 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', - 'clustergroup', 'cluster', + 'clustergroup', 'cluster', 'tags', ] fieldsets = ( - ('VLAN Group', ('name', 'slug', 'description')), + ('VLAN Group', ('name', 'slug', 'description', 'tags')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) widgets = { diff --git a/netbox/ipam/migrations/0051_extend_tag_support.py b/netbox/ipam/migrations/0051_extend_tag_support.py new file mode 100644 index 000000000..ea31a6645 --- /dev/null +++ b/netbox/ipam/migrations/0051_extend_tag_support.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='role', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='vlangroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4fc2b5dbb..514e87a62 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -31,7 +31,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -168,7 +168,7 @@ class Aggregate(PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 4ba8d7041..14eaa7ccc 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -21,7 +21,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLANGroup(OrganizationalModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index ddad6c573..a2a0c67b1 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -85,11 +85,14 @@ class RIRTable(BaseTable): url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) + tags = TagColumn( + url_name='ipam:rir_list' + ) actions = ButtonsColumn(RIR) class Meta(BaseTable.Meta): model = RIR - fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') @@ -144,11 +147,14 @@ class RoleTable(BaseTable): url_params={'role_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:role_list' + ) actions = ButtonsColumn(Role) class Meta(BaseTable.Meta): model = Role - fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions') + fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index fd1e92be8..4c0d5d729 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:vlangroup_list' + ) actions = ButtonsColumn( model=VLANGroup, prepend_template=VLANGROUP_ADD_VLAN @@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = VLANGroup - fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions') + fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2a0bfdf32..5440efcb6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -104,11 +104,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RIR(name='RIR 3', slug='rir-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'RIR X', 'slug': 'rir-x', 'is_private': True, 'description': 'A new RIR', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -177,11 +180,14 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Role(name='Role 3', slug='role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Role X', 'slug': 'role-x', 'weight': 200, 'description': 'A new role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -384,10 +390,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', 'description': 'A new VLAN group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index d17751e25..9f51d475d 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -147,13 +147,6 @@ class NestedTagSerializer(WritableNestedSerializer): # Base model serializers # -class OrganizationalModelSerializer(CustomFieldModelSerializer): - """ - Adds support for custom fields. - """ - pass - - class PrimaryModelSerializer(CustomFieldModelSerializer): """ Adds support for custom fields and tags. @@ -189,9 +182,9 @@ class PrimaryModelSerializer(CustomFieldModelSerializer): return instance -class NestedGroupModelSerializer(CustomFieldModelSerializer): +class NestedGroupModelSerializer(PrimaryModelSerializer): """ - Extends OrganizationalModelSerializer to include MPTT support. + Extends PrimaryModelSerializer to include MPTT support. """ _depth = serializers.IntegerField(source='level', read_only=True) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 181b9a0c6..7d71bd1fb 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -41,6 +41,7 @@ class ObjectType( class OrganizationalObjectType( ChangelogMixin, CustomFieldsMixin, + TagsMixin, BaseObjectType ): """ diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 317548921..95cea6a93 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -143,6 +143,18 @@ class CustomValidationMixin(models.Model): post_clean.send(sender=self.__class__, instance=self) +class TagsMixin(models.Model): + """ + Enable the assignment of Tags. + """ + tags = TaggableManager( + through='extras.TaggedItem' + ) + + class Meta: + abstract = True + + # # Base model classes @@ -166,7 +178,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -175,15 +187,12 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, object_id_field='assigned_object_id', content_type_field='assigned_object_type' ) - tags = TaggableManager( - through='extras.TaggedItem' - ) class Meta: abstract = True -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel, MPTTModel): +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -225,7 +234,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi }) -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 27a14b350..90c13725c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType from rest_framework import serializers from netbox.api import ChoiceField, ContentTypeField -from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import * from .nested_serializers import * @@ -20,8 +20,8 @@ class TenantGroupSerializer(NestedGroupModelSerializer): class Meta: model = TenantGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'tenant_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'tenant_count', '_depth', ] @@ -60,18 +60,18 @@ class ContactGroupSerializer(NestedGroupModelSerializer): class Meta: model = ContactGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'contact_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'contact_count', '_depth', ] -class ContactRoleSerializer(OrganizationalModelSerializer): +class ContactRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') class Meta: model = ContactRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 7ce16c143..8c7c33aba 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -30,7 +30,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet): 'group', 'tenant_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.TenantGroupSerializer filterset_class = filtersets.TenantGroupFilterSet @@ -64,28 +64,24 @@ class ContactGroupViewSet(CustomFieldModelViewSet): 'group', 'contact_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.ContactGroupSerializer filterset_class = filtersets.ContactGroupFilterSet class ContactRoleViewSet(CustomFieldModelViewSet): - queryset = ContactRole.objects.all() + queryset = ContactRole.objects.prefetch_related('tags') serializer_class = serializers.ContactRoleSerializer filterset_class = filtersets.ContactRoleFilterSet class ContactViewSet(CustomFieldModelViewSet): - queryset = Contact.objects.prefetch_related( - 'group', 'tags' - ) + queryset = Contact.objects.prefetch_related('group', 'tags') serializer_class = serializers.ContactSerializer filterset_class = filtersets.ContactFilterSet class ContactAssignmentViewSet(CustomFieldModelViewSet): - queryset = ContactAssignment.objects.prefetch_related( - 'contact', 'role' - ) + queryset = ContactAssignment.objects.prefetch_related('contact', 'role') serializer_class = serializers.ContactAssignmentSerializer filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index a34b8def1..f461fe73c 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -17,7 +17,7 @@ __all__ = ( # Tenants # -class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class TenantGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=TenantGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -55,7 +55,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk # Contacts # -class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ContactGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -73,7 +73,7 @@ class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ContactRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactRole.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index b15065705..0237e4ef8 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -28,11 +28,15 @@ class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ] @@ -68,18 +72,26 @@ class ContactGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ContactGroup - fields = ['parent', 'name', 'slug', 'description'] + fields = ('parent', 'name', 'slug', 'description', 'tags') class ContactRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ContactRole - fields = ['name', 'slug', 'description'] + fields = ('name', 'slug', 'description', 'tags') class ContactForm(BootstrapMixin, CustomFieldModelForm): diff --git a/netbox/tenancy/migrations/0004_extend_tag_support.py b/netbox/tenancy/migrations/0004_extend_tag_support.py new file mode 100644 index 000000000..942be38b5 --- /dev/null +++ b/netbox/tenancy/migrations/0004_extend_tag_support.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('tenancy', '0003_contacts'), + ] + + operations = [ + migrations.AddField( + model_name='contactgroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='contactrole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='tenantgroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index c709236e2..01ea2d0d5 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -24,7 +24,7 @@ __all__ = ( # Tenants # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. @@ -111,7 +111,7 @@ class Tenant(PrimaryModel): # Contacts # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. @@ -145,7 +145,7 @@ class ContactGroup(NestedGroupModel): return reverse('tenancy:contactgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 5b254842b..02c431846 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -55,11 +55,14 @@ class TenantGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='Tenants' ) + tags = TagColumn( + url_name='tenancy:tenantgroup_list' + ) actions = ButtonsColumn(TenantGroup) class Meta(BaseTable.Meta): model = TenantGroup - fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') @@ -96,11 +99,14 @@ class ContactGroupTable(BaseTable): url_params={'role_id': 'pk'}, verbose_name='Contacts' ) + tags = TagColumn( + url_name='tenancy:contactgroup_list' + ) actions = ButtonsColumn(ContactGroup) class Meta(BaseTable.Meta): model = ContactGroup - fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index fb7ff3ce3..dcfcc1652 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -16,10 +16,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for tenanantgroup in tenant_groups: tenanantgroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Tenant Group X', 'slug': 'tenant-group-x', 'description': 'A new tenant group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -90,10 +93,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for tenanantgroup in contact_groups: tenanantgroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Contact Group X', 'slug': 'contact-group-x', 'description': 'A new contact group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -120,10 +126,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ContactRole(name='Contact Role 3', slug='contact-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Devie Role X', 'slug': 'contact-role-x', 'description': 'New contact role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 1928960a9..ef8c975d3 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -6,7 +6,7 @@ from dcim.choices import InterfaceModeChoices from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -17,26 +17,26 @@ from .nested_serializers import * # Clusters # -class ClusterTypeSerializer(OrganizationalModelSerializer): +class ClusterTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] -class ClusterGroupSerializer(OrganizationalModelSerializer): +class ClusterGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 8eebd2120..d07ace3d5 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -23,7 +23,7 @@ class VirtualizationRootView(APIRootView): class ClusterTypeViewSet(CustomFieldModelViewSet): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') - ) + ).prefetch_related('tags') serializer_class = serializers.ClusterTypeSerializer filterset_class = filtersets.ClusterTypeFilterSet @@ -31,7 +31,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet): class ClusterGroupViewSet(CustomFieldModelViewSet): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') - ) + ).prefetch_related('tags') serializer_class = serializers.ClusterGroupSerializer filterset_class = filtersets.ClusterGroupFilterSet diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index c140fbc73..d18d432cd 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -23,7 +23,7 @@ __all__ = ( ) -class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ClusterTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterType.objects.all(), widget=forms.MultipleHiddenInput @@ -37,7 +37,7 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ClusterGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index d66bc9f1f..88ebc9e83 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -28,22 +28,30 @@ __all__ = ( class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ClusterType - fields = [ - 'name', 'slug', 'description', - ] + fields = ( + 'name', 'slug', 'description', 'tags', + ) class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ClusterGroup - fields = [ - 'name', 'slug', 'description', - ] + fields = ( + 'name', 'slug', 'description', 'tags', + ) class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): diff --git a/netbox/virtualization/migrations/0025_extend_tag_support.py b/netbox/virtualization/migrations/0025_extend_tag_support.py new file mode 100644 index 000000000..c77aee194 --- /dev/null +++ b/netbox/virtualization/migrations/0025_extend_tag_support.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('virtualization', '0024_cluster_relax_uniqueness'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='clustertype', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 11792944a..bd64f56cf 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -30,7 +30,7 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -64,7 +64,7 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index b0e922e71..64b376e1d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -40,11 +40,14 @@ class ClusterTypeTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustertype_list' + ) actions = ButtonsColumn(ClusterType) class Meta(BaseTable.Meta): model = ClusterType - fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') @@ -60,11 +63,14 @@ class ClusterGroupTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustergroup_list' + ) actions = ButtonsColumn(ClusterGroup) class Meta(BaseTable.Meta): model = ClusterGroup - fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 020c9ebc5..138b1afae 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -22,10 +22,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Group X', 'slug': 'cluster-group-x', 'description': 'A new cluster group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -52,10 +55,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterType(name='Cluster Type 3', slug='cluster-type-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Type X', 'slug': 'cluster-type-x', 'description': 'A new cluster type', + 'tags': [t.pk for t in tags], } cls.csv_data = ( From 6f05f17c62ee075a7a997554e981ad81489ec2f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 11:23:31 -0400 Subject: [PATCH 050/289] Standardize & simplify tags panel inclusion --- netbox/templates/circuits/circuit.html | 2 +- netbox/templates/circuits/circuittype.html | 1 + netbox/templates/circuits/provider.html | 2 +- netbox/templates/circuits/providernetwork.html | 2 +- netbox/templates/dcim/cable.html | 2 +- netbox/templates/dcim/consoleport.html | 2 +- netbox/templates/dcim/consoleserverport.html | 2 +- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/devicebay.html | 2 +- netbox/templates/dcim/devicerole.html | 1 + netbox/templates/dcim/devicetype.html | 2 +- netbox/templates/dcim/frontport.html | 2 +- netbox/templates/dcim/interface.html | 2 +- netbox/templates/dcim/inventoryitem.html | 2 +- netbox/templates/dcim/location.html | 1 + netbox/templates/dcim/manufacturer.html | 1 + netbox/templates/dcim/platform.html | 1 + netbox/templates/dcim/powerfeed.html | 2 +- netbox/templates/dcim/poweroutlet.html | 2 +- netbox/templates/dcim/powerpanel.html | 2 +- netbox/templates/dcim/powerport.html | 2 +- netbox/templates/dcim/rack.html | 2 +- netbox/templates/dcim/rackreservation.html | 2 +- netbox/templates/dcim/rackrole.html | 1 + netbox/templates/dcim/rearport.html | 2 +- netbox/templates/dcim/region.html | 1 + netbox/templates/dcim/site.html | 3 +-- netbox/templates/dcim/sitegroup.html | 1 + netbox/templates/dcim/virtualchassis.html | 2 +- netbox/templates/inc/panels/tags.html | 15 +++++++++------ netbox/templates/ipam/aggregate.html | 2 +- netbox/templates/ipam/ipaddress.html | 2 +- netbox/templates/ipam/iprange.html | 2 +- netbox/templates/ipam/prefix.html | 2 +- netbox/templates/ipam/rir.html | 1 + netbox/templates/ipam/role.html | 1 + netbox/templates/ipam/routetarget.html | 2 +- netbox/templates/ipam/service.html | 2 +- netbox/templates/ipam/vlan.html | 2 +- netbox/templates/ipam/vlangroup.html | 1 + netbox/templates/ipam/vrf.html | 2 +- netbox/templates/tenancy/contact.html | 2 +- netbox/templates/tenancy/contactgroup.html | 1 + netbox/templates/tenancy/contactrole.html | 1 + netbox/templates/tenancy/tenant.html | 2 +- netbox/templates/tenancy/tenantgroup.html | 1 + netbox/templates/virtualization/cluster.html | 2 +- netbox/templates/virtualization/clustergroup.html | 1 + netbox/templates/virtualization/clustertype.html | 1 + .../templates/virtualization/virtualmachine.html | 2 +- netbox/templates/virtualization/vminterface.html | 4 ++-- 51 files changed, 60 insertions(+), 42 deletions(-) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index b61dac6fc..22713b592 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -65,7 +65,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index ad81de7e1..57737a6d1 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -28,6 +28,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index d353e4f37..c16afa421 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -47,7 +47,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:provider_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 18a11e115..9641c9934 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -38,7 +38,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:providernetwork_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index c5d1f7906..00704e6ca 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -64,7 +64,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:cable_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index c340cbc5c..60711eb9d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -41,7 +41,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 91de60252..f65af3285 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -41,7 +41,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 869ab1ec7..ea0c795c5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -221,7 +221,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:device_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 918b6b022..ff8f90db2 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -33,7 +33,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 2c2d7fe6f..22385ae27 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -58,6 +58,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 74a3e73d7..21a04e7d0 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -88,7 +88,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:devicetype_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index c6b6cea48..6cc3d482f 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -53,7 +53,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 0715bec58..af038326d 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -103,7 +103,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index e55d441d4..163d8edb3 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -65,7 +65,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index eeb891daf..434253d43 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -68,6 +68,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 792a3e127..d43a206c6 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -34,6 +34,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index bbdf809dd..8cd26a116 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -55,6 +55,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index f29a127e3..1824cac19 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -108,7 +108,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerfeed_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 1f960e0d5..396ef42a8 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -45,7 +45,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index a99aabf32..021fa1133 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -39,7 +39,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerpanel_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 74ad9603b..dfe428c50 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -45,7 +45,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 586d31771..93bd21fd9 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -163,7 +163,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rack_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% if power_feeds %}
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 07ca55f7c..1e16af675 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -84,7 +84,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rackreservation_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 2668905f4..2f4661c9f 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -34,6 +34,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index b60e04882..b3ecce3ad 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -47,7 +47,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index c03b11e7d..7452e594e 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -45,6 +45,7 @@
+ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 8442ae41e..a17c505a9 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -169,7 +169,6 @@
- {{ object.contact_email }} {% else %} @@ -181,7 +180,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:site_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index dbee2c835..d04330413 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index fd31be60d..8399576f5 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -39,7 +39,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:virtualchassis_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/inc/panels/tags.html b/netbox/templates/inc/panels/tags.html index e67098c0f..c309afdf0 100644 --- a/netbox/templates/inc/panels/tags.html +++ b/netbox/templates/inc/panels/tags.html @@ -1,11 +1,14 @@ {% load helpers %} +
-
- Tags -
+
Tags
- {% for tag in tags.all %} {% tag tag url %} {% empty %} - No tags assigned - {% endfor %} + {% with url=object|validated_viewname:"list" %} + {% for tag in object.tags.all %} + {% tag tag url %} + {% empty %} + No tags assigned + {% endfor %} + {% endwith %}
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 202b6e41c..aca89a526 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -65,7 +65,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:aggregate_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index d98544de4..31782bdd7 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -145,7 +145,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:ipaddress_list' %} + {% include 'inc/panels/tags.html' %}
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index e3d37a87a..b549ec7c5 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -82,7 +82,7 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 877ed49e0..eaea4e1ec 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -122,7 +122,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/rir.html b/netbox/templates/ipam/rir.html index 26d5e71da..c2f88c278 100644 --- a/netbox/templates/ipam/rir.html +++ b/netbox/templates/ipam/rir.html @@ -38,6 +38,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 7fc967047..5579010fa 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -32,6 +32,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index f615d2d50..71d6f9601 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -30,7 +30,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:routetarget_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 7609a280b..5a47e44f0 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -61,7 +61,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:service_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index e8c514cca..367ae3641 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -83,7 +83,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vlan_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index 2d31feb22..1c36e92f6 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -54,6 +54,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index b320fe6b8..349fe20d3 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -60,7 +60,7 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vrf_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 8bdf6c030..3c6ada5a0 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -60,7 +60,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index 0eef750eb..efb86af91 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html index 4ddde3624..3272728f2 100644 --- a/netbox/templates/tenancy/contactrole.html +++ b/netbox/templates/tenancy/contactrole.html @@ -30,6 +30,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index dc51b48c5..f54fd1425 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -36,7 +36,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index 31a756d9e..75d2c5a27 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 84b8235ad..b7af89bb2 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -61,7 +61,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:cluster_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index b367d97f7..3979fa0e6 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -28,6 +28,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html index e3c050a1b..de5f3c519 100644 --- a/netbox/templates/virtualization/clustertype.html +++ b/netbox/templates/virtualization/clustertype.html @@ -28,6 +28,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0d9ea4a22..068d7f164 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -90,7 +90,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index ef12b63a1..1678013f2 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -70,8 +70,8 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %}
From 4932e4f8c64560a453542b8729dea49d0b590ee5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 11:28:25 -0400 Subject: [PATCH 051/289] Changelog for #6497 --- docs/release-notes/version-3.1.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 291831500..c829ef2b9 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -20,6 +20,7 @@ When assigning a contact to an object, the user must select a predefined role (e * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces * [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices +* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations @@ -37,6 +38,23 @@ When assigning a contact to an object, the user must select a predefined role (e * `/api/tenancy/contact-groups/` * `/api/tenancy/contact-roles/` * `/api/tenancy/contacts/` +* Added `tags` field to the following models: + * circuits.CircuitType + * dcim.DeviceRole + * dcim.Location + * dcim.Manufacturer + * dcim.Platform + * dcim.RackRole + * dcim.Region + * dcim.SiteGroup + * ipam.RIR + * ipam.Role + * ipam.VLANGroup + * tenancy.ContactGroup + * tenancy.ContactRole + * tenancy.TenantGroup + * virtualization.ClusterGroup + * virtualization.ClusterType * dcim.Cable * Added `tenant` field * dcim.Device From 1c6a84659cc0a401b43f2abae8334fbf8eaf5774 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 14:11:11 -0400 Subject: [PATCH 052/289] #3979 cleanup --- netbox/dcim/tables/template_code.py | 4 +- netbox/netbox/views/__init__.py | 8 ++- .../templates/wireless/wirelesslink_edit.html | 33 ++++++++++ netbox/wireless/forms/models.py | 62 ++++++++++++++++--- 4 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 netbox/templates/wireless/wirelesslink_edit.html diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index aab15b5ef..f6938807a 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -236,8 +236,8 @@ INTERFACE_BUTTONS = """ {% endif %} {% elif record.is_wireless and perms.wireless.add_wirelesslink %} - - + + {% endif %} """ diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 2c033e760..b361352d0 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -27,6 +27,7 @@ from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES from netbox.forms import SearchForm from tenancy.models import Tenant from virtualization.models import Cluster, VirtualMachine +from wireless.models import WirelessLAN, WirelessLink class HomeView(View): @@ -92,14 +93,19 @@ class HomeView(View): ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), ) + wireless = ( + ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count), + ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count), + ) sections = ( ("Organization", org, "domain"), ("IPAM", ipam, "counter"), ("Virtualization", virtualization, "monitor"), ("Inventory", dcim, "server"), - ("Connections", connections, "cable-data"), ("Circuits", circuits, "transit-connection-variant"), + ("Connections", connections, "cable-data"), ("Power", power, "flash"), + ("Wireless", wireless, "wifi"), ) stats = [] diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html new file mode 100644 index 000000000..034d147de --- /dev/null +++ b/netbox/templates/wireless/wirelesslink_edit.html @@ -0,0 +1,33 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
+
+
Side A
+
+ {% render_field form.device_a %} + {% render_field form.interface_a %} +
+
+
+
+
+
Side B
+
+ {% render_field form.device_b %} + {% render_field form.interface_b %} +
+
+
+ {% if form.custom_fields %} +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
+ {% endif %} +{% endblock %} diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 544d5823d..f7985a31d 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,4 +1,4 @@ -from dcim.models import Device, Interface +from dcim.models import Device, Interface, Location, Site from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN @@ -64,10 +64,30 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): + site_a = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + initial_params={ + 'devices': '$device_a', + } + ) + location_a = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + label='Location', + initial_params={ + 'devices': '$device_a', + } + ) device_a = DynamicModelChoiceField( queryset=Device.objects.all(), + query_params={ + 'site_id': '$site_a', + 'location_id': '$location_a', + }, required=False, - label='Device A', + label='Device', initial_params={ 'interfaces': '$interface_a' } @@ -79,12 +99,32 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): 'device_id': '$device_a', }, disabled_indicator='_occupied', - label='Interface A' + label='Interface' + ) + site_b = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + initial_params={ + 'devices': '$device_b', + } + ) + location_b = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + label='Location', + initial_params={ + 'devices': '$device_b', + } ) device_b = DynamicModelChoiceField( queryset=Device.objects.all(), + query_params={ + 'site_id': '$site_b', + 'location_id': '$location_b', + }, required=False, - label='Device B', + label='Device', initial_params={ 'interfaces': '$interface_b' } @@ -96,7 +136,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): 'device_id': '$device_b', }, disabled_indicator='_occupied', - label='Interface B' + label='Interface' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -106,11 +146,13 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'auth_type', - 'auth_cipher', 'auth_psk', 'tags', + 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', + 'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] fieldsets = ( - ('Link', ('device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags')), + ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), + ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), + ('Link', ('status', 'ssid', 'description', 'tags')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) widgets = { @@ -118,3 +160,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): 'auth_type': StaticSelect, 'auth_cipher': StaticSelect, } + labels = { + 'auth_type': 'Type', + 'auth_cipher': 'Cipher', + } From 6f66138a1878a9fdafe93c1ecda251954a67bf78 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 15:15:01 -0400 Subject: [PATCH 053/289] Changelog for #3979 --- docs/release-notes/version-3.1.md | 13 ++++++++++++- netbox/netbox/settings.py | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index c829ef2b9..88142bf78 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -13,7 +13,18 @@ A set of new models for tracking contact information has been introduced within When assigning a contact to an object, the user must select a predefined role (e.g. "billing" or "technical") and may optionally indicate a priority relative to other contacts associated with the object. There is no limit on how many contacts can be assigned to an object, nor on how many objects to which a contact can be assigned. -#### +#### Wireless Networks ([#3979](https://github.com/netbox-community/netbox/issues/3979)) + +This release introduces two new models to represent wireless networks: + +* Wireless LAN - A multi-access wireless segment to which any number of wireless interfaces may be attached +* Wireless Link - A point-to-point connection between exactly two wireless interfaces + +Both types of connection include SSID and authentication attributes. Additionally, the interface model has been extended to include several attributes pertinent to wireless operation: + +* Wireless role - Access point or station +* Channel - A predefined channel within a standardized band +* Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6381435f2..279b8c453 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.9-dev' +VERSION = '3.1-beta1' # Hostname HOSTNAME = platform.node() From c06b3374cef7abee6836d07d31e348197695b3df Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 15:29:52 -0400 Subject: [PATCH 054/289] #6497: Add missing tag fields to filter forms --- netbox/circuits/filtersets.py | 1 + netbox/circuits/forms/filtersets.py | 4 +--- netbox/dcim/filtersets.py | 7 +++++++ netbox/dcim/forms/filtersets.py | 24 +++++++---------------- netbox/ipam/filtersets.py | 3 +++ netbox/ipam/forms/filtersets.py | 12 ++++-------- netbox/tenancy/filtersets.py | 3 +++ netbox/tenancy/forms/filtersets.py | 6 +++--- netbox/virtualization/filtersets.py | 2 ++ netbox/virtualization/forms/filtersets.py | 8 ++------ netbox/wireless/filtersets.py | 1 + netbox/wireless/forms/filtersets.py | 4 +--- 12 files changed, 35 insertions(+), 40 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 15bc5a8b3..fd582dd99 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -111,6 +111,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): class CircuitTypeFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = CircuitType diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 63b654148..b29f8f772 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -79,14 +79,12 @@ class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = CircuitType - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f6d8abb0a..e81bd5e43 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -72,6 +72,7 @@ class RegionFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) + tag = TagFilter() class Meta: model = Region @@ -89,6 +90,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) + tag = TagFilter() class Meta: model = SiteGroup @@ -208,6 +210,7 @@ class LocationFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Location (slug)', ) + tag = TagFilter() class Meta: model = Location @@ -223,6 +226,7 @@ class LocationFilterSet(OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = RackRole @@ -388,6 +392,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = Manufacturer @@ -570,6 +575,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent class DeviceRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = DeviceRole @@ -588,6 +594,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + tag = TagFilter() class Meta: model = Platform diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 5c776386a..6530b3b46 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -106,10 +106,6 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Region - field_groups = [ - ['q'], - ['parent_id'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -121,14 +117,11 @@ class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent region'), fetch_trigger='open' ) + tag = TagFilterField(model) class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = SiteGroup - field_groups = [ - ['q'], - ['parent_id'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -140,6 +133,7 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -219,18 +213,17 @@ class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilt label=_('Parent'), fetch_trigger='open' ) + tag = TagFilterField(model) class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = RackRole - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -371,14 +364,12 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Manufacturer - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -456,14 +447,12 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = DeviceRole - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -479,6 +468,7 @@ class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Manufacturer'), fetch_trigger='open' ) + tag = TagFilterField(model) class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 37a9299dc..56d23387f 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -118,6 +118,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RIRFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = RIR @@ -179,6 +180,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): method='search', label='Search', ) + tag = TagFilter() class Meta: model = Role @@ -636,6 +638,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): cluster = django_filters.NumberFilter( method='filter_scope' ) + tag = TagFilter() class Meta: model = VLANGroup diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 8bc0f10fb..415664f62 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -91,10 +91,6 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelF class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = RIR - field_groups = [ - ['q'], - ['is_private'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -107,6 +103,7 @@ class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -138,14 +135,12 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Role - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -363,7 +358,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): field_groups = [ - ['q'], + ['q', 'tag'], ['region', 'sitegroup', 'site', 'location', 'rack'] ] model = VLANGroup @@ -402,6 +397,7 @@ class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Rack'), fetch_trigger='open' ) + tag = TagFilterField(model) class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index f6d0ac72e..dd73edace 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -33,6 +33,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) + tag = TagFilter() class Meta: model = TenantGroup @@ -118,6 +119,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) + tag = TagFilter() class Meta: model = ContactGroup @@ -125,6 +127,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): class ContactRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ContactRole diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 69941701f..b693db68f 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -31,6 +31,7 @@ class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -71,18 +72,17 @@ class ContactGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class ContactRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = ContactRole - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class ContactFilterForm(BootstrapMixin, CustomFieldModelFilterForm): diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 3fc1da8ea..e2aac9b80 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( class ClusterTypeFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ClusterType @@ -27,6 +28,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): class ClusterGroupFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ClusterGroup diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 0bb5c2bd7..1e8156c33 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -22,26 +22,22 @@ __all__ = ( class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = ClusterType - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = ClusterGroup - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index cffdcf046..654dd843f 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -25,6 +25,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): queryset=WirelessLANGroup.objects.all(), to_field_name='slug' ) + tag = TagFilter() class Meta: model = WirelessLANGroup diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 483d74a7c..b7eeec76b 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -29,6 +29,7 @@ class WirelessLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -71,9 +72,6 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = WirelessLink - field_groups = [ - ['q', 'tag'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), From a3e7cab93597be622ca47ed72dddcff0953dfdc4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 15:33:58 -0400 Subject: [PATCH 055/289] Split tenancy models into separate modules --- netbox/tenancy/models/__init__.py | 2 + .../tenancy/{models.py => models/contacts.py} | 100 +----------------- netbox/tenancy/models/tenants.py | 96 +++++++++++++++++ 3 files changed, 101 insertions(+), 97 deletions(-) create mode 100644 netbox/tenancy/models/__init__.py rename netbox/tenancy/{models.py => models/contacts.py} (66%) create mode 100644 netbox/tenancy/models/tenants.py diff --git a/netbox/tenancy/models/__init__.py b/netbox/tenancy/models/__init__.py new file mode 100644 index 000000000..6d62edd20 --- /dev/null +++ b/netbox/tenancy/models/__init__.py @@ -0,0 +1,2 @@ +from .contacts import * +from .tenants import * diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models/contacts.py similarity index 66% rename from netbox/tenancy/models.py rename to netbox/tenancy/models/contacts.py index 01ea2d0d5..2669aa121 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models/contacts.py @@ -1,116 +1,22 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from extras.utils import extras_features from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from tenancy.choices import * from utilities.querysets import RestrictedQuerySet -from .choices import * - __all__ = ( 'ContactAssignment', 'Contact', 'ContactGroup', 'ContactRole', - 'Tenant', - 'TenantGroup', ) -# -# Tenants -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class TenantGroup(NestedGroupModel): - """ - An arbitrary collection of Tenants. - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - class Meta: - ordering = ['name'] - - def get_absolute_url(self): - return reverse('tenancy:tenantgroup', args=[self.pk]) - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Tenant(PrimaryModel): - """ - A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal - department. - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - group = models.ForeignKey( - to='tenancy.TenantGroup', - on_delete=models.SET_NULL, - related_name='tenants', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) - - # Generic relations - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - objects = RestrictedQuerySet.as_manager() - - clone_fields = [ - 'group', 'description', - ] - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('tenancy:tenant', args=[self.pk]) - - -# -# Contacts -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py new file mode 100644 index 000000000..7dae2c093 --- /dev/null +++ b/netbox/tenancy/models/tenants.py @@ -0,0 +1,96 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models +from django.urls import reverse +from mptt.models import TreeForeignKey + +from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'Tenant', + 'TenantGroup', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class TenantGroup(NestedGroupModel): + """ + An arbitrary collection of Tenants. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ['name'] + + def get_absolute_url(self): + return reverse('tenancy:tenantgroup', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Tenant(PrimaryModel): + """ + A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal + department. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + group = models.ForeignKey( + to='tenancy.TenantGroup', + on_delete=models.SET_NULL, + related_name='tenants', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'group', 'description', + ] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:tenant', args=[self.pk]) From e1e2c76ae14b102a0db56997ff36ce43b2b5adce Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 16:30:18 -0400 Subject: [PATCH 056/289] Add bridge field to Interface, VMInterface models --- netbox/dcim/api/serializers.py | 5 +- netbox/dcim/api/views.py | 2 +- netbox/dcim/filtersets.py | 5 ++ netbox/dcim/forms/bulk_edit.py | 15 +++- netbox/dcim/forms/bulk_import.py | 38 ++------- netbox/dcim/forms/models.py | 18 ++-- netbox/dcim/forms/object_create.py | 9 +- netbox/dcim/migrations/0134_interface_wwn.py | 17 ---- .../migrations/0134_interface_wwn_bridge.py | 23 +++++ .../migrations/0135_tenancy_extensions.py | 2 +- netbox/dcim/models/device_components.py | 83 +++++++++++++------ netbox/dcim/tables/devices.py | 14 ++-- netbox/templates/dcim/interface.html | 10 +++ netbox/templates/dcim/interface_edit.html | 1 + .../templates/virtualization/vminterface.html | 10 +++ .../virtualization/vminterface_edit.html | 1 + netbox/virtualization/api/serializers.py | 5 +- netbox/virtualization/filtersets.py | 5 ++ netbox/virtualization/forms/bulk_edit.py | 14 +++- netbox/virtualization/forms/bulk_import.py | 14 +++- netbox/virtualization/forms/models.py | 10 ++- netbox/virtualization/forms/object_create.py | 9 +- .../migrations/0026_vminterface_bridge.py | 19 +++++ netbox/virtualization/models.py | 33 +++++--- netbox/virtualization/tables.py | 17 ++-- 25 files changed, 260 insertions(+), 119 deletions(-) delete mode 100644 netbox/dcim/migrations/0134_interface_wwn.py create mode 100644 netbox/dcim/migrations/0134_interface_wwn_bridge.py create mode 100644 netbox/virtualization/migrations/0026_vminterface_bridge.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index bc5e9b54e..1f2897a7f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -605,6 +605,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) parent = NestedInterfaceSerializer(required=False, allow_null=True) + bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) @@ -622,8 +623,8 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', + 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9cbdf7d5d..921ee3a99 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e81bd5e43..c049025b7 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -975,6 +975,11 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT queryset=Interface.objects.all(), label='Parent interface (ID)', ) + bridge_id = django_filters.ModelMultipleChoiceFilter( + field_name='bridge', + queryset=Interface.objects.all(), + label='Bridged interface (ID)', + ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9abdcb8ff..b1dce2281 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -939,8 +939,8 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', + 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -964,6 +964,10 @@ class InterfaceBulkEditForm( queryset=Interface.objects.all(), required=False ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -991,7 +995,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', + 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] @@ -1000,8 +1004,9 @@ class InterfaceBulkEditForm( if 'device' in self.initial: device = Device.objects.filter(pk=self.initial['device']).first() - # Restrict parent/LAG interface assignment by device + # Restrict parent/bridge/LAG interface assignment by device self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.pk) self.fields['lag'].widget.add_query_param('device_id', device.pk) # Limit VLAN choices by device @@ -1029,6 +1034,8 @@ class InterfaceBulkEditForm( self.fields['parent'].choices = () self.fields['parent'].widget.attrs['disabled'] = True + self.fields['bridge'].choices = () + self.fields['bridge'].widget.attrs['disabled'] = True self.fields['lag'].choices = () self.fields['lag'].widget.attrs['disabled'] = True diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f39e3cd7f..18bdb3d3f 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -570,6 +570,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Parent interface' ) + bridge = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged interface' + ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -594,39 +600,11 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', + 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces belonging to this device (or virtual chassis) - device = None - if self.is_bound and 'device' in self.data: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - pass - if device and device.virtual_chassis: - self.fields['lag'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis) - ) - elif device: - self.fields['lag'].queryset = Interface.objects.filter( - device=device, - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter(device=device) - else: - self.fields['lag'].queryset = Interface.objects.none() - self.fields['parent'].queryset = Interface.objects.none() - def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data if 'enabled' not in self.data: diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index e395c67d2..a2b5e7dba 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1093,6 +1093,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Bridged interface' + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1143,8 +1148,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', + 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { @@ -1168,13 +1173,14 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - # Restrict parent/LAG interface assignment by device/VC + # Restrict parent/bridge/LAG interface assignment by device/VC self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.pk) + self.fields['lag'].widget.add_query_param('device_id', device.pk) if device.virtual_chassis and device.virtual_chassis.master: - # Get available LAG interfaces by VirtualChassis master + self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - else: - self.fields['lag'].widget.add_query_param('device_id', device.pk) # Limit VLAN choices by device self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 547fe7e68..3beb42c8d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -446,6 +446,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): 'device_id': '$device', } ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -497,7 +504,7 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/dcim/migrations/0134_interface_wwn.py b/netbox/dcim/migrations/0134_interface_wwn.py deleted file mode 100644 index 0739edbbb..000000000 --- a/netbox/dcim/migrations/0134_interface_wwn.py +++ /dev/null @@ -1,17 +0,0 @@ -import dcim.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0133_port_colors'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='wwn', - field=dcim.fields.WWNField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0134_interface_wwn_bridge.py b/netbox/dcim/migrations/0134_interface_wwn_bridge.py new file mode 100644 index 000000000..a900ae6be --- /dev/null +++ b/netbox/dcim/migrations/0134_interface_wwn_bridge.py @@ -0,0 +1,23 @@ +import dcim.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0133_port_colors'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wwn', + field=dcim.fields.WWNField(blank=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interface'), + ), + ] diff --git a/netbox/dcim/migrations/0135_tenancy_extensions.py b/netbox/dcim/migrations/0135_tenancy_extensions.py index 673b5027f..96d765eea 100644 --- a/netbox/dcim/migrations/0135_tenancy_extensions.py +++ b/netbox/dcim/migrations/0135_tenancy_extensions.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0002_tenant_ordering'), - ('dcim', '0134_interface_wwn'), + ('dcim', '0134_interface_wwn_bridge'), ] operations = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index c2a37fcae..2a6adfa0c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -462,6 +462,22 @@ class BaseInterface(models.Model): choices=InterfaceModeChoices, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) + bridge = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='bridge_interfaces', + null=True, + blank=True, + verbose_name='Bridge interface' + ) class Meta: abstract = True @@ -495,14 +511,6 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): max_length=100, blank=True ) - parent = models.ForeignKey( - to='self', - on_delete=models.SET_NULL, - related_name='child_interfaces', - null=True, - blank=True, - verbose_name='Parent interface' - ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -586,7 +594,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): related_query_name='interface' ) - clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only'] + clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -610,6 +618,16 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." }) + # Parent validation + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + + # A physical interface cannot have a parent interface + if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: + raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + # An interface's parent must belong to the same device or virtual chassis if self.parent and self.parent.device != self.device: if self.device.virtual_chassis is None: @@ -623,13 +641,34 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): f"is not part of virtual chassis {self.device.virtual_chassis}." }) - # An interface cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({'parent': "An interface cannot be its own parent."}) + # Bridge validation - # A physical interface cannot have a parent interface - if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: - raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + # An interface cannot be bridged to itself + if self.pk and self.bridge_id == self.pk: + raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) + + # A bridged interface belong to the same device or virtual chassis + if self.bridge and self.bridge.device != self.device: + if self.device.virtual_chassis is None: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device " + f"({self.bridge.device})." + }) + elif self.bridge.device.virtual_chassis != self.device.virtual_chassis: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which " + f"is not part of virtual chassis {self.device.virtual_chassis}." + }) + + # LAG validation + + # A virtual interface cannot have a parent LAG + if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: + raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) + + # A LAG interface cannot be its own parent + if self.pk and self.lag_id == self.pk: + raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: @@ -643,13 +682,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): f"of virtual chassis {self.device.virtual_chassis}." }) - # A virtual interface cannot have a parent LAG - if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: - raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) - - # A LAG interface cannot be its own parent - if self.pk and self.lag_id == self.pk: - raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) + # Wireless validation # RF role & channel may only be set for wireless interfaces if self.rf_role and not self.is_wireless: @@ -679,11 +712,13 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): elif self.rf_channel: self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') + # VLAN validation + # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: raise ValidationError({ - 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " - "device, or it must be global".format(self.untagged_vlan) + 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " + f"interface's parent device, or it must be global." }) @property diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 06c594f6b..8ea27b8a6 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -521,8 +521,10 @@ class DeviceInterfaceTable(InterfaceTable): attrs={'td': {'class': 'text-nowrap'}} ) parent = tables.Column( - linkify=True, - verbose_name='Parent' + linkify=True + ) + bridge = tables.Column( + linkify=True ) lag = tables.Column( linkify=True, @@ -537,10 +539,10 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', - 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', + 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 730720b42..eb47f5655 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + LAG diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index aec88d25a..2afa0a7b6 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -18,6 +18,7 @@ {% render_field form.label %} {% render_field form.type %} {% render_field form.parent %} + {% render_field form.bridge %} {% render_field form.lag %} {% render_field form.mac_address %} {% render_field form.wwn %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 1678013f2..2646686e8 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -47,6 +47,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + Description {{ object.description|placeholder }} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index b4d097513..824f2bf24 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -17,6 +17,7 @@ {% render_field form.name %} {% render_field form.enabled %} {% render_field form.parent %} + {% render_field form.bridge %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index ef8c975d3..6cdc0e09a 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -107,6 +107,7 @@ class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() parent = NestedVMInterfaceSerializer(required=False, allow_null=True) + bridge = NestedVMInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -120,8 +121,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer): class Meta: model = VMInterface fields = [ - 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', ] diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index e2aac9b80..dc084a67f 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -264,6 +264,11 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet): queryset=VMInterface.objects.all(), label='Parent interface (ID)', ) + bridge_id = django_filters.ModelMultipleChoiceFilter( + field_name='bridge', + queryset=VMInterface.objects.all(), + label='Bridged interface (ID)', + ) mac_address = MultiValueMACAddressFilter( label='MAC address', ) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d18d432cd..d6c190904 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -165,6 +165,10 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode queryset=VMInterface.objects.all(), required=False ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False + ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() @@ -195,7 +199,7 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode class Meta: nullable_fields = [ - 'parent', 'mtu', 'description', + 'parent', 'bridge', 'mtu', 'description', ] def __init__(self, *args, **kwargs): @@ -203,8 +207,9 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode if 'virtual_machine' in self.initial: vm_id = self.initial.get('virtual_machine') - # Restrict parent interface assignment by VM + # Restrict parent/bridge interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) @@ -231,6 +236,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + self.fields['parent'].choices = () + self.fields['parent'].widget.attrs['disabled'] = True + self.fields['bridge'].choices = () + self.fields['bridge'].widget.attrs['disabled'] = True + class VMInterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index d01418aa0..bd3279959 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -104,6 +104,18 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): queryset=VirtualMachine.objects.all(), to_field_name='name' ) + parent = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) + bridge = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged interface' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -113,7 +125,7 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = VMInterface fields = ( - 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', ) def clean_enabled(self): diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 88ebc9e83..7fa5b0fa6 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -277,6 +277,11 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Bridged interface' + ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -306,8 +311,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) class Meta: model = VMInterface fields = [ - 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'tags', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), @@ -326,6 +331,7 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) # Restrict parent interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index b58fb51f8..332334594 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -35,6 +35,13 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo 'virtual_machine_id': '$virtual_machine', } ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine', + } + ) mac_address = forms.CharField( required=False, label='MAC Address' @@ -61,7 +68,7 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo required=False ) field_order = ( - 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode', + 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/virtualization/migrations/0026_vminterface_bridge.py b/netbox/virtualization/migrations/0026_vminterface_bridge.py new file mode 100644 index 000000000..04909c72c --- /dev/null +++ b/netbox/virtualization/migrations/0026_vminterface_bridge.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-21 20:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0025_extend_tag_support'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='virtualization.vminterface'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index bd64f56cf..c614618c0 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -378,14 +378,6 @@ class VMInterface(PrimaryModel, BaseInterface): max_length=200, blank=True ) - parent = models.ForeignKey( - to='self', - on_delete=models.SET_NULL, - related_name='child_interfaces', - null=True, - blank=True, - verbose_name='Parent interface' - ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -423,6 +415,12 @@ class VMInterface(PrimaryModel, BaseInterface): def clean(self): super().clean() + # Parent validation + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + # An interface's parent must belong to the same virtual machine if self.parent and self.parent.virtual_machine != self.virtual_machine: raise ValidationError({ @@ -430,15 +428,26 @@ class VMInterface(PrimaryModel, BaseInterface): f"({self.parent.virtual_machine})." }) - # An interface cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({'parent': "An interface cannot be its own parent."}) + # Bridge validation + + # An interface cannot be bridged to itself + if self.pk and self.bridge_id == self.pk: + raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) + + # A bridged interface belong to the same virtual machine + if self.bridge and self.bridge.virtual_machine != self.virtual_machine: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different virtual machine " + f"({self.bridge.virtual_machine})." + }) + + # VLAN validation # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: raise ValidationError({ 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " - f"interface's parent virtual machine, or it must be global" + f"interface's parent virtual machine, or it must be global." }) def to_objectchange(self, action): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 64b376e1d..56ad88f1f 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -166,9 +166,6 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) - parent = tables.Column( - linkify=True - ) tags = TagColumn( url_name='virtualization:vminterface_list' ) @@ -176,13 +173,19 @@ class VMInterfaceTable(BaseInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'virtual_machine', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'pk', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) - default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'parent', 'description') + default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') class VirtualMachineVMInterfaceTable(VMInterfaceTable): + parent = tables.Column( + linkify=True + ) + bridge = tables.Column( + linkify=True + ) actions = ButtonsColumn( model=VMInterface, buttons=('edit', 'delete'), @@ -192,8 +195,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', From 5193fa64838720e5d0ed4e67ec15868c16457353 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 16:57:01 -0400 Subject: [PATCH 057/289] Add tests for #6346 --- netbox/dcim/tests/test_api.py | 3 ++- netbox/dcim/tests/test_filtersets.py | 13 +++++++++++++ netbox/dcim/tests/test_views.py | 4 +++- netbox/virtualization/tests/test_api.py | 3 ++- netbox/virtualization/tests/test_filtersets.py | 13 +++++++++++++ netbox/virtualization/tests/test_views.py | 5 ++++- 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index e5977b760..b3f182ce7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1206,6 +1206,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'name': 'Interface 5', 'type': '1000base-t', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, @@ -1214,7 +1215,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'name': 'Interface 6', 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, - 'parent': interfaces[0].pk, + 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f66ceb855..51cfafaf2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2125,6 +2125,19 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = Interface.objects.first() + bridged_interfaces = ( + Interface(device=bridge_interface.device, name='Bridged 1', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 2', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 3', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_lag(self): # Create LAG members device = Device.objects.first() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c08eb6e8a..92757f28d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1581,6 +1581,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): Interface(device=device, name='Interface 2'), Interface(device=device, name='Interface 3'), Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG), + Interface(device=device, name='_BRIDGE', type=InterfaceTypeChoices.TYPE_VIRTUAL), # Must be ordered last ) Interface.objects.bulk_create(interfaces) @@ -1596,10 +1597,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.form_data = { 'device': device.pk, - 'virtual_machine': None, 'name': 'Interface X', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), @@ -1617,6 +1618,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'name_pattern': 'Interface [4-6]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 3245fb9bf..4a9b67bf0 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -246,14 +246,15 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'virtual_machine': virtualmachine.pk, 'name': 'Interface 5', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, { 'virtual_machine': virtualmachine.pk, 'name': 'Interface 6', - 'parent': interfaces[0].pk, 'mode': InterfaceModeChoices.MODE_TAGGED, + 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 0ca6364a5..a74ccc4d9 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -452,6 +452,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = VMInterface.objects.first() + bridged_interfaces = ( + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 1', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 2', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 3', bridge=bridge_interface), + ) + VMInterface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_mtu(self): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 138b1afae..7dc5660fd 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -248,10 +248,11 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VirtualMachine.objects.bulk_create(virtualmachines) - VMInterface.objects.bulk_create([ + interfaces = VMInterface.objects.bulk_create([ VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'), + VMInterface(virtual_machine=virtualmachines[1], name='BRIDGE'), ]) vlans = ( @@ -268,6 +269,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 65000, 'description': 'New description', @@ -281,6 +283,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name_pattern': 'Interface [4-6]', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 2000, 'description': 'New description', From e96f5447f42e72738575c705f039f61fde592587 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 17:03:21 -0400 Subject: [PATCH 058/289] Changelog for #6346 --- docs/release-notes/version-3.1.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 88142bf78..f586f43bb 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -7,6 +7,8 @@ * The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. +### New Features + #### Contacts ([#1344](https://github.com/netbox-community/netbox/issues/1344)) A set of new models for tracking contact information has been introduced within the tenancy app. Users may now create individual contact objects to be associated with various models within NetBox. Each contact has a name, title, email address, etc. Contacts can be arranged in hierarchical groups for ease of management. @@ -26,6 +28,12 @@ Both types of connection include SSID and authentication attributes. Additionall * Channel - A predefined channel within a standardized band * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) +#### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) + +A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. + +Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect. + ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces @@ -73,6 +81,9 @@ Both types of connection include SSID and authentication attributes. Additionall * dcim.DeviceType * Added `airflow` field * dcim.Interface + * Added `bridge` field * Added `wwn` field * dcim.Location * Added `tenant` field +* virtualization.VMInterface + * Added `bridge` field From 7e26d921901077522a6f0fa4c4755c26280a1a7d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 22 Oct 2021 16:27:08 -0400 Subject: [PATCH 059/289] Introduce conditions & condition sets --- netbox/extras/conditions.py | 122 +++++++++++++++++++ netbox/extras/tests/test_conditions.py | 160 +++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 netbox/extras/conditions.py create mode 100644 netbox/extras/tests/test_conditions.py diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py new file mode 100644 index 000000000..6aa6e776f --- /dev/null +++ b/netbox/extras/conditions.py @@ -0,0 +1,122 @@ +import functools + +__all__ = ( + 'Condition', + 'ConditionSet', +) + + +LOGIC_TYPES = ( + 'and', + 'or' +) + + +def is_ruleset(data): + """ + Determine whether the given dictionary looks like a rule set. + """ + return type(data) is dict and len(data) == 1 and list(data.keys())[0] in LOGIC_TYPES + + +class Condition: + """ + An individual conditional rule that evaluates a single attribute and its value. + + :param attr: The name of the attribute being evaluated + :param value: The value being compared + :param op: The logical operation to use when evaluating the value (default: 'eq') + """ + EQ = 'eq' + NEQ = 'neq' + GT = 'gt' + GTE = 'gte' + LT = 'lt' + LTE = 'lte' + IN = 'in' + CONTAINS = 'contains' + + OPERATORS = ( + EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS + ) + + def __init__(self, attr, value, op=EQ): + self.attr = attr + self.value = value + if op not in self.OPERATORS: + raise ValueError(f"Unknown operator: {op}") + self.eval_func = getattr(self, f'eval_{op}') + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches the condition. + """ + value = functools.reduce(dict.get, self.attr.split('.'), data) + return self.eval_func(value) + + # Equivalency + + def eval_eq(self, value): + return value == self.value + + def eval_neq(self, value): + return value != self.value + + # Numeric comparisons + + def eval_gt(self, value): + return value > self.value + + def eval_gte(self, value): + return value >= self.value + + def eval_lt(self, value): + return value < self.value + + def eval_lte(self, value): + return value <= self.value + + # Membership + + def eval_in(self, value): + return value in self.value + + def eval_contains(self, value): + return self.value in value + + +class ConditionSet: + """ + A set of one or more Condition to be evaluated per the prescribed logic (AND or OR). Example: + + {"and": [ + {"attr": "foo", "op": "eq", "value": 1}, + {"attr": "bar", "op": "neq", "value": 2} + ]} + + :param ruleset: A dictionary mapping a logical operator to a list of conditional rules + """ + def __init__(self, ruleset): + if type(ruleset) is not dict: + raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.") + if len(ruleset) != 1: + raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})") + + # Determine the logic type + logic = list(ruleset.keys())[0] + if type(logic) is not str or logic.lower() not in LOGIC_TYPES: + raise ValueError(f"Invalid logic type: {logic} (must be 'and' or 'or')") + self.logic = logic.lower() + + # Compile the set of Conditions + self.conditions = [ + ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) + for rule in ruleset[self.logic] + ] + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches this set of conditions. + """ + func = any if self.logic == 'or' else all + return func(d.eval(data) for d in self.conditions) diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py new file mode 100644 index 000000000..7defca5b5 --- /dev/null +++ b/netbox/extras/tests/test_conditions.py @@ -0,0 +1,160 @@ +from django.test import TestCase + +from extras.conditions import Condition, ConditionSet + + +class ConditionTestCase(TestCase): + + def test_dotted_path_access(self): + c = Condition('a.b.c', 1, 'eq') + self.assertTrue(c.eval({'a': {'b': {'c': 1}}})) + self.assertFalse(c.eval({'a': {'b': {'c': 2}}})) + self.assertFalse(c.eval({'a': {'b': {'x': 1}}})) + + def test_undefined_attr(self): + c = Condition('x', 1, 'eq') + self.assertFalse(c.eval({})) + self.assertTrue(c.eval({'x': 1})) + + # + # Operator tests + # + + def test_default_operator(self): + c = Condition('x', 1) + self.assertEqual(c.eval_func, c.eval_eq) + + def test_eq(self): + c = Condition('x', 1, 'eq') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_neq(self): + c = Condition('x', 1, 'neq') + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + + def test_gt(self): + c = Condition('x', 1, 'gt') + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 1})) + + def test_gte(self): + c = Condition('x', 1, 'gte') + self.assertTrue(c.eval({'x': 2})) + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 0})) + + def test_lt(self): + c = Condition('x', 2, 'lt') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_lte(self): + c = Condition('x', 2, 'lte') + self.assertTrue(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 3})) + + def test_in(self): + c = Condition('x', [1, 2, 3], 'in') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 9})) + + def test_contains(self): + c = Condition('x', 1, 'contains') + self.assertTrue(c.eval({'x': [1, 2, 3]})) + self.assertFalse(c.eval({'x': [2, 3, 4]})) + + +class ConditionSetTest(TestCase): + + def test_empty(self): + with self.assertRaises(ValueError): + ConditionSet({}) + + def test_invalid_logic(self): + with self.assertRaises(ValueError): + ConditionSet({'foo': []}) + + def test_and_single_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 2, 'op': 'eq'}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertFalse(cs.eval({'a': 1, 'b': 3})) + + def test_or_single_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq'}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertTrue(cs.eval({'a': 2, 'b': 1})) + self.assertFalse(cs.eval({'a': 2, 'b': 2})) + + def test_and_multi_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 2, 'c': 9})) + + def test_or_multi_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 9})) + + def test_mixed_and(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + + def test_mixed_or(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) From 78ecc8673ca71bc6db3dddf065ca4203bc224740 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 22 Oct 2021 17:15:08 -0400 Subject: [PATCH 060/289] Add conditions for webhooks --- netbox/extras/api/serializers.py | 2 +- netbox/extras/forms/bulk_edit.py | 2 +- netbox/extras/forms/models.py | 1 + .../migrations/0063_webhook_conditions.py | 18 +++++++++++++ netbox/extras/models/models.py | 18 ++++++++++--- netbox/extras/tests/test_views.py | 1 + netbox/extras/webhooks_worker.py | 27 ++++++++++--------- 7 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 netbox/extras/migrations/0063_webhook_conditions.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index b2049e836..46d295195 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer): 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', - 'ssl_verification', 'ca_file_path', + 'conditions', 'ssl_verification', 'ca_file_path', ] diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b85a74a5b..937814c5a 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -137,7 +137,7 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ['secret', 'ca_file_path'] + nullable_fields = ['secret', 'conditions', 'ca_file_path'] class TagBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 7e462e62b..23f4872c2 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -102,6 +102,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): ('HTTP Request', ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', )), + ('Conditions', ('conditions',)), ('SSL', ('ssl_verification', 'ca_file_path')), ) widgets = { diff --git a/netbox/extras/migrations/0063_webhook_conditions.py b/netbox/extras/migrations/0063_webhook_conditions.py new file mode 100644 index 000000000..8cc5b1bd3 --- /dev/null +++ b/netbox/extras/migrations/0063_webhook_conditions.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2021-10-22 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='conditions', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 75f5242d3..43af19f82 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -9,11 +9,12 @@ from django.db import models from django.http import HttpResponse from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format, time_format +from django.utils.formats import date_format from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * +from extras.conditions import ConditionSet from extras.utils import extras_features, FeatureQuery, image_upload from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet @@ -107,6 +108,11 @@ class Webhook(ChangeLoggedModel): "the secret as the key. The secret is not transmitted in " "the request." ) + conditions = models.JSONField( + blank=True, + null=True, + help_text="A set of conditions which determine whether the webhook will be generated." + ) ssl_verification = models.BooleanField( default=True, verbose_name='SSL verification', @@ -138,9 +144,13 @@ class Webhook(ChangeLoggedModel): # 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( - "You must select at least one type: create, update, and/or delete." - ) + raise ValidationError("At least one type must be selected: create, update, and/or delete.") + + if self.conditions: + try: + ConditionSet(self.conditions) + except ValueError as e: + raise ValidationError({'conditions': e}) # CA file path requires SSL verification enabled if not self.ssl_verification and self.ca_file_path: diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 72d965fd0..9ce324a5c 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -145,6 +145,7 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'payload_url': 'http://example.com/?x', 'http_method': 'GET', 'http_content_type': 'application/foo', + 'conditions': None, } cls.csv_data = ( diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index ce63e14ce..6bbfba907 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -6,6 +6,7 @@ from django_rq import job from jinja2.exceptions import TemplateError from .choices import ObjectChangeActionChoices +from .conditions import ConditionSet from .webhooks import generate_signature logger = logging.getLogger('netbox.webhooks_worker') @@ -16,6 +17,12 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user """ Make a POST request to the defined Webhook """ + # Evaluate webhook conditions (if any) + if webhook.conditions: + if not ConditionSet(webhook.conditions).eval(data): + return + + # Prepare context data for headers & body templates context = { 'event': dict(ObjectChangeActionChoices)[event].lower(), 'timestamp': timestamp, @@ -33,14 +40,14 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user try: headers.update(webhook.render_headers(context)) except (TemplateError, ValueError) as e: - logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e)) + logger.error(f"Error parsing HTTP headers for webhook {webhook}: {e}") raise e # Render the request body try: body = webhook.render_body(context) except TemplateError as e: - logger.error("Error rendering request body for webhook {}: {}".format(webhook, e)) + logger.error(f"Error rendering request body for webhook {webhook}: {e}") raise e # Prepare the HTTP request @@ -51,15 +58,13 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user 'data': body.encode('utf8'), } logger.info( - "Sending {} request to {} ({} {})".format( - params['method'], params['url'], context['model'], context['event'] - ) + f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})" ) logger.debug(params) try: prepared_request = requests.Request(**params).prepare() except requests.exceptions.RequestException as e: - logger.error("Error forming HTTP request: {}".format(e)) + logger.error(f"Error forming HTTP request: {e}") raise e # If a secret key is defined, sign the request with a hash of the key and its content @@ -74,12 +79,10 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user response = session.send(prepared_request, proxies=settings.HTTP_PROXIES) if 200 <= response.status_code <= 299: - logger.info("Request succeeded; response status {}".format(response.status_code)) - return 'Status {} returned, webhook successfully processed.'.format(response.status_code) + logger.info(f"Request succeeded; response status {response.status_code}") + return f"Status {response.status_code} returned, webhook successfully processed." else: - logger.warning("Request failed; response status {}: {}".format(response.status_code, response.content)) + logger.warning(f"Request failed; response status {response.status_code}: {response.content}") raise requests.exceptions.RequestException( - "Status {} returned with content '{}', webhook FAILED to process.".format( - response.status_code, response.content - ) + f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process." ) From 94bd27bcf5e63283164e55b9c1a330a88ed667ed Mon Sep 17 00:00:00 2001 From: Miguel Teixeira <816267+CironAkono@users.noreply.github.com> Date: Sun, 24 Oct 2021 03:24:54 +0100 Subject: [PATCH 061/289] Fix interface icons on the device interfaces table --- netbox/dcim/tables/devices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c22e673b7..8939c01be 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -501,8 +501,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable class DeviceInterfaceTable(InterfaceTable): name = tables.TemplateColumn( - template_code=' {{ value }}', order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} From b07e88869aa382fa45643470f05bd6f7abbb1780 Mon Sep 17 00:00:00 2001 From: Miguel Teixeira <816267+CironAkono@users.noreply.github.com> Date: Sun, 24 Oct 2021 03:31:29 +0100 Subject: [PATCH 062/289] Fix interfaces row colors on device interfaces table --- netbox/dcim/tables/devices.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8939c01be..512022679 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -53,6 +53,16 @@ def get_cabletermination_row_class(record): return '' +def get_interface_row_class(record): + if not record.enabled: + return 'danger' + elif not record.is_connectable: + return 'primary' + else: + return get_cabletermination_row_class(record) + return '' + + def get_interface_state_attribute(record): """ Get interface enabled state as string to attach to DOM element. @@ -534,7 +544,7 @@ class DeviceInterfaceTable(InterfaceTable): 'cable', 'connection', 'actions', ) row_attrs = { - 'class': get_cabletermination_row_class, + 'class': get_interface_row_class, 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, } From a01068949c54c2ec6f428245f9cc68f400075e09 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sun, 24 Oct 2021 23:42:47 -0500 Subject: [PATCH 063/289] Initial work on #6732 --- netbox/dcim/api/serializers.py | 6 ++- netbox/dcim/api/views.py | 3 +- netbox/dcim/filtersets.py | 16 ++++++- netbox/dcim/forms/bulk_edit.py | 15 +++---- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 10 ++++- netbox/dcim/forms/models.py | 13 ++++-- netbox/dcim/models/sites.py | 8 +--- netbox/dcim/tables/sites.py | 13 ++++-- netbox/dcim/views.py | 3 +- netbox/ipam/api/nested_serializers.py | 13 ++++++ netbox/ipam/api/serializers.py | 18 ++++++++ netbox/ipam/api/urls.py | 3 ++ netbox/ipam/api/views.py | 12 +++++ netbox/ipam/filtersets.py | 39 ++++++++++++++++ netbox/ipam/forms/bulk_edit.py | 36 ++++++++++++++- netbox/ipam/forms/bulk_import.py | 27 +++++++++++ netbox/ipam/forms/filtersets.py | 30 +++++++++++++ netbox/ipam/forms/models.py | 26 +++++++++++ netbox/ipam/models/__init__.py | 1 + netbox/ipam/models/ip.py | 48 ++++++++++++++++++++ netbox/ipam/tables/ip.py | 25 +++++++++++ netbox/ipam/tests/test_api.py | 32 ++++++++++++++ netbox/ipam/urls.py | 12 +++++ netbox/ipam/views.py | 63 +++++++++++++++++++++++++- netbox/netbox/filtersets.py | 1 - netbox/netbox/navigation_menu.py | 6 +++ netbox/templates/dcim/site.html | 8 ++-- netbox/templates/ipam/asn.html | 64 +++++++++++++++++++++++++++ 29 files changed, 515 insertions(+), 38 deletions(-) create mode 100644 netbox/templates/ipam/asn.html diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9b0e7f5b3..3c4021e72 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -116,16 +116,18 @@ class SiteSerializer(PrimaryModelSerializer): device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True) + asn_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Site fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asns', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', + 'asn_count', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', + 'vlan_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2b9d9734c..0f39c5434 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -16,7 +16,7 @@ from circuits.models import Circuit from dcim import filtersets from dcim.models import * from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet -from ipam.models import Prefix, VLAN +from ipam.models import Prefix, VLAN, ASN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata @@ -139,6 +139,7 @@ class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.prefetch_related( 'region', 'tenant', 'tags' ).annotate( + asn_count=count_related(ASN, 'sites'), device_count=count_related(Device, 'site'), rack_count=count_related(Rack, 'site'), prefix_count=count_related(Prefix, 'site'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c66397029..82396b64b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet +from ipam.models import ASN from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, ) @@ -127,12 +128,23 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Group (slug)', ) + asn_id = django_filters.ModelMultipleChoiceFilter( + field_name='asns', + queryset=ASN.objects.all(), + label='AS (ID)', + ) + asn = django_filters.ModelMultipleChoiceFilter( + field_name='asns__asn', + queryset=ASN.objects.all(), + to_field_name='asn', + label='AS (Number)', + ) tag = TagFilter() class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] @@ -151,7 +163,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): Q(comments__icontains=value) ) try: - qs_filter |= Q(asn=int(value.strip())) + qs_filter |= Q(asns=int(value.strip())) except ValueError: pass return queryset.filter(qs_filter) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 06ccc958c..9728f231f 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from django.contrib.auth.models import User from timezone_field import TimeZoneFormField @@ -6,8 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN -from ipam.models import VLAN +from ipam.models import VLAN, ASN from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, @@ -110,11 +110,10 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd queryset=Tenant.objects.all(), required=False ) - asn = forms.IntegerField( - min_value=BGP_ASN_MIN, - max_value=BGP_ASN_MAX, - required=False, - label='ASN' + asns = DynamicModelChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False ) description = forms.CharField( max_length=100, @@ -128,7 +127,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd class Meta: nullable_fields = [ - 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 720ea8dbd..10898fb81 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -94,7 +94,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): class Meta: model = Site fields = ( - 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', + 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0ee08bc77..24ecf60fe 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -6,6 +6,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm +from ipam.models import ASN from tenancy.forms import TenancyFilterForm from utilities.forms import ( APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, @@ -143,11 +144,12 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): model = Site - field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id'] + field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asns'] field_groups = [ ['q', 'tag'], ['status', 'region_id', 'group_id'], ['tenant_group_id', 'tenant_id'], + ['asn_id'] ] q = forms.CharField( required=False, @@ -171,6 +173,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo label=_('Site group'), fetch_trigger='open' ) + asn_id = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + required=False, + label=_('ASNs'), + fetch_trigger='open' + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 8236b1a97..9703c4cac 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from timezone_field import TimeZoneFormField @@ -8,7 +9,7 @@ from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelForm from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup +from ipam.models import IPAddress, VLAN, VLANGroup, ASN from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, @@ -101,6 +102,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=SiteGroup.objects.all(), required=False ) + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False + ) slug = SlugField() time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), @@ -116,13 +122,13 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Site fields = [ - 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', ] fieldsets = ( ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags', + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ('Contact Info', ( @@ -147,7 +153,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): help_texts = { 'name': "Full name of the site", 'facility': "Data center provider and facility (e.g. Equinix NY7)", - 'asn': "BGP autonomous system number", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", 'physical_address': "Physical location of the building (e.g. for GPS)", diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index ab9d8e82d..a093a4d84 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -189,12 +189,6 @@ class Site(PrimaryModel): blank=True, help_text='Local facility ID or description' ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) time_zone = TimeZoneField( blank=True ) @@ -257,7 +251,7 @@ class Site(PrimaryModel): objects = RestrictedQuerySet.as_manager() clone_fields = [ - 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', + 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 3ff6ab75b..ab9399978 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -75,6 +75,11 @@ class SiteTable(BaseTable): group = tables.Column( linkify=True ) + asn_count = LinkedCountColumn( + viewname='ipam:asn_list', + url_params={'site_id': 'pk'}, + verbose_name='ASNs' + ) tenant = TenantColumn() comments = MarkdownColumn() tags = TagColumn( @@ -84,11 +89,11 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site fields = ( - 'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', + 'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'description') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5079e01a5..e188ecfe5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,7 +14,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView -from ipam.models import IPAddress, Prefix, Service, VLAN +from ipam.models import IPAddress, Prefix, Service, VLAN, ASN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm @@ -310,6 +310,7 @@ class SiteView(generic.ObjectView): def get_extra_context(self, request, instance): stats = { + 'asn_count': ASN.objects.restrict(request.user, 'view').filter(sites=instance).count(), 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index a52a6a03c..da679a01a 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -5,6 +5,7 @@ from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', + 'NestedASNSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', 'NestedPrefixSerializer', @@ -18,6 +19,18 @@ __all__ = [ ] +# +# ASNs +# + +class NestedASNSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + + class Meta: + model = models.ASN + fields = ['id', 'url', 'display', 'asn'] + + # # VRFs # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 183c45b2a..02e209241 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -17,6 +17,24 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * +# +# ASNs +# +from ..models import ASN + + +class ASNSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = ASN + fields = [ + 'id', 'url', 'display', 'asn', 'site_count', 'rir', 'tenant', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + # # VRFs # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 06c4ab0ea..b05fcb303 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -5,6 +5,9 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.IPAMRootView +# ASNs +router.register('asns', views.ASNViewSet) + # VRFs router.register('vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69b6d97f0..18f2e13ce 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,11 +1,13 @@ from rest_framework.routers import APIRootView +from dcim.models import Site from extras.api.views import CustomFieldModelViewSet from ipam import filtersets from ipam.models import * from netbox.api.views import ModelViewSet from utilities.utils import count_related from . import mixins, serializers +from ..models import ASN class IPAMRootView(APIRootView): @@ -16,6 +18,16 @@ class IPAMRootView(APIRootView): return 'IPAM' +# +# ASNs +# + +class ASNViewSet(CustomFieldModelViewSet): + queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns')) + serializer_class = serializers.ASNSerializer + filterset_class = filtersets.ASNFilterSet + + # # VRFs # diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 37a9299dc..025f4a9cb 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -9,6 +9,7 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import TagFilter from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet +from tenancy.models import Tenant from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) @@ -19,6 +20,7 @@ from .models import * __all__ = ( 'AggregateFilterSet', + 'ASNFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', 'PrefixFilterSet', @@ -31,6 +33,8 @@ __all__ = ( 'VRFFilterSet', ) +from .models import ASN + class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( @@ -174,6 +178,41 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.none() +class ASNFilterSet(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)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='sites', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='sites__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = ASN + fields = ['id', 'asn'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(Q(description__icontains=value) | Q(asn__icontains=value)) + return queryset.filter(qs_filter) + + class RoleFilterSet(OrganizationalModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 895dbe200..7b7a0fb0d 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -5,14 +5,16 @@ from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, - StaticSelect, + StaticSelect, DynamicModelMultipleChoiceField, ) __all__ = ( 'AggregateBulkEditForm', + 'ASNBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', 'PrefixBulkEditForm', @@ -89,6 +91,38 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['is_private', 'description'] +class ASNBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ASN.objects.all(), + widget=forms.MultipleHiddenInput() + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False + ) + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + required=False, + label='RIR' + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'date_added', 'description', + ] + widgets = { + 'date_added': DatePicker(), + } + + class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Aggregate.objects.all(), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 49d5014f9..e4190a66c 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -6,12 +6,14 @@ from extras.forms import CustomFieldModelCSVForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from virtualization.models import VirtualMachine, VMInterface __all__ = ( 'AggregateCSVForm', + 'ASNCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', 'PrefixCSVForm', @@ -80,6 +82,31 @@ class AggregateCSVForm(CustomFieldModelCSVForm): fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') +class ASNCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + rir = CSVModelChoiceField( + queryset=RIR.objects.all(), + to_field_name='name', + help_text='Assigned RIR' + ) + sites = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Assigned site' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = ASN + fields = ('asn', 'rir', 'tenant', 'description') + help_texts = {} + + class RoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 8bc0f10fb..ab084311c 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -6,7 +6,9 @@ from extras.forms import CustomFieldModelFilterForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.forms import TenancyFilterForm +from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -14,6 +16,7 @@ from utilities.forms import ( __all__ = ( 'AggregateFilterForm', + 'ASNFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', 'PrefixFilterForm', @@ -136,6 +139,33 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil tag = TagFilterField(model) +class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = ASN + field_groups = [ + ['q'], + ['rir_id'], + ['tenant_group_id', 'tenant_id'], + ['site_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + rir_id = DynamicModelMultipleChoiceField( + queryset=RIR.objects.all(), + required=False, + label=_('RIR'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + label=_('Site'), + fetch_trigger='open' + ) + + class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Role field_groups = [ diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d28f7b3ae..a0163a13f 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -6,6 +6,7 @@ from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.forms import TenancyForm from utilities.forms import ( BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -15,6 +16,7 @@ from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInter __all__ = ( 'AggregateForm', + 'ASNForm', 'IPAddressAssignForm', 'IPAddressBulkAddForm', 'IPAddressForm', @@ -118,6 +120,30 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } +class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + label='RIR', + ) + + class Meta: + model = ASN + fields = [ + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description' + ] + fieldsets = ( + ('ASN', ('asn', 'rir', 'sites', 'description')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + help_texts = { + 'asn': "AS number", + 'rir': "Regional Internet Registry responsible for this prefix", + } + widgets = { + 'date_added': DatePicker(), + } + + class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index cb8b4b932..0f65e6652 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -4,6 +4,7 @@ from .vlans import * from .vrfs import * __all__ = ( + 'ASN', 'Aggregate', 'IPAddress', 'IPRange', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4fc2b5dbb..45baf8258 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -8,6 +8,7 @@ from django.db.models import F, Q from django.urls import reverse from django.utils.functional import cached_property +from dcim.fields import ASNField from dcim.models import Device from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel @@ -23,6 +24,7 @@ from virtualization.models import VirtualMachine __all__ = ( 'Aggregate', + 'ASN', 'IPAddress', 'IPRange', 'Prefix', @@ -69,6 +71,52 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) +class ASN(PrimaryModel): + + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN', + help_text='32-bit autonomous system number' + ) + description = models.CharField( + max_length=200, + blank=True + ) + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='asns', + blank=False, + null=False + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='asns', + blank=True, + null=True + ) + sites = models.ManyToManyField( + to='dcim.Site', + related_name='asns', + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['asn'] + verbose_name = 'ASN' + verbose_name_plural = 'ASNs' + + def __str__(self): + return f'AS{self.asn}' + + def get_absolute_url(self): + return reverse('ipam:asn', args=[self.pk]) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(PrimaryModel): """ diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index ddad6c573..e624f6f13 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -2,6 +2,7 @@ import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor +from ipam.models import ASN from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, @@ -11,6 +12,7 @@ from ipam.models import * __all__ = ( 'AggregateTable', + 'ASNTable', 'InterfaceIPAddressTable', 'IPAddressAssignTable', 'IPAddressTable', @@ -93,6 +95,29 @@ class RIRTable(BaseTable): default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') +# +# RIRs +# + +class ASNTable(BaseTable): + pk = ToggleColumn() + asn = tables.Column( + linkify=True + ) + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'asn_id': 'pk'}, + verbose_name='Sites' + ) + + actions = ButtonsColumn(ASN) + + class Meta(BaseTable.Meta): + model = ASN + fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') + default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions') + + # # Aggregates # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5ba45b7fd..5229d3430 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -20,6 +20,38 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class ASNTest(APIViewTestCases.APIViewTestCase): + model = ASN + brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] + create_data = [ + { + 'name': 'VRF 4', + 'rd': '65000:4', + }, + { + 'name': 'VRF 5', + 'rd': '65000:5', + }, + { + 'name': 'VRF 6', + 'rd': '65000:6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + VRF(name='VRF 3'), # No RD + ) + VRF.objects.bulk_create(vrfs) + + class VRFTest(APIViewTestCases.APIViewTestCase): model = VRF brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 9d9a846bf..88c5d7c9e 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -7,6 +7,18 @@ from .models import * app_name = 'ipam' urlpatterns = [ + # ASNs + path('asns/', views.ASNListView.as_view(), name='asn_list'), + path('asns/add/', views.ASNEditView.as_view(), name='asn_add'), + path('asns/import/', views.ASNBulkImportView.as_view(), name='asn_import'), + path('asns/edit/', views.ASNBulkEditView.as_view(), name='asn_bulk_edit'), + path('asns/delete/', views.ASNBulkDeleteView.as_view(), name='asn_bulk_delete'), + path('asns//', views.ASNView.as_view(), name='asn'), + path('asns//edit/', views.ASNEditView.as_view(), name='asn_edit'), + path('asns//delete/', views.ASNDeleteView.as_view(), name='asn_delete'), + path('asns//changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}), + path('asns//journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}), + # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c24a80124..73b228ac4 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -2,7 +2,8 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render -from dcim.models import Device, Interface +from dcim.models import Device, Interface, Site +from dcim.tables import SiteTable from netbox.views import generic from utilities.forms import TableConfigForm from utilities.tables import paginate_table @@ -11,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface from . import filtersets, forms, tables from .constants import * from .models import * +from .models import ASN from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans @@ -195,6 +197,65 @@ class RIRBulkDeleteView(generic.BulkDeleteView): table = tables.RIRTable +# +# ASNs +# + +class ASNListView(generic.ObjectListView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns'), + ) + filterset = filtersets.ASNFilterSet + filterset_form = forms.ASNFilterForm + table = tables.ASNTable + + +class ASNView(generic.ObjectView): + queryset = ASN.objects.all() + + def get_extra_context(self, request, instance): + sites_table = SiteTable( + list(instance.sites.all()), + orderable=False + ) + + return { + 'sites_table': sites_table, + } + + +class ASNEditView(generic.ObjectEditView): + queryset = ASN.objects.all() + model_form = forms.ASNForm + + +class ASNDeleteView(generic.ObjectDeleteView): + queryset = ASN.objects.all() + + +class ASNBulkImportView(generic.BulkImportView): + queryset = ASN.objects.all() + model_form = forms.ASNCSVForm + table = tables.ASNTable + + +class ASNBulkEditView(generic.BulkEditView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNFilterSet + table = tables.ASNTable + form = forms.ASNBulkEditForm + + +class ASNBulkDeleteView(generic.BulkDeleteView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNFilterSet + table = tables.ASNTable + + # # Aggregates # diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 791c21d19..2240ce58d 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -153,7 +153,6 @@ class BaseFilterSet(django_filters.FilterSet): # The filter field has been explicity defined on the filterset class so we must manually # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field - resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid new_filter = type(existing_filter)( field_name=field_name, lookup_expr=lookup_expr, diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index de2c170a3..4b1e2a5b5 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -214,6 +214,12 @@ IPAM_MENU = Menu( get_model_item('ipam', 'role', 'Prefix & VLAN Roles'), ), ), + MenuGroup( + label='ASNs', + items=( + get_model_item('ipam', 'asn', 'ASNs'), + ), + ), MenuGroup( label='Aggregates', items=( diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 8442ae41e..55cc57b50 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -80,10 +80,6 @@ Description {{ object.description|placeholder }} - - AS Number - {{ object.asn|placeholder }} - Time Zone @@ -216,6 +212,10 @@

{{ stats.vm_count }}

Virtual Machines

+ diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html new file mode 100644 index 000000000..8be09c660 --- /dev/null +++ b/netbox/templates/ipam/asn.html @@ -0,0 +1,64 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+
+
+ ASN +
+
+ + + + + + + + + + + + + + + + + +
AS Number{{ object.asn }}
RIR + {{ object.rir }} +
Tenant + {% if object.tenant %} + {% if prefix.object.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% plugin_right_page object %} +
+
+
+
+ {% include 'inc/panel_table.html' with table=sites_table heading='Sites' %} + {% plugin_full_width_page object %} +
+
+{% endblock %} From 8b529abfe10f1a05b5449a964118a8608f96b750 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sun, 24 Oct 2021 23:47:31 -0500 Subject: [PATCH 064/289] Initial work on #6732 --- .../dcim/migrations/0138_remove_site_asn.py | 18 +++++++++ netbox/ipam/migrations/0051_asn_model.py | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 netbox/dcim/migrations/0138_remove_site_asn.py create mode 100644 netbox/ipam/migrations/0051_asn_model.py diff --git a/netbox/dcim/migrations/0138_remove_site_asn.py b/netbox/dcim/migrations/0138_remove_site_asn.py new file mode 100644 index 000000000..a4100ea14 --- /dev/null +++ b/netbox/dcim/migrations/0138_remove_site_asn.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2021-10-25 04:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0137_relax_uniqueness_constraints'), + ('ipam', '0051_asn_model') + ] + + operations = [ + migrations.RemoveField( + model_name='site', + name='asn', + ), + ] diff --git a/netbox/ipam/migrations/0051_asn_model.py b/netbox/ipam/migrations/0051_asn_model.py new file mode 100644 index 000000000..b397532ea --- /dev/null +++ b/netbox/ipam/migrations/0051_asn_model.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.8 on 2021-10-25 04:34 + +import dcim.fields +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('tenancy', '0003_contacts'), + ('dcim', '0137_relax_uniqueness_constraints'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.CreateModel( + name='ASN', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('asn', dcim.fields.ASNField(blank=True, null=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')), + ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), + ('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='asns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'ASN', + 'verbose_name_plural': 'ASNs', + 'ordering': ['asn'], + }, + ), + ] From b92de63245e186b54b8507e11804ffc1e275ce42 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 08:56:20 -0400 Subject: [PATCH 065/289] Improve validation --- netbox/extras/conditions.py | 17 +++++++++++++++-- netbox/extras/tests/test_conditions.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 6aa6e776f..050d5564c 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -40,11 +40,24 @@ class Condition: EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS ) + TYPES = { + str: (EQ, NEQ, CONTAINS), + bool: (EQ, NEQ, CONTAINS), + int: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), + float: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), + list: (EQ, NEQ, IN, CONTAINS) + } + def __init__(self, attr, value, op=EQ): + if op not in self.OPERATORS: + raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") + if type(value) not in self.TYPES: + raise ValueError(f"Unsupported value type: {type(value)}") + if op not in self.TYPES[type(value)]: + raise ValueError(f"Invalid type for {op} operation: {type(value)}") + self.attr = attr self.value = value - if op not in self.OPERATORS: - raise ValueError(f"Unknown operator: {op}") self.eval_func = getattr(self, f'eval_{op}') def eval(self, data): diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 7defca5b5..2ce55c064 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -16,6 +16,25 @@ class ConditionTestCase(TestCase): self.assertFalse(c.eval({})) self.assertTrue(c.eval({'x': 1})) + # + # Validation tests + # + + def test_invalid_op(self): + with self.assertRaises(ValueError): + # 'blah' is not a valid operator + Condition('x', 1, 'blah') + + def test_invalid_type(self): + with self.assertRaises(ValueError): + # dict type is unsupported + Condition('x', 1, dict()) + + def test_invalid_op_type(self): + with self.assertRaises(ValueError): + # 'gt' supports only numeric values + Condition('x', 'foo', 'gt') + # # Operator tests # From 35c967e6f72ebdc4edb3be7d3737aa0912469688 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 09:09:51 -0400 Subject: [PATCH 066/289] Implement condition negation --- netbox/extras/conditions.py | 36 ++++++++++++++------------ netbox/extras/tests/test_conditions.py | 18 ++++++++++--- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 050d5564c..7f1d804e8 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -6,17 +6,15 @@ __all__ = ( ) -LOGIC_TYPES = ( - 'and', - 'or' -) +AND = 'and' +OR = 'or' def is_ruleset(data): """ Determine whether the given dictionary looks like a rule set. """ - return type(data) is dict and len(data) == 1 and list(data.keys())[0] in LOGIC_TYPES + return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR) class Condition: @@ -28,7 +26,6 @@ class Condition: :param op: The logical operation to use when evaluating the value (default: 'eq') """ EQ = 'eq' - NEQ = 'neq' GT = 'gt' GTE = 'gte' LT = 'lt' @@ -37,18 +34,18 @@ class Condition: CONTAINS = 'contains' OPERATORS = ( - EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS + EQ, GT, GTE, LT, LTE, IN, CONTAINS ) TYPES = { - str: (EQ, NEQ, CONTAINS), - bool: (EQ, NEQ, CONTAINS), - int: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), - float: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), - list: (EQ, NEQ, IN, CONTAINS) + str: (EQ, CONTAINS), + bool: (EQ, CONTAINS), + int: (EQ, GT, GTE, LT, LTE, CONTAINS), + float: (EQ, GT, GTE, LT, LTE, CONTAINS), + list: (EQ, IN, CONTAINS) } - def __init__(self, attr, value, op=EQ): + def __init__(self, attr, value, op=EQ, negate=False): if op not in self.OPERATORS: raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") if type(value) not in self.TYPES: @@ -59,13 +56,18 @@ class Condition: self.attr = attr self.value = value self.eval_func = getattr(self, f'eval_{op}') + self.negate = negate def eval(self, data): """ Evaluate the provided data to determine whether it matches the condition. """ value = functools.reduce(dict.get, self.attr.split('.'), data) - return self.eval_func(value) + result = self.eval_func(value) + + if self.negate: + return not result + return result # Equivalency @@ -104,7 +106,7 @@ class ConditionSet: {"and": [ {"attr": "foo", "op": "eq", "value": 1}, - {"attr": "bar", "op": "neq", "value": 2} + {"attr": "bar", "op": "eq", "value": 2, "negate": true} ]} :param ruleset: A dictionary mapping a logical operator to a list of conditional rules @@ -117,8 +119,8 @@ class ConditionSet: # Determine the logic type logic = list(ruleset.keys())[0] - if type(logic) is not str or logic.lower() not in LOGIC_TYPES: - raise ValueError(f"Invalid logic type: {logic} (must be 'and' or 'or')") + if type(logic) is not str or logic.lower() not in (AND, OR): + raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')") self.logic = logic.lower() # Compile the set of Conditions diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 2ce55c064..47ae0b662 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -48,8 +48,8 @@ class ConditionTestCase(TestCase): self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 2})) - def test_neq(self): - c = Condition('x', 1, 'neq') + def test_eq_negated(self): + c = Condition('x', 1, 'eq', negate=True) self.assertFalse(c.eval({'x': 1})) self.assertTrue(c.eval({'x': 2})) @@ -80,11 +80,21 @@ class ConditionTestCase(TestCase): self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 9})) + def test_in_negated(self): + c = Condition('x', [1, 2, 3], 'in', negate=True) + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 9})) + def test_contains(self): c = Condition('x', 1, 'contains') self.assertTrue(c.eval({'x': [1, 2, 3]})) self.assertFalse(c.eval({'x': [2, 3, 4]})) + def test_contains_negated(self): + c = Condition('x', 1, 'contains', negate=True) + self.assertFalse(c.eval({'x': [1, 2, 3]})) + self.assertTrue(c.eval({'x': [2, 3, 4]})) + class ConditionSetTest(TestCase): @@ -100,11 +110,11 @@ class ConditionSetTest(TestCase): cs = ConditionSet({ 'and': [ {'attr': 'a', 'value': 1, 'op': 'eq'}, - {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq', 'negate': True}, ] }) self.assertTrue(cs.eval({'a': 1, 'b': 2})) - self.assertFalse(cs.eval({'a': 1, 'b': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 1})) def test_or_single_depth(self): cs = ConditionSet({ From 2423e0872ff5e1953b4647a8617954870ec39064 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 09:52:08 -0400 Subject: [PATCH 067/289] Documentation & changelog for #6238 --- docs/models/extras/webhook.md | 14 +++++ docs/reference/conditions.md | 89 +++++++++++++++++++++++++++++++ docs/release-notes/version-3.1.md | 16 ++++++ mkdocs.yml | 2 + 4 files changed, 121 insertions(+) create mode 100644 docs/reference/conditions.md diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index ee5e9d059..c71657336 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -17,6 +17,7 @@ A webhook is a mechanism for conveying to some external system a change that too * **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). * **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) * **Secret** - 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** - An optional set of conditions evaluated to determine whether the webhook fires for a given object. * **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!) * **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional). @@ -80,3 +81,16 @@ If no body template is specified, the request body will be populated with a JSON } } ``` + +## Conditional Webhooks + +A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active": + +```json +{ + "attr": "status", + "value": "active" +} +``` + +For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md). diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md new file mode 100644 index 000000000..c335bf9a8 --- /dev/null +++ b/docs/reference/conditions.md @@ -0,0 +1,89 @@ +# Conditions + +Conditions are NetBox's mechanism for evaluating whether a set data meets a prescribed set of conditions. It allows the author to convey simple logic by declaring an arbitrary number of attribute-value-operation tuples nested within a hierarchy of logical AND and OR statements. + +## Conditions + +A condition is expressed as a JSON object with the following keys: + +| Key name | Required | Default | Description | +|----------|----------|---------|-------------| +| attr | Yes | - | Name of the key within the data being evaluated | +| value | Yes | - | The reference value to which the given data will be compared | +| op | No | `eq` | The logical operation to be performed | +| negate | No | False | Negate (invert) the result of the condition's evaluation | + +### Available Operations + +* `eq`: Equals +* `gt`: Greater than +* `gte`: Greater than or equal to +* `lt`: Less than +* `lte`: Less than or equal to +* `in`: Is present within a list of values +* `contains`: Contains the specified value + +### Examples + +`name` equals "foobar": + +```json +{ + "attr": "name", + "value": "foobar" +} +``` + +`asn` is greater than 65000: + +```json +{ + "attr": "asn", + "value": 65000, + "op": "gt" +} +``` + +`status` is not "planned" or "staging": + +```json +{ + "attr": "status", + "value": ["planned", "staging"], + "op": "in", + "negate": true +} +``` + +## Condition Sets + +Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets. + +### Examples + +`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied. + +```json +{ + "or": [ + { + "and": [ + { + "attr": "status", + "value": "active" + }, + { + "attr": "primary_ip", + "value": "", + "negate": true + } + ] + }, + { + "attr": "tags", + "value": "exempt", + "op": "contains" + } + ] +} +``` diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index f586f43bb..b047e1320 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -28,6 +28,20 @@ Both types of connection include SSID and authentication attributes. Additionall * Channel - A predefined channel within a standardized band * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) +#### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) + +Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON: + +```json +{ + "attr": "status", + "op": "in", + "value": ["active", "staged"] +} +``` + +Multiple conditions may be nested using AND/OR logic as well. For more information, please see the [conditional logic documentation](../reference/conditions.md). + #### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. @@ -85,5 +99,7 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * Added `wwn` field * dcim.Location * Added `tenant` field +* extras.Webhook + * Added the `conditions` field * virtualization.VMInterface * Added `bridge` field diff --git a/mkdocs.yml b/mkdocs.yml index 001808f0d..9d9bb964a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,8 @@ nav: - Authentication: 'rest-api/authentication.md' - GraphQL API: - Overview: 'graphql-api/overview.md' + - Reference: + - Conditions: 'reference/conditions.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' From 0d84338e28c4a34036b168dfcdb075b0bf091449 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 10:14:18 -0400 Subject: [PATCH 068/289] Add regex condition op --- netbox/extras/conditions.py | 11 +++++++++-- netbox/extras/tests/test_conditions.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 7f1d804e8..6f1b012eb 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -1,4 +1,5 @@ import functools +import re __all__ = ( 'Condition', @@ -32,13 +33,14 @@ class Condition: LTE = 'lte' IN = 'in' CONTAINS = 'contains' + REGEX = 'regex' OPERATORS = ( - EQ, GT, GTE, LT, LTE, IN, CONTAINS + EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX ) TYPES = { - str: (EQ, CONTAINS), + str: (EQ, CONTAINS, REGEX), bool: (EQ, CONTAINS), int: (EQ, GT, GTE, LT, LTE, CONTAINS), float: (EQ, GT, GTE, LT, LTE, CONTAINS), @@ -99,6 +101,11 @@ class Condition: def eval_contains(self, value): return self.value in value + # Regular expressions + + def eval_regex(self, value): + return re.match(self.value, value) is not None + class ConditionSet: """ diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 47ae0b662..ee6afeaf6 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -95,6 +95,16 @@ class ConditionTestCase(TestCase): self.assertFalse(c.eval({'x': [1, 2, 3]})) self.assertTrue(c.eval({'x': [2, 3, 4]})) + def test_regex(self): + c = Condition('x', '[a-z]+', 'regex') + self.assertTrue(c.eval({'x': 'abc'})) + self.assertFalse(c.eval({'x': '123'})) + + def test_regex_negated(self): + c = Condition('x', '[a-z]+', 'regex', negate=True) + self.assertFalse(c.eval({'x': 'abc'})) + self.assertTrue(c.eval({'x': '123'})) + class ConditionSetTest(TestCase): From 68081fb9a28619698339b80d431aa2329ddaac22 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 11:07:15 -0400 Subject: [PATCH 069/289] Cleanup & API changelog for #3979 --- docs/release-notes/version-3.1.md | 11 +++++++++++ netbox/dcim/api/serializers.py | 9 ++++++--- netbox/wireless/api/urls.py | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index b047e1320..ef7500e1e 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ ### Breaking Changes * The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. +* The `cable_peer` and `cable_peer_type` attributes of the interface model has been renamed to `link_peer` and `link_peer_type`, respectively, to accommodate wireless links. ### New Features @@ -71,6 +72,10 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * `/api/tenancy/contact-groups/` * `/api/tenancy/contact-roles/` * `/api/tenancy/contacts/` +* Added the following endpoints for wireless networks: + * `/api/wireless/wireless-lans/` + * `/api/wireless/wireless-lan-groups/` + * `/api/wireless/wireless-links/` * Added `tags` field to the following models: * circuits.CircuitType * dcim.DeviceRole @@ -96,7 +101,13 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * Added `airflow` field * dcim.Interface * Added `bridge` field + * Added `rf_role` field + * Added `rf_channel` field + * Added `rf_channel_frequency` field + * Added `rf_chanel_width` field * Added `wwn` field + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Location * Added `tenant` field * extras.Webhook diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f2897a7f..d8c5a7771 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -17,6 +17,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer +from wireless.api.nested_serializers import NestedWirelessLinkSerializer from wireless.choices import * from .nested_serializers import * @@ -618,6 +619,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con many=True ) cable = NestedCableSerializer(read_only=True) + wireless_link = NestedWirelessLinkSerializer(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: @@ -625,9 +627,10 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', - 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', + 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', + 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', + '_occupied', ] def validate(self, data): diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 54f764db6..b02aa67c0 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -5,7 +5,7 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView -router.register('wireless-lan-groupss', views.WirelessLANGroupViewSet) +router.register('wireless-lan-groups', views.WirelessLANGroupViewSet) router.register('wireless-lans', views.WirelessLANViewSet) router.register('wireless-links', views.WirelessLinkViewSet) From 61d2158f7633d14f6d6934e0bb0f48c678029691 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 11:11:58 -0400 Subject: [PATCH 070/289] #6346: Add 'bridge' interface type --- docs/release-notes/version-3.1.md | 2 +- netbox/dcim/choices.py | 2 ++ netbox/dcim/constants.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ef7500e1e..b583d8b44 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -45,7 +45,7 @@ Multiple conditions may be nested using AND/OR logic as well. For more informati #### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) -A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. +A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. Additionally, "bridge" has been added as an interface type. (However, interfaces of any type may be designated as bridged.) Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect. diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 9b5363d4c..de46aec8a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -720,6 +720,7 @@ class InterfaceTypeChoices(ChoiceSet): # Virtual TYPE_VIRTUAL = 'virtual' + TYPE_BRIDGE = 'bridge' TYPE_LAG = 'lag' # Ethernet @@ -820,6 +821,7 @@ class InterfaceTypeChoices(ChoiceSet): 'Virtual interfaces', ( (TYPE_VIRTUAL, 'Virtual'), + (TYPE_BRIDGE, 'Bridge'), (TYPE_LAG, 'Link Aggregation Group (LAG)'), ), ), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0d64b357b..2136f06aa 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -34,6 +34,7 @@ INTERFACE_MTU_MAX = 65536 VIRTUAL_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG, + InterfaceTypeChoices.TYPE_BRIDGE, ] WIRELESS_IFACE_TYPES = [ From 82243732a19f83cb55c60fe206470cbe04df638b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 14:42:20 -0400 Subject: [PATCH 071/289] Initial work on #5883 --- netbox/dcim/models/devices.py | 4 +- netbox/dcim/tables/devices.py | 17 +-- netbox/extras/admin.py | 68 ++++++++++- netbox/extras/forms/__init__.py | 1 + netbox/extras/forms/config.py | 67 +++++++++++ .../extras/migrations/0064_configrevision.py | 20 ++++ netbox/extras/models/__init__.py | 3 +- netbox/extras/models/models.py | 111 ++++++++++-------- netbox/extras/signals.py | 16 ++- netbox/ipam/models/ip.py | 7 +- netbox/netbox/config/__init__.py | 35 ++++++ netbox/netbox/config/parameters.py | 55 +++++++++ netbox/netbox/context_processors.py | 2 + netbox/netbox/settings.py | 57 ++++----- netbox/templates/base/layout.html | 8 +- netbox/templates/login.html | 4 +- netbox/virtualization/models.py | 5 +- netbox/virtualization/tables.py | 4 +- 18 files changed, 375 insertions(+), 109 deletions(-) create mode 100644 netbox/extras/forms/config.py create mode 100644 netbox/extras/migrations/0064_configrevision.py create mode 100644 netbox/netbox/config/__init__.py create mode 100644 netbox/netbox/config/parameters.py diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2b3b80d24..d6b23fed4 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -15,6 +15,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.config import ConfigResolver from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -815,7 +816,8 @@ class Device(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - if settings.PREFER_IPV4 and self.primary_ip4: + config = ConfigResolver() + if config.PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8ea27b8a6..167dba95d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -160,18 +160,11 @@ class DeviceTable(BaseTable): linkify=True, verbose_name='Type' ) - if settings.PREFER_IPV4: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip4', 'primary_ip6'), - verbose_name='IP Address' - ) - else: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip6', 'primary_ip4'), - verbose_name='IP Address' - ) + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) primary_ip4 = tables.Column( linkify=True, verbose_name='IPv4 Address' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index dae21c2c9..e99406e49 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,10 +1,74 @@ from django.contrib import admin -from .models import JobResult +from .forms import ConfigRevisionForm +from .models import ConfigRevision, JobResult + + +@admin.register(ConfigRevision) +class ConfigRevisionAdmin(admin.ModelAdmin): + fieldsets = [ + # ('Authentication', { + # 'fields': ('LOGIN_REQUIRED', 'LOGIN_PERSISTENCE', 'LOGIN_TIMEOUT'), + # }), + # ('Rack Elevations', { + # 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), + # }), + ('IPAM', { + 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), + }), + # ('Security', { + # 'fields': ( + # 'ALLOWED_URL_SCHEMES', 'EXEMPT_VIEW_PERMISSIONS', + # ), + # }), + ('Banners', { + 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), + }), + # ('Logging', { + # 'fields': ('CHANGELOG_RETENTION',), + # }), + # ('Pagination', { + # 'fields': ('MAX_PAGE_SIZE', 'PAGINATE_COUNT'), + # }), + # ('Miscellaneous', { + # 'fields': ('GRAPHQL_ENABLED', 'METRICS_ENABLED', 'MAINTENANCE_MODE', 'MAPS_URL'), + # }), + ('Config Revision', { + 'fields': ('comment',), + }) + ] + form = ConfigRevisionForm + list_display = ('id', 'is_active', 'created', 'comment') + ordering = ('-id',) + readonly_fields = ('data',) + + def get_changeform_initial_data(self, request): + """ + Populate initial form data from the most recent ConfigRevision. + """ + latest_revision = ConfigRevision.objects.last() + initial = latest_revision.data if latest_revision else {} + initial.update(super().get_changeform_initial_data(request)) + + return initial + + def has_add_permission(self, request): + # Only superusers may modify the configuration. + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + # ConfigRevisions cannot be modified once created. + return False + + def has_delete_permission(self, request, obj=None): + # Only inactive ConfigRevisions may be deleted (must be superuser). + return request.user.is_superuser and ( + obj is None or not obj.is_active() + ) # -# Reports +# Reports & scripts # @admin.register(JobResult) diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index 1584e2f51..b470650da 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -3,4 +3,5 @@ from .filtersets import * from .bulk_edit import * from .bulk_import import * from .customfields import * +from .config import * from .scripts import * diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py new file mode 100644 index 000000000..001252f0c --- /dev/null +++ b/netbox/extras/forms/config.py @@ -0,0 +1,67 @@ +from django import forms + +from netbox.config.parameters import PARAMS + +__all__ = ( + 'ConfigRevisionForm', +) + + +EMPTY_VALUES = ('', None, [], ()) + + +class FormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported configuration parameter + param_fields = {} + for param in PARAMS: + help_text = f'{param.description}
' if param.description else '' + # help_text += f'Current value: {getattr(settings, param.name)}' + param_fields[param.name] = param.field( + required=False, + label=param.label, + help_text=help_text, + **param.field_kwargs + ) + attrs.update(param_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass): + """ + Form for creating a new ConfigRevision. + """ + class Meta: + widgets = { + 'comment': forms.Textarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Bugfix for django-timezone-field: Add empty choice to default options + # self.fields['TIME_ZONE'].choices = [('', ''), *self.fields['TIME_ZONE'].choices] + + def save(self, commit=True): + instance = super().save(commit=False) + + # Populate JSON data on the instance + instance.data = self.render_json() + + if commit: + instance.save() + + return instance + + def render_json(self): + json = {} + + # Iterate through each field and populate non-empty values + for field_name in self.declared_fields: + if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: + json[field_name] = self.cleaned_data[field_name] + + return json diff --git a/netbox/extras/migrations/0064_configrevision.py b/netbox/extras/migrations/0064_configrevision.py new file mode 100644 index 000000000..c3fce8abe --- /dev/null +++ b/netbox/extras/migrations/0064_configrevision.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0063_webhook_conditions'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigRevision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('comment', models.CharField(blank=True, max_length=200)), + ('data', models.JSONField(blank=True, null=True)), + ], + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 84676453f..3cb6372be 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,12 +1,13 @@ from .change_logging import ObjectChange from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField -from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook +from .models import * from .tags import Tag, TaggedItem __all__ = ( 'ConfigContext', 'ConfigContextModel', + 'ConfigRevision', 'CustomField', 'CustomLink', 'ExportTemplate', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 43af19f82..4f93b19ce 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,9 +1,11 @@ import json import uuid +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 ValidationError from django.db import models from django.http import HttpResponse @@ -20,8 +22,8 @@ from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 - __all__ = ( + 'ConfigRevision', 'CustomLink', 'ExportTemplate', 'ImageAttachment', @@ -33,10 +35,6 @@ __all__ = ( ) -# -# Webhooks -# - @extras_features('webhooks') class Webhook(ChangeLoggedModel): """ @@ -181,10 +179,6 @@ class Webhook(ChangeLoggedModel): return json.dumps(context, cls=JSONEncoder) -# -# Custom links -# - @extras_features('webhooks') class CustomLink(ChangeLoggedModel): """ @@ -240,10 +234,6 @@ class CustomLink(ChangeLoggedModel): return reverse('extras:customlink', args=[self.pk]) -# -# Export templates -# - @extras_features('webhooks') class ExportTemplate(ChangeLoggedModel): content_type = models.ForeignKey( @@ -333,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel): return response -# -# Image attachments -# - class ImageAttachment(BigIDModel): """ An uploaded image which is associated with an object. @@ -409,11 +395,6 @@ class ImageAttachment(BigIDModel): return None -# -# Journal entries -# - - @extras_features('webhooks') class JournalEntry(ChangeLoggedModel): """ @@ -463,36 +444,6 @@ class JournalEntry(ChangeLoggedModel): return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) -# -# Custom scripts -# - -@extras_features('job_results') -class Script(models.Model): - """ - Dummy model used to generate permissions for custom scripts. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Reports -# - -@extras_features('job_results') -class Report(models.Model): - """ - Dummy model used to generate permissions for reports. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Job results -# - class JobResult(BigIDModel): """ This model stores the results from running a user-defined report. @@ -582,3 +533,59 @@ class JobResult(BigIDModel): func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs) return job_result + + +class ConfigRevision(models.Model): + """ + An atomic revision of NetBox's configuration. + """ + created = models.DateTimeField( + auto_now_add=True + ) + comment = models.CharField( + max_length=200, + blank=True + ) + data = models.JSONField( + blank=True, + null=True, + verbose_name='Configuration data' + ) + + def __str__(self): + return f'Config revision #{self.pk} ({self.created})' + + def __getattr__(self, item): + if item in self.data: + return self.data[item] + return super().__getattribute__(item) + + @admin.display(boolean=True) + def is_active(self): + return cache.get('config_version') == self.pk + + +# +# Custom scripts & reports +# + +@extras_features('job_results') +class Script(models.Model): + """ + Dummy model used to generate permissions for custom scripts. Does not exist in the database. + """ + class Meta: + managed = False + + +# +# Reports +# + +@extras_features('job_results') +class Report(models.Model): + """ + Dummy model used to generate permissions for reports. Does not exist in the database. + """ + class Meta: + managed = False diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4f09706be..01fd30f15 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -2,13 +2,14 @@ import logging from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django_prometheus.models import model_deletes, model_inserts, model_updates from netbox.signals import post_clean from .choices import ObjectChangeActionChoices -from .models import CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook @@ -161,3 +162,16 @@ def run_custom_validators(sender, instance, **kwargs): validators = settings.CUSTOM_VALIDATORS.get(model_name, []) for validator in validators: validator(instance) + + +# +# Dynamic configuration +# + +@receiver(post_save, sender=ConfigRevision) +def update_config(sender, instance, **kwargs): + """ + Update the cached NetBox configuration when a new ConfigRevision is created. + """ + cache.set('config', instance.data, None) + cache.set('config_version', instance.pk, None) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 514e87a62..d655dcb21 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -17,6 +17,7 @@ from ipam.fields import IPNetworkField, IPAddressField from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator +from netbox.config import ConfigResolver from utilities.querysets import RestrictedQuerySet from virtualization.models import VirtualMachine @@ -316,7 +317,8 @@ class Prefix(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + config = ConfigResolver() + if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: raise ValidationError({ @@ -811,7 +813,8 @@ class IPAddress(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + config = ConfigResolver() + if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() if duplicate_ips and ( self.role not in IPADDRESS_ROLES_NONUNIQUE or diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py new file mode 100644 index 000000000..34ee127fc --- /dev/null +++ b/netbox/netbox/config/__init__.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.core.cache import cache + +from .parameters import PARAMS + +__all__ = ( + 'ConfigResolver', + 'PARAMS', +) + + +class ConfigResolver: + """ + Active NetBox configuration. + """ + def __init__(self): + self.config = cache.get('config') + self.version = self.config.get('config_version') + self.defaults = {param.name: param.default for param in PARAMS} + + def __getattr__(self, item): + + # Check for hard-coded configuration in settings.py + if hasattr(settings, item): + return getattr(settings, item) + + # Return config value from cache + if item in self.config: + return self.config[item] + + # Fall back to the parameter's default value + if item in self.defaults: + return self.defaults[item] + + raise AttributeError(f"Invalid configuration parameter: {item}") diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py new file mode 100644 index 000000000..604131ec1 --- /dev/null +++ b/netbox/netbox/config/parameters.py @@ -0,0 +1,55 @@ +from django import forms + + +class OptionalBooleanSelect(forms.Select): + """ + An optional boolean field (yes/no/default). + """ + def __init__(self, attrs=None): + choices = ( + ('', 'Default'), + (True, 'Yes'), + (False, 'No'), + ) + super().__init__(attrs, choices) + + +class OptionalBooleanField(forms.NullBooleanField): + widget = OptionalBooleanSelect + + +class ConfigParam: + + def __init__(self, name, label, default, description=None, field=None, field_kwargs=None): + self.name = name + self.label = label + self.default = default + self.field = field or forms.CharField + self.description = description + self.field_kwargs = field_kwargs or {} + + +PARAMS = ( + + # Banners + ConfigParam('BANNER_LOGIN', 'Login banner', ''), + ConfigParam('BANNER_TOP', 'Top banner', ''), + ConfigParam('BANNER_BOTTOM', 'Bottom banner', ''), + + # IPAM + ConfigParam( + name='ENFORCE_GLOBAL_UNIQUE', + label='Globally unique IP space', + default=False, + description="Enforce unique IP addressing within the global table", + field=OptionalBooleanField + ), + ConfigParam( + name='PREFER_IPV4', + label='Prefer IPv4', + default=False, + description="Prefer IPv4 addresses over IPv6", + field=OptionalBooleanField + ), + +) diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index d6dd67d99..fee32a063 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,6 +1,7 @@ from django.conf import settings as django_settings from extras.registry import registry +from netbox.config import ConfigResolver def settings_and_registry(request): @@ -9,6 +10,7 @@ def settings_and_registry(request): """ return { 'settings': django_settings, + 'config': ConfigResolver(), 'registry': registry, 'preferences': request.user.config if request.user.is_authenticated else {}, } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 279b8c453..0eb164523 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -11,6 +11,8 @@ from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from netbox.config import PARAMS + # # Environment setup @@ -68,18 +70,11 @@ DATABASE = getattr(configuration, 'DATABASE') REDIS = getattr(configuration, 'REDIS') SECRET_KEY = getattr(configuration, 'SECRET_KEY') -# Set optional parameters +# Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) -ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -)) -BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '') -BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '') -BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only -CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) @@ -90,30 +85,12 @@ DEBUG = getattr(configuration, 'DEBUG', False) DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) -ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) -EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) -GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) -LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) -LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) -MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) -MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') -MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') -METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) -NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) -NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') -NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) -NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') -PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) -LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) -PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) @@ -127,7 +104,6 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', []) REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') -RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') @@ -141,6 +117,33 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') +# Check for hard-coded dynamic config parameters +for param in PARAMS: + if hasattr(configuration, param.name): + globals()[param.name] = getattr(configuration, param.name) + +ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( + 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', +)) +CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) +EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) +GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) +LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) +LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) +LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) +MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) +MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') +MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) +METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) +NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) +NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') +NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) +NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') +PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) +RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) +RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) +RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) + # Validate update repo URL and timeout if RELEASE_CHECK_URL: validator = URLValidator( diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 9575d4dcb..a4c8c77b6 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -58,9 +58,9 @@ - {% if settings.BANNER_TOP %} + {% if config.BANNER_TOP %} {% endif %} @@ -98,9 +98,9 @@ {% endblock %} - {% if settings.BANNER_BOTTOM %} + {% if config.BANNER_BOTTOM %} {% endif %} diff --git a/netbox/templates/login.html b/netbox/templates/login.html index 37cdd8e53..a01d75422 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -7,9 +7,9 @@
{# Login banner #} - {% if settings.BANNER_LOGIN %} + {% if config.BANNER_LOGIN %} {% endif %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index c614618c0..51d255fc7 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator @@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.config import ConfigResolver from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface @@ -340,7 +340,8 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - if settings.PREFER_IPV4 and self.primary_ip4: + config = ConfigResolver() + if config.PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 56ad88f1f..0a605267d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -17,8 +17,6 @@ __all__ = ( 'VMInterfaceTable', ) -PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4') - VMINTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %} @@ -136,7 +134,7 @@ class VirtualMachineTable(BaseTable): ) primary_ip = tables.Column( linkify=True, - order_by=PRIMARY_IP_ORDERING, + order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address' ) tags = TagColumn( From 7c0f32e8ee73914cca3262c68c685da02c82bf5a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Oct 2021 10:04:56 -0400 Subject: [PATCH 072/289] Introduce ConfigItem; add rack elevation parameters --- netbox/dcim/api/serializers.py | 5 +++-- netbox/dcim/models/devices.py | 6 ++---- netbox/dcim/models/racks.py | 10 +++++++--- netbox/extras/admin.py | 6 +++--- netbox/ipam/models/ip.py | 11 ++++------- netbox/netbox/config/__init__.py | 23 +++++++++++++++++++---- netbox/netbox/config/parameters.py | 16 ++++++++++++++++ netbox/netbox/configuration.example.py | 20 -------------------- netbox/netbox/context_processors.py | 4 ++-- netbox/netbox/settings.py | 2 -- netbox/virtualization/models.py | 5 ++--- 11 files changed, 58 insertions(+), 50 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d8c5a7771..a5f4ac5fe 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,6 +13,7 @@ from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) +from netbox.config import ConfigItem from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model @@ -229,10 +230,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=RackElevationDetailRenderChoices.RENDER_JSON ) unit_width = serializers.IntegerField( - default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH') ) unit_height = serializers.IntegerField( - default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d6b23fed4..418944a4a 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,7 +1,6 @@ from collections import OrderedDict import yaml -from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -15,7 +14,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features -from netbox.config import ConfigResolver +from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -816,8 +815,7 @@ class Device(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - config = ConfigResolver() - if config.PREFER_IPV4 and self.primary_ip4: + if ConfigItem('PREFER_IPV4')() and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index a6d7f33af..4a023477f 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,6 +1,5 @@ from collections import OrderedDict -from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType @@ -15,6 +14,7 @@ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG from extras.utils import extras_features +from netbox.config import Config from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -373,8 +373,8 @@ class Rack(PrimaryModel): self, face=DeviceFaceChoices.FACE_FRONT, user=None, - unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, - unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, + unit_width=None, + unit_height=None, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, include_images=True, base_url=None @@ -393,6 +393,10 @@ class Rack(PrimaryModel): :param base_url: Base URL for links and images. If none, URLs will be relative. """ elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) + if unit_width is None or unit_height is None: + config = Config() + unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT return elevation.render(face, unit_width, unit_height, legend_width) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index e99406e49..cac600626 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -10,9 +10,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): # ('Authentication', { # 'fields': ('LOGIN_REQUIRED', 'LOGIN_PERSISTENCE', 'LOGIN_TIMEOUT'), # }), - # ('Rack Elevations', { - # 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), - # }), + ('Rack Elevations', { + 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), + }), ('IPAM', { 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), }), diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index d655dcb21..af114537a 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,10 +1,9 @@ import netaddr -from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models -from django.db.models import F, Q +from django.db.models import F from django.urls import reverse from django.utils.functional import cached_property @@ -17,7 +16,7 @@ from ipam.fields import IPNetworkField, IPAddressField from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator -from netbox.config import ConfigResolver +from netbox.config import Config from utilities.querysets import RestrictedQuerySet from virtualization.models import VirtualMachine @@ -317,8 +316,7 @@ class Prefix(PrimaryModel): }) # Enforce unique IP space (if applicable) - config = ConfigResolver() - if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and Config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: raise ValidationError({ @@ -813,8 +811,7 @@ class IPAddress(PrimaryModel): }) # Enforce unique IP space (if applicable) - config = ConfigResolver() - if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and Config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() if duplicate_ips and ( self.role not in IPADDRESS_ROLES_NONUNIQUE or diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py index 34ee127fc..7e57f3e8d 100644 --- a/netbox/netbox/config/__init__.py +++ b/netbox/netbox/config/__init__.py @@ -4,18 +4,20 @@ from django.core.cache import cache from .parameters import PARAMS __all__ = ( - 'ConfigResolver', + 'Config', + 'ConfigItem', 'PARAMS', ) -class ConfigResolver: +class Config: """ - Active NetBox configuration. + Fetch and store in memory the current NetBox configuration. This class must be instantiated prior to access, and + must be re-instantiated each time it's necessary to check for updates to the cached config. """ def __init__(self): self.config = cache.get('config') - self.version = self.config.get('config_version') + self.version = cache.get('config_version') self.defaults = {param.name: param.default for param in PARAMS} def __getattr__(self, item): @@ -33,3 +35,16 @@ class ConfigResolver: return self.defaults[item] raise AttributeError(f"Invalid configuration parameter: {item}") + + +class ConfigItem: + """ + A callable to retrieve a configuration parameter from the cache. This can serve as a placeholder to defer + referencing a configuration parameter. + """ + def __init__(self, item): + self.item = item + + def __call__(self): + config = Config() + return getattr(config, self.item) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 604131ec1..4e1ff80f4 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -52,4 +52,20 @@ PARAMS = ( field=OptionalBooleanField ), + # Racks + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', + label='Rack Unit Height', + default=22, + description="Default unit height for rendered rack elevations", + field=forms.IntegerField + ), + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', + label='Rack Unit Width', + default=220, + description="Default unit width for rendered rack elevations", + field=forms.IntegerField + ), + ) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 03023740f..bb4a9021e 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -77,14 +77,6 @@ ALLOWED_URL_SCHEMES = ( 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', ) -# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same -# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. -BANNER_TOP = '' -BANNER_BOTTOM = '' - -# Text to include on the login page above the login form. HTML is allowed. -BANNER_LOGIN = '' - # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' @@ -134,10 +126,6 @@ EMAIL = { 'FROM_EMAIL': '', } -# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table -# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. -ENFORCE_GLOBAL_UNIQUE = False - # Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and # by anonymous users. List models in the form `.`. Add '*' to this list to exempt all models. EXEMPT_VIEW_PERMISSIONS = [ @@ -229,14 +217,6 @@ PLUGINS = [] # } # } -# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to -# prefer IPv4 instead. -PREFER_IPV4 = False - -# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1. -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22 -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220 - # Remote authentication support REMOTE_AUTH_ENABLED = False REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index fee32a063..8ae0a0f26 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,7 +1,7 @@ from django.conf import settings as django_settings from extras.registry import registry -from netbox.config import ConfigResolver +from netbox.config import Config def settings_and_registry(request): @@ -10,7 +10,7 @@ def settings_and_registry(request): """ return { 'settings': django_settings, - 'config': ConfigResolver(), + 'config': Config(), 'registry': registry, 'preferences': request.user.config if request.user.is_authenticated else {}, } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0eb164523..248b3d697 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -140,8 +140,6 @@ NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) # Validate update repo URL and timeout diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 51d255fc7..f82550b4f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -8,7 +8,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features -from netbox.config import ConfigResolver +from netbox.config import Config from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface @@ -340,8 +340,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - config = ConfigResolver() - if config.PREFER_IPV4 and self.primary_ip4: + if Config().PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 From 559dc2f8652959c136727ecf11fc3392fd6ac4f5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Oct 2021 10:24:33 -0400 Subject: [PATCH 073/289] Add ALLOWED_URL_SCHEMES --- netbox/extras/admin.py | 8 +++----- netbox/netbox/config/parameters.py | 14 ++++++++++++++ netbox/netbox/configuration.example.py | 5 ----- netbox/netbox/settings.py | 3 --- netbox/utilities/templatetags/helpers.py | 3 ++- netbox/utilities/validators.py | 9 +++++++-- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index cac600626..5e2de7e16 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -16,11 +16,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('IPAM', { 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), }), - # ('Security', { - # 'fields': ( - # 'ALLOWED_URL_SCHEMES', 'EXEMPT_VIEW_PERMISSIONS', - # ), - # }), + ('Security', { + 'fields': ('ALLOWED_URL_SCHEMES',), + }), ('Banners', { 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), }), diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 4e1ff80f4..8ad02a5dd 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.postgres.forms import SimpleArrayField class OptionalBooleanSelect(forms.Select): @@ -68,4 +69,17 @@ PARAMS = ( field=forms.IntegerField ), + # Security + ConfigParam( + name='ALLOWED_URL_SCHEMES', + label='Allowed URL schemes', + default=( + 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', + 'xmpp', + ), + description="Permitted schemes for URLs in user-provided content", + field=SimpleArrayField, + field_kwargs={'base_field': forms.CharField()} + ), + ) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index bb4a9021e..63e74524a 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -72,11 +72,6 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] -# URL schemes that are allowed within links in NetBox -ALLOWED_URL_SCHEMES = ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -) - # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 248b3d697..f42c99dbf 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -122,9 +122,6 @@ for param in PARAMS: if hasattr(configuration, param.name): globals()[param.name] = getattr(configuration, param.name) -ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -)) CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 1b5bb220d..833d19535 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -14,6 +14,7 @@ from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown +from netbox.config import Config from utilities.forms import get_selected_values, TableConfigForm from utilities.utils import foreground_color @@ -44,7 +45,7 @@ def render_markdown(value): value = strip_tags(value) # Sanitize Markdown links - schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) + schemes = '|'.join(Config().ALLOWED_URL_SCHEMES) pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index b087b0867..5b5775482 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,9 +1,10 @@ import re -from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator +from netbox.config import Config + class EnhancedURLValidator(URLValidator): """ @@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator): r'(?::\d{2,5})?' # Port number r'(?:[/?#][^\s]*)?' # Path r'\Z', re.IGNORECASE) - schemes = settings.ALLOWED_URL_SCHEMES + + def __init__(self, schemes=None, **kwargs): + super().__init__(**kwargs) + if schemes is not None: + self.schemes = Config().ALLOWED_URL_SCHEMES class ExclusionValidator(BaseValidator): From 94804fecd8c00043dcdf50c45393f86a1e87b6c7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Oct 2021 10:57:33 -0400 Subject: [PATCH 074/289] Add MAINTENANCE_MODE, MAPS_URL --- netbox/extras/admin.py | 6 +++--- netbox/netbox/config/parameters.py | 19 +++++++++++++++++-- netbox/netbox/configuration.example.py | 6 ------ netbox/netbox/settings.py | 2 -- netbox/templates/base/layout.html | 2 +- netbox/templates/dcim/site.html | 4 ++-- netbox/users/views.py | 4 ++-- 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 5e2de7e16..6df9c55cf 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -28,9 +28,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): # ('Pagination', { # 'fields': ('MAX_PAGE_SIZE', 'PAGINATE_COUNT'), # }), - # ('Miscellaneous', { - # 'fields': ('GRAPHQL_ENABLED', 'METRICS_ENABLED', 'MAINTENANCE_MODE', 'MAPS_URL'), - # }), + ('Miscellaneous', { + 'fields': ('MAINTENANCE_MODE', 'MAPS_URL'), + }), ('Config Revision', { 'fields': ('comment',), }) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 8ad02a5dd..4e77cec0e 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -56,14 +56,14 @@ PARAMS = ( # Racks ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', - label='Rack Unit Height', + label='Rack unit height', default=22, description="Default unit height for rendered rack elevations", field=forms.IntegerField ), ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', - label='Rack Unit Width', + label='Rack unit width', default=220, description="Default unit width for rendered rack elevations", field=forms.IntegerField @@ -82,4 +82,19 @@ PARAMS = ( field_kwargs={'base_field': forms.CharField()} ), + # Miscellaneous + ConfigParam( + name='MAINTENANCE_MODE', + label='Maintenance mode', + default=False, + description="Enable maintenance mode", + field=OptionalBooleanField + ), + ConfigParam( + name='MAPS_URL', + label='Maps URL', + default='https://maps.google.com/?q=', + description="Base URL for mapping geographic locations" + ), + ) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 63e74524a..c40ea4eff 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -158,12 +158,6 @@ LOGIN_REQUIRED = False # re-authenticate. (Default: 1209600 [14 days]) LOGIN_TIMEOUT = None -# Setting this to True will display a "maintenance mode" banner at the top of every page. -MAINTENANCE_MODE = False - -# The URL to use when mapping physical addresses or GPS coordinates -MAPS_URL = 'https://maps.google.com/?q=' - # An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. # "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request # all objects by specifying "?limit=0". diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f42c99dbf..beae4d568 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -128,8 +128,6 @@ GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) -MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) -MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index a4c8c77b6..2770a6dc6 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -64,7 +64,7 @@ {% endif %} - {% if settings.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE %}
{{ script.filename }} -
{{ script.source }}
+
{{ script.source }}
{% endblock content-wrapper %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index f463b0f2c..3cbd0c611 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -102,11 +102,11 @@ {% endif %}
-
{{ result.data.output }}
+
{{ result.data.output }}

{{ script.filename }}

-
{{ script.source }}
+
{{ script.source }}
{% endblock content-wrapper %} From 96565c31d93c8a1446020e449cf9dd04c3791c57 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 10:04:12 -0500 Subject: [PATCH 099/289] #6732 - ASN should be unique --- netbox/ipam/migrations/0052_unique_asn.py | 19 +++++++++++++++++++ netbox/ipam/models/ip.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 netbox/ipam/migrations/0052_unique_asn.py diff --git a/netbox/ipam/migrations/0052_unique_asn.py b/netbox/ipam/migrations/0052_unique_asn.py new file mode 100644 index 000000000..8a5bb1da9 --- /dev/null +++ b/netbox/ipam/migrations/0052_unique_asn.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-28 15:03 + +import dcim.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0051_asn_model'), + ] + + operations = [ + migrations.AlterField( + model_name='asn', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, unique=True), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 45baf8258..110d93cfe 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -74,6 +74,7 @@ class RIR(OrganizationalModel): class ASN(PrimaryModel): asn = ASNField( + unique=True, blank=True, null=True, verbose_name='ASN', From 1902e112f643a134433b995610746bc62830cbd4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:46:55 -0500 Subject: [PATCH 100/289] #6732 - Fix tests for utilities --- netbox/utilities/tests/test_filters.py | 63 ++++++++++++++++++++------ 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 374167f1c..e4609ef9b 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,6 +5,9 @@ from django.test import TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager +from circuits.choices import CircuitStatusChoices +from circuits.filtersets import CircuitFilterSet +from circuits.models import Circuit, Provider, CircuitType from dcim.choices import * from dcim.fields import MACAddressField from dcim.filtersets import DeviceFilterSet, SiteFilterSet @@ -13,6 +16,7 @@ from dcim.models import ( ) from extras.filters import TagFilter from extras.models import TaggedItem +from ipam.models import RIR, ASN from netbox.filtersets import BaseFilterSet from utilities.filters import ( MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, @@ -337,10 +341,26 @@ class DynamicFilterLookupExpressionTest(TestCase): device_filterset = DeviceFilterSet site_queryset = Site.objects.all() site_filterset = SiteFilterSet + circuit_queryset = Circuit.objects.all() + circuit_filterset = CircuitFilterSet @classmethod def setUpTestData(cls): + provider = Provider.objects.create(name='Test Provider', slug='test-provider') + circuit_type = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') + + circuits = ( + Circuit(cid='CID12123', provider=provider, type=circuit_type, + status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=1000), + Circuit(cid='CID12124', provider=provider, type=circuit_type, + status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=10000), + Circuit(cid='CID12125', provider=provider, type=circuit_type, + status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=100000) + + ) + Circuit.objects.bulk_create(circuits) + manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -378,12 +398,25 @@ class DynamicFilterLookupExpressionTest(TestCase): region.save() sites = ( - Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), - Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), - Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), + Site(name='Site 1', slug='abc-site-1', region=regions[0]), + Site(name='Site 2', slug='def-site-2', region=regions[1]), + Site(name='Site 3', slug='ghi-site-3', region=regions[2]), ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65001, rir=rir), + ASN(asn=65101, rir=rir), + ASN(asn=65201, rir=rir) + ] + ASN.objects.bulk_create(asns) + + asns[0].sites.add(sites[0]) + asns[1].sites.add(sites[1]) + asns[2].sites.add(sites[2]) + racks = ( Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 2', site=sites[1]), @@ -436,21 +469,21 @@ class DynamicFilterLookupExpressionTest(TestCase): params = {'slug__niew': ['-1']} self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) - def test_site_asn_lt(self): - params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + def test_circuit_commit_lt(self): + params = {'commit_rate__lt': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) - def test_site_asn_lte(self): - params = {'asn__lte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + def test_circuit_commit_lte(self): + params = {'commit_rate__lte': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) - def test_site_asn_gt(self): - params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + def test_circuit_commit_gt(self): + params = {'commit_rate__gt': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) - def test_site_asn_gte(self): - params = {'asn__gte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + def test_circuit_commit_gte(self): + params = {'commit_rate__gte': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} From 0f68ecda785a4512fbaee337dff18ba2218a3013 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:47:54 -0500 Subject: [PATCH 101/289] #6732 - Fix hiding of ASN field in Site creation form --- netbox/dcim/forms/models.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 805788c04..ca7074e05 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -166,13 +166,15 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def __init__(self, instance, *args, **kwargs): super(SiteForm, self).__init__(instance=instance, *args, **kwargs) if instance is None or (instance and (instance.asn is None or instance.asn == '')): - site_fieldset = list(self.Meta.fieldsets[0][1]) - site_fieldset.pop(6) - self.Meta.fieldsets = ( - ('Site', tuple(site_fieldset)), - self.Meta.fieldsets[1], - self.Meta.fieldsets[2], - ) + if 'asn' in self.Meta.fieldsets[0][1]: + site_fieldset = list(self.Meta.fieldsets[0][1]) + index = site_fieldset.index('asn') + site_fieldset.pop(index) + self.Meta.fieldsets = ( + ('Site', tuple(site_fieldset)), + self.Meta.fieldsets[1], + self.Meta.fieldsets[2], + ) del self.fields['asn'] From ada81e31c9bac8422d7e3ebb3b6053f7c66765af Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:48:50 -0500 Subject: [PATCH 102/289] #6732 - Fix CSV import form --- netbox/ipam/forms/bulk_import.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index e4190a66c..41604f0e4 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.forms import IntegerField from dcim.models import Device, Interface, Site from extras.forms import CustomFieldModelCSVForm @@ -83,17 +84,12 @@ class AggregateCSVForm(CustomFieldModelCSVForm): class ASNCSVForm(CustomFieldModelCSVForm): - slug = SlugField() + asn = IntegerField() rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', help_text='Assigned RIR' ) - sites = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name', - help_text='Assigned site' - ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, From 3c261b05d964bc573f1fe4008e22535e96d4e5c4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:49:21 -0500 Subject: [PATCH 103/289] #6732 - Fix ASN tests --- netbox/dcim/tests/test_filtersets.py | 19 ++++++++++++- netbox/dcim/tests/test_views.py | 33 ++++++++++++++++++++--- netbox/ipam/tests/test_api.py | 2 +- netbox/ipam/tests/test_filtersets.py | 40 +++++++++++++++------------- netbox/ipam/tests/test_views.py | 6 ++--- 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ce78e0470..c4558b882 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import IPAddress +from ipam.models import IPAddress, RIR, ASN from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests @@ -148,6 +148,23 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = ( + ASN(asn=64512, rir=rir, tenant=tenants[0]), + ASN(asn=64513, rir=rir, tenant=tenants[0]), + ASN(asn=64514, rir=rir, tenant=tenants[0]), + ASN(asn=65001, rir=rir, tenant=tenants[0]), + ASN(asn=65002, rir=rir, tenant=tenants[0]) + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[2]]) + asns[3].sites.set([sites[2]]) + asns[4].sites.set([sites[1]]) + def test_name(self): params = {'name': ['Site 1', 'Site 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a9c191679..df0cfcf5d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN +from ipam.models import VLAN, ASN, RIR from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -104,7 +104,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for group in groups: group.save() - Site.objects.bulk_create([ + sites = Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), @@ -112,6 +112,33 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65000, rir=rir), + ASN(asn=65001, rir=rir), + ASN(asn=65002, rir=rir), + ASN(asn=65003, rir=rir), + ASN(asn=65004, rir=rir), + ASN(asn=65005, rir=rir), + ASN(asn=65006, rir=rir), + ASN(asn=65007, rir=rir), + ASN(asn=65008, rir=rir), + ASN(asn=65009, rir=rir), + ASN(asn=65010, rir=rir), + ] + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + asns[4].sites.set([sites[2]]) + asns[5].sites.set([sites[1]]) + asns[6].sites.set([sites[2]]) + asns[7].sites.set([sites[2]]) + asns[8].sites.set([sites[2]]) + asns[10].sites.set([sites[0]]) + cls.form_data = { 'name': 'Site X', 'slug': 'site-x', @@ -120,7 +147,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', - 'asn': 65001, 'time_zone': pytz.UTC, 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', @@ -146,7 +172,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'tenant': None, - 'asn': 65009, 'time_zone': pytz.timezone('US/Eastern'), 'description': 'New description', } diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 42fc7132b..77473e504 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -23,7 +23,7 @@ class AppTest(APITestCase): class ASNTest(APIViewTestCases.APIViewTestCase): model = ASN - brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] + brief_fields = ['asn', 'display', 'id', 'url'] bulk_update_data = { 'description': 'New description', } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 602fdd0f9..523680767 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -34,29 +34,31 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): ] asns = ( + ASN(asn=64512, rir=rirs[0], tenant=tenants[0]), ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), 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[5]), + ASN(asn=65535, rir=rirs[1], tenant=tenants[4]), 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[5]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[4]), ) ASN.objects.bulk_create(asns) asns[0].sites.set([sites[0]]) - 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[0]]) - asns[6].sites.set([sites[1]]) - asns[7].sites.set([sites[2]]) - asns[8].sites.set([sites[0]]) - asns[9].sites.set([sites[1]]) + 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]]) def test_asn(self): params = {'asn': ['64512', '65535']} @@ -65,23 +67,23 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): 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(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) params = {'tenant': [tenants[0].slug, tenants[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) def test_rir(self): rirs = RIR.objects.all()[:1] - 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) + 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) 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(), 8) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 3bd22b112..86f11bf3d 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -46,8 +46,8 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'asn': 64512, 'rir': rirs[0].pk, - 'tenant': tenants[0], - 'site': sites[0], + 'tenant': tenants[0].pk, + 'site': sites[0].pk, 'description': 'A new ASN', } @@ -55,7 +55,7 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): "asn,rir", "64533,RFC 6996", "64523,RFC 6996", - "64513,RFC 6996", + "4200000002,RFC 6996", ) cls.bulk_edit_data = { From de5c9ef4b2addaa850d93b1ebcf7dc7bcd50aca9 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:49:59 -0500 Subject: [PATCH 104/289] #6732 - Add graphql support for new ASN model and fix ASN overflow on longs --- netbox/dcim/graphql/types.py | 4 ++++ netbox/ipam/graphql/scalars.py | 5 +++++ netbox/ipam/graphql/types.py | 4 ++++ netbox/netbox/graphql/scalars.py | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 netbox/ipam/graphql/scalars.py create mode 100644 netbox/netbox/graphql/scalars.py diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 80c32e66d..6d93452cd 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,8 +1,11 @@ +import graphene + from dcim import filtersets, models from extras.graphql.mixins import ( ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin +from ipam.graphql.scalars import ASNField from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -374,6 +377,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): + asn = graphene.Field(ASNField) class Meta: model = models.Site diff --git a/netbox/ipam/graphql/scalars.py b/netbox/ipam/graphql/scalars.py new file mode 100644 index 000000000..d59375ba3 --- /dev/null +++ b/netbox/ipam/graphql/scalars.py @@ -0,0 +1,5 @@ +from netbox.graphql.scalars import BigInt + + +class ASNField(BigInt): + pass diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 0fbe06c50..71c7fd24e 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,4 +1,7 @@ +import graphene + from ipam import filtersets, models +from ipam.graphql.scalars import ASNField from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -18,6 +21,7 @@ __all__ = ( class ASNType(PrimaryObjectType): + asn = graphene.Field(ASNField) class Meta: model = models.ASN diff --git a/netbox/netbox/graphql/scalars.py b/netbox/netbox/graphql/scalars.py new file mode 100644 index 000000000..7d14189dd --- /dev/null +++ b/netbox/netbox/graphql/scalars.py @@ -0,0 +1,23 @@ +from graphene import Scalar +from graphql.language import ast +from graphql.type.scalars import MAX_INT, MIN_INT + + +class BigInt(Scalar): + """ + Handle any BigInts + """ + @staticmethod + def to_float(value): + num = int(value) + if num > MAX_INT or num < MIN_INT: + return float(num) + return num + + serialize = to_float + parse_value = to_float + + @staticmethod + def parse_literal(node): + if isinstance(node, ast.IntValue): + return BigInt.to_float(node.value) From fff124ebb1d6c2ec104102e3e225cd6d72a582e8 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 12:06:41 -0500 Subject: [PATCH 105/289] #6732 - Update migration file --- .../{0051_asn_model.py => 0052_asn_model.py} | 4 ++-- netbox/ipam/migrations/0052_unique_asn.py | 19 ------------------- 2 files changed, 2 insertions(+), 21 deletions(-) rename netbox/ipam/migrations/{0051_asn_model.py => 0052_asn_model.py} (96%) delete mode 100644 netbox/ipam/migrations/0052_unique_asn.py diff --git a/netbox/ipam/migrations/0051_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py similarity index 96% rename from netbox/ipam/migrations/0051_asn_model.py rename to netbox/ipam/migrations/0052_asn_model.py index b397532ea..4adafd411 100644 --- a/netbox/ipam/migrations/0051_asn_model.py +++ b/netbox/ipam/migrations/0052_asn_model.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): ('extras', '0062_clear_secrets_changelog'), ('tenancy', '0003_contacts'), ('dcim', '0137_relax_uniqueness_constraints'), - ('ipam', '0050_iprange'), + ('ipam', '0051_extend_tag_support'), ] operations = [ @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('asn', dcim.fields.ASNField(blank=True, null=True)), + ('asn', dcim.fields.ASNField(blank=True, null=True, 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')), ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), diff --git a/netbox/ipam/migrations/0052_unique_asn.py b/netbox/ipam/migrations/0052_unique_asn.py deleted file mode 100644 index 8a5bb1da9..000000000 --- a/netbox/ipam/migrations/0052_unique_asn.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-28 15:03 - -import dcim.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0051_asn_model'), - ] - - operations = [ - migrations.AlterField( - model_name='asn', - name='asn', - field=dcim.fields.ASNField(blank=True, null=True, unique=True), - ), - ] From 6377d475fc13b44765c3918736596d061329ceaa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Oct 2021 13:56:41 -0400 Subject: [PATCH 106/289] Refactor generation of additional lookup filters --- netbox/netbox/filtersets.py | 113 ++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 51 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 791c21d19..879f57bdc 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -2,6 +2,7 @@ import django_filters from copy import deepcopy from django.contrib.contenttypes.models import ContentType from django.db import models +from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field from dcim.forms import MACAddressField @@ -115,6 +116,59 @@ class BaseFilterSet(django_filters.FilterSet): return None + @classmethod + def get_additional_lookups(cls, existing_filter_name, existing_filter): + new_filters = {} + + # Skip nonstandard lookup expressions + if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: + return {} + + # Choose the lookup expression map based on the filter type + lookup_map = cls._get_filter_lookup_dict(existing_filter) + if lookup_map is None: + # Do not augment this filter type with more lookup expressions + return {} + + # Get properties of the existing filter for later use + field_name = existing_filter.field_name + field = get_model_field(cls._meta.model, field_name) + + # Create new filters for each lookup expression in the map + for lookup_name, lookup_expr in lookup_map.items(): + new_filter_name = f'{existing_filter_name}__{lookup_name}' + + try: + if existing_filter_name in cls.declared_filters: + # The filter field has been explicitly defined on the filterset class so we must manually + # create the new filter with the same type because there is no guarantee the defined type + # is the same as the default type for the field + resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + new_filter = type(existing_filter)( + field_name=field_name, + lookup_expr=lookup_expr, + label=existing_filter.label, + exclude=existing_filter.exclude, + distinct=existing_filter.distinct, + **existing_filter.extra + ) + else: + # The filter field is listed in Meta.fields so we can safely rely on default behaviour + # Will raise FieldLookupError if the lookup is invalid + new_filter = cls.filter_for_field(field, field_name, lookup_expr) + except FieldLookupError: + # The filter could not be created because the lookup expression is not supported on the field + continue + + if lookup_name.startswith('n'): + # This is a negation filter which requires a queryset.exclude() clause + # Of course setting the negation of the existing filter's exclude attribute handles both cases + new_filter.exclude = not existing_filter.exclude + + new_filters[new_filter_name] = new_filter + + return new_filters + @classmethod def get_filters(cls): """ @@ -125,59 +179,12 @@ class BaseFilterSet(django_filters.FilterSet): """ filters = super().get_filters() - new_filters = {} + additional_filters = {} for existing_filter_name, existing_filter in filters.items(): - # Loop over existing filters to extract metadata by which to create new filters + additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter)) - # If the filter makes use of a custom filter method or lookup expression skip it - # as we cannot sanely handle these cases in a generic mannor - if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: - continue + filters.update(additional_filters) - # Choose the lookup expression map based on the filter type - lookup_map = cls._get_filter_lookup_dict(existing_filter) - if lookup_map is None: - # Do not augment this filter type with more lookup expressions - continue - - # Get properties of the existing filter for later use - field_name = existing_filter.field_name - field = get_model_field(cls._meta.model, field_name) - - # Create new filters for each lookup expression in the map - for lookup_name, lookup_expr in lookup_map.items(): - new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) - - try: - if existing_filter_name in cls.declared_filters: - # The filter field has been explicity defined on the filterset class so we must manually - # create the new filter with the same type because there is no guarantee the defined type - # is the same as the default type for the field - resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - new_filter = type(existing_filter)( - field_name=field_name, - lookup_expr=lookup_expr, - label=existing_filter.label, - exclude=existing_filter.exclude, - distinct=existing_filter.distinct, - **existing_filter.extra - ) - else: - # The filter field is listed in Meta.fields so we can safely rely on default behaviour - # Will raise FieldLookupError if the lookup is invalid - new_filter = cls.filter_for_field(field, field_name, lookup_expr) - except django_filters.exceptions.FieldLookupError: - # The filter could not be created because the lookup expression is not supported on the field - continue - - if lookup_name.startswith('n'): - # This is a negation filter which requires a queryset.exclude() clause - # Of course setting the negation of the existing filter's exclude attribute handles both cases - new_filter.exclude = not existing_filter.exclude - - new_filters[new_filter_name] = new_filter - - filters.update(new_filters) return filters @@ -213,8 +220,12 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): ).exclude( filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED ) + + custom_field_filters = {} for cf in custom_fields: - self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) + custom_field_filters[f'cf_{cf.name}'] = CustomFieldFilter(field_name=cf.name, custom_field=cf) + + self.filters.update(custom_field_filters) class OrganizationalModelFilterSet(PrimaryModelFilterSet): From 9565addcd4bf8d7f402746d836307396b24e80d3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 13:12:55 -0500 Subject: [PATCH 107/289] #6732 - Fix test failure when sending data --- netbox/dcim/forms/models.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 24a1e8140..bda6c4348 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -172,9 +172,12 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'longitude': "Longitude in decimal format (xx.yyyyyy)" } - def __init__(self, instance, *args, **kwargs): - super(SiteForm, self).__init__(instance=instance, *args, **kwargs) - if instance is None or (instance and (instance.asn is None or instance.asn == '')): + def __init__(self, data=None, instance=None, *args, **kwargs): + super().__init__(data=data, instance=instance, *args, **kwargs) + if instance is None or \ + (instance and (instance.asn is None or instance.asn == '')) or \ + (data and (data.get('asn') is None or instance.get('asn')) == ''): + print(f'{instance}') if 'asn' in self.Meta.fieldsets[0][1]: site_fieldset = list(self.Meta.fieldsets[0][1]) index = site_fieldset.index('asn') @@ -812,7 +815,6 @@ class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm): class DeviceVCMembershipForm(forms.ModelForm): - class Meta: model = Device fields = [ @@ -908,7 +910,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = ConsolePortTemplate fields = [ @@ -920,7 +921,6 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = ConsoleServerPortTemplate fields = [ @@ -932,7 +932,6 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = PowerPortTemplate fields = [ @@ -944,7 +943,6 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = PowerOutletTemplate fields = [ @@ -955,7 +953,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit power_port choices to current DeviceType @@ -966,7 +963,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = InterfaceTemplate fields = [ @@ -979,7 +975,6 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = FrontPortTemplate fields = [ @@ -991,7 +986,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): } def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit rear_port choices to current DeviceType @@ -1002,7 +996,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = RearPortTemplate fields = [ @@ -1015,7 +1008,6 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = DeviceBayTemplate fields = [ @@ -1278,7 +1270,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ) def __init__(self, device_bay, *args, **kwargs): - super().__init__(*args, **kwargs) self.fields['installed_device'].queryset = Device.objects.filter( From 0a8788eb976ac7a433e73c9683b092393b69f4d7 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 13:57:36 -0500 Subject: [PATCH 108/289] #6732 - Fix Site form and ASN form --- netbox/dcim/forms/models.py | 10 +++++++++- netbox/ipam/forms/models.py | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index bda6c4348..2d2b28ee0 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -174,10 +174,13 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def __init__(self, data=None, instance=None, *args, **kwargs): super().__init__(data=data, instance=instance, *args, **kwargs) + + self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) + + # Hide the ASN field if there is nothing there as this is deprecated if instance is None or \ (instance and (instance.asn is None or instance.asn == '')) or \ (data and (data.get('asn') is None or instance.get('asn')) == ''): - print(f'{instance}') if 'asn' in self.Meta.fieldsets[0][1]: site_fieldset = list(self.Meta.fieldsets[0][1]) index = site_fieldset.index('asn') @@ -189,6 +192,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) del self.fields['asn'] + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + instance.asns.set(self.cleaned_data['asns']) + return instance + class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index aff071e5d..abf2aa4a1 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -129,6 +129,11 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=RIR.objects.all(), label='RIR', ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + label='Sites', + required=False + ) class Meta: model = ASN From 7c60089692d853c7db2bb3af623bf28347697596 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Oct 2021 15:31:50 -0400 Subject: [PATCH 109/289] Ditch CustomFieldFilter --- netbox/extras/filters.py | 36 --------------------- netbox/extras/filtersets.py | 7 ---- netbox/extras/models/customfields.py | 48 ++++++++++++++++++++++++++++ netbox/netbox/filtersets.py | 9 +++--- 4 files changed, 53 insertions(+), 47 deletions(-) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index b37aaf40e..de739aa59 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,47 +1,11 @@ import django_filters -from django.forms import DateField, IntegerField, NullBooleanField from .models import Tag -from .choices import * __all__ = ( - 'CustomFieldFilter', 'TagFilter', ) -EXACT_FILTER_TYPES = ( - CustomFieldTypeChoices.TYPE_BOOLEAN, - CustomFieldTypeChoices.TYPE_DATE, - CustomFieldTypeChoices.TYPE_INTEGER, - CustomFieldTypeChoices.TYPE_SELECT, - CustomFieldTypeChoices.TYPE_MULTISELECT, -) - - -class CustomFieldFilter(django_filters.Filter): - """ - Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. - """ - def __init__(self, custom_field, *args, **kwargs): - self.custom_field = custom_field - - if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER: - self.field_class = IntegerField - elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - self.field_class = NullBooleanField - elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE: - self.field_class = DateField - - super().__init__(*args, **kwargs) - - self.field_name = f'custom_field_data__{self.field_name}' - - if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - self.lookup_expr = 'has_key' - elif custom_field.type not in EXACT_FILTER_TYPES: - if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: - self.lookup_expr = 'icontains' - class TagFilter(django_filters.ModelMultipleChoiceFilter): """ diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index af8d904f4..0d44eab57 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -26,13 +26,6 @@ __all__ = ( 'WebhookFilterSet', ) -EXACT_FILTER_TYPES = ( - CustomFieldTypeChoices.TYPE_BOOLEAN, - CustomFieldTypeChoices.TYPE_DATE, - CustomFieldTypeChoices.TYPE_INTEGER, - CustomFieldTypeChoices.TYPE_SELECT, -) - class WebhookFilterSet(BaseFilterSet): content_types = ContentTypeFilter() diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index bc6458039..a889762f9 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -8,6 +8,7 @@ from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse from django.utils.safestring import mark_safe +from django_filters import filters from extras.choices import * from extras.utils import FeatureQuery, extras_features @@ -308,6 +309,53 @@ class CustomField(ChangeLoggedModel): return field + def to_filter(self): + """ + Return a django_filters Filter instance suitable for this field type. + """ + kwargs = { + 'field_name': f'custom_field_data__{self.name}' + } + + # Text/URL + if self.type in ( + CustomFieldTypeChoices.TYPE_TEXT, + CustomFieldTypeChoices.TYPE_LONGTEXT, + CustomFieldTypeChoices.TYPE_URL, + ): + filter_class = filters.CharFilter + if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: + kwargs['lookup_expr'] = 'icontains' + + # Integer + elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: + # TODO: Remove dirty hack to change lookup type from Decimal + filter_class = filters.NumberFilter + filter_class.field_class = forms.IntegerField + + # Boolean + elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + filter_class = filters.BooleanFilter + + # Date + elif self.type == CustomFieldTypeChoices.TYPE_DATE: + filter_class = filters.DateFilter + + # Select + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + filter_class = filters.CharFilter + + # Multiselect + elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + filter_class = filters.CharFilter + kwargs['lookup_expr'] = 'has_key' + + # Unsupported custom field type + else: + return None + + return filter_class(**kwargs) + def validate(self, value): """ Validate a value according to the field's type validation rules. diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 879f57bdc..ea0b9eda0 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -7,7 +7,7 @@ from django_filters.utils import get_model_field, resolve_field from dcim.forms import MACAddressField from extras.choices import CustomFieldFilterLogicChoices -from extras.filters import CustomFieldFilter, TagFilter +from extras.filters import TagFilter from extras.models import CustomField from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, @@ -15,7 +15,6 @@ from utilities.constants import ( ) from utilities import filters - __all__ = ( 'BaseFilterSet', 'ChangeLoggedModelFilterSet', @@ -222,8 +221,10 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): ) custom_field_filters = {} - for cf in custom_fields: - custom_field_filters[f'cf_{cf.name}'] = CustomFieldFilter(field_name=cf.name, custom_field=cf) + for custom_field in custom_fields: + cf_filter = custom_field.to_filter() + if cf_filter: + custom_field_filters[f'cf_{custom_field.name}'] = cf_filter self.filters.update(custom_field_filters) From 2e0f15b35fbe72ab1f9b890dd7740e6e94d81550 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Oct 2021 16:09:36 -0400 Subject: [PATCH 110/289] Automatically add additional lookup filters for custom fields --- netbox/extras/models/customfields.py | 11 +++++++++-- netbox/netbox/filtersets.py | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index a889762f9..5f60c6fea 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -309,13 +309,17 @@ class CustomField(ChangeLoggedModel): return field - def to_filter(self): + def to_filter(self, lookup_expr=None): """ Return a django_filters Filter instance suitable for this field type. + + :param lookup_expr: Custom lookup expression (optional) """ kwargs = { 'field_name': f'custom_field_data__{self.name}' } + if lookup_expr is not None: + kwargs['lookup_expr'] = lookup_expr # Text/URL if self.type in ( @@ -354,7 +358,10 @@ class CustomField(ChangeLoggedModel): else: return None - return filter_class(**kwargs) + filter_instance = filter_class(**kwargs) + filter_instance.custom_field = self + + return filter_instance def validate(self, value): """ diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index ea0b9eda0..fff08693e 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -84,6 +84,7 @@ class BaseFilterSet(django_filters.FilterSet): def _get_filter_lookup_dict(existing_filter): # Choose the lookup expression map based on the filter type if isinstance(existing_filter, ( + django_filters.NumberFilter, filters.MultiValueDateFilter, filters.MultiValueDateTimeFilter, filters.MultiValueNumberFilter, @@ -151,6 +152,10 @@ class BaseFilterSet(django_filters.FilterSet): distinct=existing_filter.distinct, **existing_filter.extra ) + elif hasattr(existing_filter, 'custom_field'): + # Filter is for a custom field + custom_field = existing_filter.custom_field + new_filter = custom_field.to_filter(lookup_expr=lookup_expr) else: # The filter field is listed in Meta.fields so we can safely rely on default behaviour # Will raise FieldLookupError if the lookup is invalid @@ -222,9 +227,14 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): custom_field_filters = {} for custom_field in custom_fields: - cf_filter = custom_field.to_filter() - if cf_filter: - custom_field_filters[f'cf_{custom_field.name}'] = cf_filter + filter_name = f'cf_{custom_field.name}' + filter_instance = custom_field.to_filter() + if filter_instance: + custom_field_filters[filter_name] = filter_instance + + # Add relevant additional lookups + additional_lookups = self.get_additional_lookups(filter_name, filter_instance) + custom_field_filters.update(additional_lookups) self.filters.update(custom_field_filters) From 1ce9192369f78de6edf388103f08622036b484b2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Oct 2021 16:26:31 -0400 Subject: [PATCH 111/289] Move MACAddressField to utilities --- netbox/dcim/forms/__init__.py | 1 - netbox/dcim/forms/fields.py | 25 ------------------------- netbox/netbox/filtersets.py | 2 +- netbox/utilities/filters.py | 2 +- netbox/utilities/forms/fields.py | 24 ++++++++++++++++++++++++ 5 files changed, 26 insertions(+), 28 deletions(-) delete mode 100644 netbox/dcim/forms/fields.py diff --git a/netbox/dcim/forms/__init__.py b/netbox/dcim/forms/__init__.py index 322abff9a..22f0b1204 100644 --- a/netbox/dcim/forms/__init__.py +++ b/netbox/dcim/forms/__init__.py @@ -1,4 +1,3 @@ -from .fields import * from .models import * from .filtersets import * from .object_create import * diff --git a/netbox/dcim/forms/fields.py b/netbox/dcim/forms/fields.py deleted file mode 100644 index 25a20667b..000000000 --- a/netbox/dcim/forms/fields.py +++ /dev/null @@ -1,25 +0,0 @@ -from django import forms -from netaddr import EUI -from netaddr.core import AddrFormatError - -__all__ = ( - 'MACAddressField', -) - - -class MACAddressField(forms.Field): - widget = forms.CharField - default_error_messages = { - 'invalid': 'MAC address must be in EUI-48 format', - } - - def to_python(self, value): - value = super().to_python(value) - - # Validate MAC address format - try: - value = EUI(value.strip()) - except AddrFormatError: - raise forms.ValidationError(self.error_messages['invalid'], code='invalid') - - return value diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index fff08693e..f42ab064b 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -5,7 +5,6 @@ from django.db import models from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field -from dcim.forms import MACAddressField from extras.choices import CustomFieldFilterLogicChoices from extras.filters import TagFilter from extras.models import CustomField @@ -13,6 +12,7 @@ from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP ) +from utilities.forms import MACAddressField from utilities import filters __all__ = ( diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 8dac65aac..fe4bae3b4 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,7 +3,7 @@ from django import forms from django.conf import settings from django_filters.constants import EMPTY_VALUES -from dcim.forms import MACAddressField +from utilities.forms import MACAddressField def multivalue_field_factory(field_class): diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 2561c2e22..332da9ed9 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -2,6 +2,7 @@ import csv import json import re from io import StringIO +from netaddr import AddrFormatError, EUI import django_filters from django import forms @@ -38,6 +39,7 @@ __all__ = ( 'ExpandableNameField', 'JSONField', 'LaxURLField', + 'MACAddressField', 'SlugField', 'TagFilterField', ) @@ -129,6 +131,28 @@ class JSONField(_JSONField): return json.dumps(value, sort_keys=True, indent=4) +class MACAddressField(forms.Field): + widget = forms.CharField + default_error_messages = { + 'invalid': 'MAC address must be in EUI-48 format', + } + + def to_python(self, value): + value = super().to_python(value) + + # Validate MAC address format + try: + value = EUI(value.strip()) + except AddrFormatError: + raise forms.ValidationError(self.error_messages['invalid'], code='invalid') + + return value + + +# +# Content type fields +# + class ContentTypeChoiceMixin: def __init__(self, queryset, *args, **kwargs): From 32205045bae2e894886737df78861711be6b9d3e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Oct 2021 16:40:51 -0400 Subject: [PATCH 112/289] Use multi-value filters for custom fields --- netbox/extras/models/customfields.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 5f60c6fea..84ba13263 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,6 +1,7 @@ import re from datetime import datetime, date +import django_filters from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField @@ -8,11 +9,11 @@ from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse from django.utils.safestring import mark_safe -from django_filters import filters from extras.choices import * from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel +from utilities import filters from utilities.forms import ( CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, ) @@ -327,31 +328,29 @@ class CustomField(ChangeLoggedModel): CustomFieldTypeChoices.TYPE_LONGTEXT, CustomFieldTypeChoices.TYPE_URL, ): - filter_class = filters.CharFilter + filter_class = filters.MultiValueCharFilter if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: kwargs['lookup_expr'] = 'icontains' # Integer elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: - # TODO: Remove dirty hack to change lookup type from Decimal - filter_class = filters.NumberFilter - filter_class.field_class = forms.IntegerField + filter_class = filters.MultiValueNumberFilter # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - filter_class = filters.BooleanFilter + filter_class = django_filters.BooleanFilter # Date elif self.type == CustomFieldTypeChoices.TYPE_DATE: - filter_class = filters.DateFilter + filter_class = filters.MultiValueDateFilter # Select elif self.type == CustomFieldTypeChoices.TYPE_SELECT: - filter_class = filters.CharFilter + filter_class = filters.MultiValueCharFilter # Multiselect elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - filter_class = filters.CharFilter + filter_class = filters.MultiValueCharFilter kwargs['lookup_expr'] = 'has_key' # Unsupported custom field type From 7c147db3241ce9c63ed3b9632c1fb9053baf22e5 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 16:08:32 -0500 Subject: [PATCH 113/289] #6732 - Fix test exception in Site form --- netbox/dcim/forms/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2d2b28ee0..3f88fee04 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -175,7 +175,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def __init__(self, data=None, instance=None, *args, **kwargs): super().__init__(data=data, instance=instance, *args, **kwargs) - self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) + if self.instance and self.instance.pk is not None: + self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) # Hide the ASN field if there is nothing there as this is deprecated if instance is None or \ From 696fe7bc0d70da66e5e109f3dcf47761a1f67109 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Oct 2021 09:45:48 -0400 Subject: [PATCH 114/289] Add tests for custom field lookups --- netbox/extras/tests/test_customfields.py | 89 +++++++++++++++++------- 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 0b51a4de3..5a9c4257f 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -719,7 +719,7 @@ class CustomFieldModelTest(TestCase): site.clean() -class CustomFieldFilterTest(TestCase): +class CustomFieldModelFilterTest(TestCase): queryset = Site.objects.all() filterset = SiteFilterSet @@ -772,7 +772,7 @@ class CustomFieldFilterTest(TestCase): cf.content_types.set([obj_type]) # Multiselect filtering - cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C']) + cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) cf.save() cf.content_types.set([obj_type]) @@ -783,49 +783,88 @@ class CustomFieldFilterTest(TestCase): 'cf3': 'foo', 'cf4': 'foo', 'cf5': '2016-06-26', - 'cf6': 'http://foo.example.com/', - 'cf7': 'http://foo.example.com/', + 'cf6': 'http://a.example.com', + 'cf7': 'http://a.example.com', 'cf8': 'Foo', - 'cf9': ['A', 'B'], + 'cf9': ['A', 'X'], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, - 'cf2': False, + 'cf2': True, 'cf3': 'foobar', 'cf4': 'foobar', 'cf5': '2016-06-27', - 'cf6': 'http://bar.example.com/', - 'cf7': 'http://bar.example.com/', + 'cf6': 'http://b.example.com', + 'cf7': 'http://b.example.com', 'cf8': 'Bar', - 'cf9': ['AA', 'B'], + 'cf9': ['B', 'X'], + }), + Site(name='Site 3', slug='site-3', custom_field_data={ + 'cf1': 300, + 'cf2': False, + 'cf3': 'bar', + 'cf4': 'bar', + 'cf5': '2016-06-28', + 'cf6': 'http://c.example.com', + 'cf7': 'http://c.example.com', + 'cf8': 'Baz', + 'cf9': ['C', 'X'], }), - Site(name='Site 3', slug='site-3'), ]) def test_filter_integer(self): - self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) def test_filter_boolean(self): - self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) - def test_filter_text(self): - self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2) + def test_filter_text_strict(self): + self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2) + + def test_filter_text_loose(self): + self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2) def test_filter_date(self): - self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) - def test_filter_url(self): - self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2) + def test_filter_url_strict(self): + self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) + + def test_filter_url_loose(self): + self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): - self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3) From 4099dd3a052c0c26970be98cfb7d56fc04dc2939 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Oct 2021 11:23:56 -0400 Subject: [PATCH 115/289] Closes #6615: Add filter lookups for custom fields --- docs/release-notes/version-3.1.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index f489f0966..d53f677d9 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -63,6 +63,7 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices * [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models +* [#6615](https://github.com/netbox-community/netbox/issues/6615) - Add filter lookups for custom fields * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations From d3364ef4d1c7d8fc1ae8e2aa92aa23f84f970d08 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:15:37 -0500 Subject: [PATCH 116/289] #6732 - Restore resolve_field to the filterset --- netbox/netbox/filtersets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 2240ce58d..91108a318 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -153,6 +153,7 @@ class BaseFilterSet(django_filters.FilterSet): # The filter field has been explicity defined on the filterset class so we must manually # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field + resolve_field(field, lookup_expr) new_filter = type(existing_filter)( field_name=field_name, lookup_expr=lookup_expr, From 43b983054ae1eecc2d918e15e6b16fd74122395c Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:26:19 -0500 Subject: [PATCH 117/289] #6732 - Corrected model field definitions --- netbox/ipam/migrations/0052_asn_model.py | 2 +- netbox/ipam/models/ip.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py index 4adafd411..1a69f0e42 100644 --- a/netbox/ipam/migrations/0052_asn_model.py +++ b/netbox/ipam/migrations/0052_asn_model.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('asn', dcim.fields.ASNField(blank=True, null=True, unique=True)), + ('asn', dcim.fields.ASNField(blank=False, null=False, 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')), ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 03fdbeae5..d61ad4c25 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -75,8 +75,8 @@ class ASN(PrimaryModel): asn = ASNField( unique=True, - blank=True, - null=True, + blank=False, + null=False, verbose_name='ASN', help_text='32-bit autonomous system number' ) From a30e7bf34f07dcf328907f6040ecf305a2829262 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:28:13 -0500 Subject: [PATCH 118/289] #6732 - Add ASN field back to bulk edit --- netbox/dcim/forms/bulk_edit.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 045dbf737..57c74cf84 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -7,6 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX from ipam.models import VLAN, ASN from tenancy.models import Tenant from utilities.forms import ( @@ -110,6 +111,12 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd queryset=Tenant.objects.all(), required=False ) + asn = forms.IntegerField( + min_value=BGP_ASN_MIN, + max_value=BGP_ASN_MAX, + required=False, + label='ASN' + ) asns = DynamicModelChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), @@ -127,7 +134,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd class Meta: nullable_fields = [ - 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone', ] From 3991115ae57f0866c81b451a2484ca652580c5bc Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:54:55 -0500 Subject: [PATCH 119/289] #6732 - Fix imports and other small items --- netbox/dcim/graphql/types.py | 4 ++-- netbox/dcim/tests/test_filtersets.py | 2 +- netbox/dcim/tests/test_views.py | 2 +- netbox/dcim/views.py | 2 +- netbox/ipam/api/serializers.py | 2 -- netbox/ipam/api/views.py | 1 - netbox/ipam/filtersets.py | 2 -- netbox/ipam/forms/bulk_import.py | 3 --- netbox/ipam/graphql/scalars.py | 5 ----- netbox/ipam/graphql/types.py | 4 ++-- netbox/ipam/tables/ip.py | 4 +--- 11 files changed, 8 insertions(+), 23 deletions(-) delete mode 100644 netbox/ipam/graphql/scalars.py diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8b9bd76ef..8ce10979e 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -5,7 +5,7 @@ from extras.graphql.mixins import ( ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin -from ipam.graphql.scalars import ASNField +from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -383,7 +383,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): - asn = graphene.Field(ASNField) + asn = graphene.Field(BigInt) class Meta: model = models.Site diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index eb37f061a..1b27a43e3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import IPAddress, RIR, ASN +from ipam.models import ASN, IPAddress, RIR from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9c446fc8b..dc22b18a0 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN, ASN, RIR +from ipam.models import ASN, VLAN, RIR from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e188ecfe5..a05f62621 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,7 +14,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView -from ipam.models import IPAddress, Prefix, Service, VLAN, ASN +from ipam.models import ASN, IPAddress, Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 28ce1575e..4b68c0c1b 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -19,8 +19,6 @@ from .nested_serializers import * # # ASNs # -from ..models import ASN - class ASNSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index e066e0f57..274ce29e8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -7,7 +7,6 @@ from ipam.models import * from netbox.api.views import ModelViewSet from utilities.utils import count_related from . import mixins, serializers -from ..models import ASN class IPAMRootView(APIRootView): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 81727edd1..1dd8f97d6 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -33,8 +33,6 @@ __all__ = ( 'VRFFilterSet', ) -from .models import ASN - class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 41604f0e4..1d18e94c7 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,13 +1,11 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.forms import IntegerField from dcim.models import Device, Interface, Site from extras.forms import CustomFieldModelCSVForm from ipam.choices import * from ipam.constants import * from ipam.models import * -from ipam.models import ASN from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from virtualization.models import VirtualMachine, VMInterface @@ -84,7 +82,6 @@ class AggregateCSVForm(CustomFieldModelCSVForm): class ASNCSVForm(CustomFieldModelCSVForm): - asn = IntegerField() rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', diff --git a/netbox/ipam/graphql/scalars.py b/netbox/ipam/graphql/scalars.py deleted file mode 100644 index d59375ba3..000000000 --- a/netbox/ipam/graphql/scalars.py +++ /dev/null @@ -1,5 +0,0 @@ -from netbox.graphql.scalars import BigInt - - -class ASNField(BigInt): - pass diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 71c7fd24e..3ba27fcf0 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,7 +1,7 @@ import graphene from ipam import filtersets, models -from ipam.graphql.scalars import ASNField +from netbox.graphql.scalars import BigInt from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -21,7 +21,7 @@ __all__ = ( class ASNType(PrimaryObjectType): - asn = graphene.Field(ASNField) + asn = graphene.Field(BigInt) class Meta: model = models.ASN diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 95376aad6..32937d17e 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -2,7 +2,6 @@ import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor -from ipam.models import ASN from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, @@ -99,7 +98,7 @@ class RIRTable(BaseTable): # -# RIRs +# ASNs # class ASNTable(BaseTable): @@ -112,7 +111,6 @@ class ASNTable(BaseTable): url_params={'asn_id': 'pk'}, verbose_name='Sites' ) - actions = ButtonsColumn(ASN) class Meta(BaseTable.Meta): From 87e07e731d51d50519f53edb26fa533520ceb2c2 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:56:58 -0500 Subject: [PATCH 120/289] #6732 - Removed ASN field hiding --- netbox/dcim/forms/models.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 3f88fee04..a9fdb3652 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -162,7 +162,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } help_texts = { 'name': "Full name of the site", - 'asn': "BGP autonomous system number. This field is depreciated in favour of the many-to-many field for ASNs", + 'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model", 'facility': "Data center provider and facility (e.g. Equinix NY7)", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", @@ -178,21 +178,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): if self.instance and self.instance.pk is not None: self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) - # Hide the ASN field if there is nothing there as this is deprecated - if instance is None or \ - (instance and (instance.asn is None or instance.asn == '')) or \ - (data and (data.get('asn') is None or instance.get('asn')) == ''): - if 'asn' in self.Meta.fieldsets[0][1]: - site_fieldset = list(self.Meta.fieldsets[0][1]) - index = site_fieldset.index('asn') - site_fieldset.pop(index) - self.Meta.fieldsets = ( - ('Site', tuple(site_fieldset)), - self.Meta.fieldsets[1], - self.Meta.fieldsets[2], - ) - del self.fields['asn'] - def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) instance.asns.set(self.cleaned_data['asns']) From 339776c139904671fcb745f527f780dc35093f2f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Oct 2021 17:06:14 -0400 Subject: [PATCH 121/289] Initial work on SSO support (WIP) --- base_requirements.txt | 8 ++++++++ netbox/netbox/middleware.py | 11 +---------- netbox/netbox/settings.py | 25 ++++++++++++++++++++++++- netbox/netbox/urls.py | 1 + netbox/templates/login.html | 8 ++++++++ netbox/users/views.py | 8 ++++++-- requirements.txt | 2 ++ 7 files changed, 50 insertions(+), 13 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 11ddac634..7295607f3 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -102,6 +102,14 @@ PyYAML # https://github.com/andymccurdy/redis-py redis +# Social authentication framework +# https://github.com/python-social-auth/social-core +social-auth-core[all] + +# Django app for social-auth-core +# https://github.com/python-social-auth/social-app-django +social-auth-app-django + # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite svgwrite diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 8d03c6aee..959b6b525 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -8,7 +8,6 @@ from django.contrib import auth from django.core.exceptions import ImproperlyConfigured from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect -from django.urls import reverse from extras.context_managers import change_logging from netbox.config import clear_config @@ -20,23 +19,15 @@ class LoginRequiredMiddleware: """ If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page. """ - def __init__(self, get_response): self.get_response = get_response def __call__(self, request): # Redirect unauthenticated requests (except those exempted) to the login page if LOGIN_REQUIRED is true if settings.LOGIN_REQUIRED and not request.user.is_authenticated: - # Determine exempt paths - exempt_paths = [ - reverse('api-root'), - reverse('graphql'), - ] - if settings.METRICS_ENABLED: - exempt_paths.append(reverse('prometheus-django-metrics')) # Redirect unauthenticated requests - if not request.path_info.startswith(tuple(exempt_paths)) and request.path_info != settings.LOGIN_URL: + if not request.path_info.startswith(settings.EXEMPT_PATHS): login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}' return HttpResponseRedirect(login_url) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 45475ef9a..6efd4d375 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -305,6 +305,7 @@ INSTALLED_APPS = [ 'graphene_django', 'mptt', 'rest_framework', + 'social_django', 'taggit', 'timezone_field', 'circuits', @@ -400,7 +401,8 @@ MESSAGE_TAGS = { } # Authentication URLs -LOGIN_URL = '/{}login/'.format(BASE_PATH) +LOGIN_URL = f'/{BASE_PATH}login/' +LOGIN_REDIRECT_URL = f'/{BASE_PATH}' CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS @@ -414,6 +416,27 @@ EXEMPT_EXCLUDE_MODELS = ( ('users', 'objectpermission'), ) +# All URLs starting with a string listed here are exempt from login enforcement +EXEMPT_PATHS = ( + f'/{BASE_PATH}api/', + f'/{BASE_PATH}graphql/', + f'/{BASE_PATH}login/', + f'/{BASE_PATH}oauth/', + f'/{BASE_PATH}metrics/', +) + + +# +# Django social auth +# + +# Load all SOCIAL_AUTH_* settings from the user configuration +for param in dir(configuration): + if param.startswith('SOCIAL_AUTH_'): + globals()[param] = getattr(configuration, param) + +SOCIAL_AUTH_JSONFIELD_ENABLED = True + # # Django Prometheus diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 4e0a2e2c6..e76efe0fe 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -39,6 +39,7 @@ _patterns = [ # Login/logout path('login/', LoginView.as_view(), name='login'), path('logout/', LogoutView.as_view(), name='logout'), + path('oauth/', include('social_django.urls', namespace='social')), # Apps path('circuits/', include('circuits.urls')), diff --git a/netbox/templates/login.html b/netbox/templates/login.html index a01d75422..b7f466c16 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -39,6 +39,14 @@ + {# TODO: Improve the design & layout #} + {% if auth_backends %} +
Or use an SSO provider:
+ {% for name, backend in auth_backends.items %} +

{{ name }}

+ {% endfor %} + {% endif %} + {# Login form errors #} {% if form.non_field_errors %} {% endif %} +
+
NHRP Groups
+
+ + + + + + + + + {% for assignment in object.fhrp_group_assignments.all %} + + + + + {% empty %} + + + + {% endfor %} + +
GroupPriority
+ {{ assignment.group }} + + {{ assignment.priority }} +
None
+
+ {% if perms.ipam.add_fhrpgroupassignment %} + + {% endif %} +
{% plugin_right_page object %} diff --git a/netbox/templates/inc/panels/nhrp_groups.html b/netbox/templates/inc/panels/nhrp_groups.html new file mode 100644 index 000000000..223354441 --- /dev/null +++ b/netbox/templates/inc/panels/nhrp_groups.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
+
Contacts
+
+ {% with fhrp_groups=object.fhrp_group_assignments.all %} + {% if contacts.exists %} + + + + + + + + {% for contact in contacts %} + + + + + + + {% endfor %} +
ProtocolGroup IDPriority
+ {{ contact.contact }} + {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} + {% if perms.tenancy.change_contactassignment %} + + + + {% endif %} + {% if perms.tenancy.delete_contactassignment %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.tenancy.add_contactassignment %} + + {% endif %} +
diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html new file mode 100644 index 000000000..c4e3eadc3 --- /dev/null +++ b/netbox/templates/ipam/fhrpgroup.html @@ -0,0 +1,82 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock breadcrumbs %} + +{% block content %} +
+
+
+
FHRP Group
+
+ + + + + + + + + + + + + + + + + +
Protocol{{ object.get_protocol_display }}
Group ID{{ object.group_id }}
Description{{ object.description|placeholder }}
Members{{ member_count }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+
+
Authentication
+
+ + + + + + + + + +
Authentication Type{{ object.get_auth_type_display|placeholder }}
Authentication Key{{ object.auth_key|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
IP Addresses
+
+ {% if ipaddress_table.rows %} + {% render_table ipaddress_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %} +
+
+
+
Members
+
+ {% include 'inc/table.html' with table=members_table %} +
+
+ {% include 'inc/paginator.html' with paginator=members_table.paginator page=members_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 31782bdd7..c39f4398a 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -73,12 +73,14 @@ Assignment - {% if object.assigned_object %} - {{ object.assigned_object.parent_object }} / - {{ object.assigned_object }} - {% else %} - + {% if object.assigned_object %} + {% if object.assigned_object.parent_object %} + {{ object.assigned_object.parent_object }} / {% endif %} + {{ object.assigned_object }} + {% else %} + + {% endif %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index db2404546..3567b86c5 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -398,6 +398,12 @@ class VMInterface(PrimaryModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) + fhrp_group_assignments = GenericRelation( + to='ipam.FHRPGroupAssignment', + content_type_field='content_type', + object_id_field='object_id', + related_query_name='vminterface' + ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 2294d2c38..5cb4f133a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -8,7 +8,7 @@ from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.tables import paginate_table from utilities.utils import count_related @@ -421,7 +421,7 @@ class VMInterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses - ipaddress_table = InterfaceIPAddressTable( + ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) From f48d7aedcee7a85d8c197152a63e80839151ff29 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 09:05:56 -0400 Subject: [PATCH 125/289] Enable filtering FHRP groups by related IP addresses --- netbox/ipam/filtersets.py | 24 ++++++++++ netbox/ipam/forms/models.py | 7 +++ netbox/ipam/migrations/0052_fhrpgroup.py | 2 +- netbox/ipam/models/fhrp.py | 1 + netbox/ipam/tables/fhrp.py | 18 +++++--- netbox/ipam/views.py | 8 ++-- netbox/templates/dcim/interface.html | 37 +-------------- netbox/templates/ipam/fhrpgroup.html | 8 ++-- .../ipam/inc/panels/fhrp_groups.html | 45 +++++++++++++++++++ .../templates/virtualization/vminterface.html | 3 +- 10 files changed, 101 insertions(+), 52 deletions(-) create mode 100644 netbox/templates/ipam/inc/panels/fhrp_groups.html diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 89bb61c02..5d385c7ef 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -624,6 +624,10 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): auth_type = django_filters.MultipleChoiceFilter( choices=FHRPGroupAuthTypeChoices ) + related_ip = django_filters.ModelMultipleChoiceFilter( + queryset=IPAddress.objects.all(), + method='filter_related_ip' + ) tag = TagFilter() class Meta: @@ -637,6 +641,26 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): Q(description__icontains=value) ) + def filter_related_ip(self, queryset, name, value): + """ + Filter by VRF & prefix of assigned IP addresses. + """ + ip_filter = Q() + for ipaddress in value: + if ipaddress.vrf: + q = Q( + ip_addresses__address__net_contained_or_equal=ipaddress.address, + ip_addresses__vrf=ipaddress.vrf + ) + else: + q = Q( + ip_addresses__address__net_contained_or_equal=ipaddress.address, + ip_addresses__vrf__isnull=True + ) + ip_filter |= q + + return queryset.filter(ip_filter) + class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): content_type = ContentTypeFilter() diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 36a078071..c605a7b7c 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -546,6 +546,13 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): model = FHRPGroupAssignment fields = ('group', 'priority') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + ipaddresses = self.instance.object.ip_addresses.all() + for ipaddress in ipaddresses: + self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk) + class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): scope_type = ContentTypeChoiceField( diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index f61191a7e..17d4ec9ca 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -44,7 +44,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('object_id', models.PositiveIntegerField()), - ('priority', models.PositiveSmallIntegerField(blank=True)), + ('priority', models.PositiveSmallIntegerField(blank=True, null=True)), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), ], diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index c108032b4..01ab6b5f8 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -82,6 +82,7 @@ class FHRPGroupAssignment(ChangeLoggedModel): ) priority = models.PositiveSmallIntegerField( blank=True, + null=True ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index e3411cd7e..8aae4bba7 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,8 +1,6 @@ import django_tables2 as tables -from utilities.tables import ( - BaseTable, ContentTypeColumn, MarkdownColumn, TagColumn, ToggleColumn, -) +from utilities.tables import BaseTable, ButtonsColumn, MarkdownColumn, TagColumn, ToggleColumn from ipam.models import * __all__ = ( @@ -47,8 +45,11 @@ class FHRPGroupTable(BaseTable): class FHRPGroupAssignmentTable(BaseTable): pk = ToggleColumn() - content_type = ContentTypeColumn( - verbose_name='Object Type' + object_parent = tables.Column( + accessor=tables.A('object.parent_object'), + linkify=True, + orderable=False, + verbose_name='Parent' ) object = tables.Column( linkify=True, @@ -57,8 +58,11 @@ class FHRPGroupAssignmentTable(BaseTable): group = tables.Column( linkify=True ) + actions = ButtonsColumn( + model=FHRPGroupAssignment, + buttons=('edit', 'delete', 'foo') + ) class Meta(BaseTable.Meta): model = FHRPGroupAssignment - fields = ('pk', 'content_type', 'object', 'group', 'priority') - default_columns = ('pk', 'content_type', 'object', 'group', 'priority') + fields = ('pk', 'group', 'object_parent', 'object', 'priority') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index b4864577d..8746787fe 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -849,12 +849,12 @@ class FHRPGroupView(generic.ObjectView): orderable=False ) - group_assignments = FHRPGroupAssignment.objects.restrict(request.user, 'view').filter( - group=instance + # Get assigned interfaces + members_table = tables.FHRPGroupAssignmentTable( + data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance), + orderable=False ) - members_table = tables.FHRPGroupAssignmentTable(group_assignments) members_table.columns.hide('group') - paginate_table(members_table, request) return { 'ipaddress_table': ipaddress_table, diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index a6dc1a901..f4ab30e4d 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -440,42 +440,7 @@ {% endif %} - + {% include 'ipam/inc/panels/fhrp_groups.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index c4e3eadc3..7f0b5d56e 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -72,10 +72,12 @@
Members
- {% include 'inc/table.html' with table=members_table %} + {% if ipaddress_table.rows %} + {% render_table members_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %}
-
- {% include 'inc/paginator.html' with paginator=members_table.paginator page=members_table.page %} {% plugin_full_width_page object %} diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html new file mode 100644 index 000000000..b3168b61c --- /dev/null +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -0,0 +1,45 @@ +{% load helpers %} + +
+
NHRP Groups
+
+ + + + + + + + + + {% for assignment in object.fhrp_group_assignments.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
GroupVirtual IPsPriority
+ {{ assignment.group }} + + {% for ipaddress in assignment.group.ip_addresses.all %} + {{ ipaddress }} + {% if not forloop.last %}
{% endif %} + {% endfor %} +
+ {{ assignment.priority }} +
None
+
+ {% if perms.ipam.add_fhrpgroupassignment %} + + {% endif %} +
diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 2646686e8..2a201bf85 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -76,11 +76,12 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} + {% include 'ipam/inc/panels/fhrp_groups.html' %} {% plugin_right_page object %}
From b2dc6c5d3db4a2ea0c42cb873cbaf0ee10e71c73 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 11:49:10 -0400 Subject: [PATCH 126/289] Changelog & initial docs for #7649 --- docs/administration/authentication.md | 37 +++++++++++++++++++++++++++ docs/administration/permissions.md | 2 +- docs/release-notes/version-3.1.md | 4 +++ mkdocs.yml | 1 + 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 docs/administration/authentication.md diff --git a/docs/administration/authentication.md b/docs/administration/authentication.md new file mode 100644 index 000000000..31983be0b --- /dev/null +++ b/docs/administration/authentication.md @@ -0,0 +1,37 @@ +# Authentication + +## Local Authentication + +Local user accounts and groups can be created in NetBox under the "Authentication and Authorization" section of the administrative user interface. This interface is available only to users with the "staff" permission enabled. + +At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](./permissions.md) may also be assigned to users and/or groups within the admin UI. + +## Remote Authentication + +NetBox may be configured to provide user authenticate via a remote backend in addition to local authentication. This is done by setting the `REMOTE_AUTH_BACKEND` configuration parameter to a suitable backend class. NetBox provides several options for remote authentication. + +### LDAP Authentication + +```python +REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' +``` + +NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../installation/6-ldap.md) for more detail about this backend. + +### HTTP Header Authentication + +```python +REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' +``` + +Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter. + +### Single Sign-On (SSO) + +```python +REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2' +``` + +NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options. + +Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index 97b691c1d..f859266af 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -1,6 +1,6 @@ # Permissions -NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. +NetBox v2.9 introduced a new object-based permissions framework, which replaces Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. {!models/users/objectpermission.md!} diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index d53f677d9..ff615a92b 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -57,6 +57,10 @@ A `bridge` field has been added to the interface model for devices and virtual m Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect. +#### Single Sign-On (SSO) Authentication ([#7649](https://github.com/netbox-community/netbox/issues/7649)) + +Support for single sign-on (SSO) authentication has been added via the [python-social-auth](https://github.com/python-social-auth) library. NetBox administrators can configure one of the [supported authentication backends](https://python-social-auth.readthedocs.io/en/latest/intro.html#auth-providers) to enable SSO authentication for users. + ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces diff --git a/mkdocs.yml b/mkdocs.yml index 52efdd656..84a7d1033 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,6 +84,7 @@ nav: - Using Plugins: 'plugins/index.md' - Developing Plugins: 'plugins/development.md' - Administration: + - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' From 8c27ff38590752656f049aef44bef6e6cd39572d Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Nov 2021 11:07:19 -0500 Subject: [PATCH 127/289] #6732 - Add ASN back to filtersets --- netbox/dcim/filtersets.py | 8 ++--- netbox/dcim/tests/test_filtersets.py | 4 +++ netbox/utilities/tests/test_filters.py | 44 +++++++++----------------- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c58e9d17e..aad02592e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -131,12 +131,12 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Group (slug)', ) - asn_id = django_filters.ModelMultipleChoiceFilter( + asns_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), label='AS (ID)', ) - asn = django_filters.ModelMultipleChoiceFilter( + asns = django_filters.ModelMultipleChoiceFilter( field_name='asns__asn', queryset=ASN.objects.all(), to_field_name='asn', @@ -147,7 +147,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] @@ -167,7 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) try: qs_filter |= Q(asn=int(value.strip())) - qs_filter |= Q(asns=int(value.strip())) + qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass return queryset.filter(qs_filter) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1b27a43e3..d05d2b2f2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -182,6 +182,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'asn': [65001, 65002]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_asns(self): + params = {'asns': [65001, 65002]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_latitude(self): params = {'latitude': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index e4609ef9b..2616dbf36 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -347,20 +347,6 @@ class DynamicFilterLookupExpressionTest(TestCase): @classmethod def setUpTestData(cls): - provider = Provider.objects.create(name='Test Provider', slug='test-provider') - circuit_type = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') - - circuits = ( - Circuit(cid='CID12123', provider=provider, type=circuit_type, - status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=1000), - Circuit(cid='CID12124', provider=provider, type=circuit_type, - status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=10000), - Circuit(cid='CID12125', provider=provider, type=circuit_type, - status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=100000) - - ) - Circuit.objects.bulk_create(circuits) - manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -398,9 +384,9 @@ class DynamicFilterLookupExpressionTest(TestCase): region.save() sites = ( - Site(name='Site 1', slug='abc-site-1', region=regions[0]), - Site(name='Site 2', slug='def-site-2', region=regions[1]), - Site(name='Site 3', slug='ghi-site-3', region=regions[2]), + Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), + Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), + Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), ) Site.objects.bulk_create(sites) @@ -469,21 +455,21 @@ class DynamicFilterLookupExpressionTest(TestCase): params = {'slug__niew': ['-1']} self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) - def test_circuit_commit_lt(self): - params = {'commit_rate__lt': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) + def test_site_asn_lt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) - def test_circuit_commit_lte(self): - params = {'commit_rate__lte': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) + def test_site_asn_lte(self): + params = {'asn__lte': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) - def test_circuit_commit_gt(self): - params = {'commit_rate__gt': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) + def test_site_asn_gt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) - def test_circuit_commit_gte(self): - params = {'commit_rate__gte': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) + def test_site_asn_gte(self): + params = {'asn__gte': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} From 5d0a7cb3079db63d9fa0efa2df32105102855bf4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Nov 2021 11:10:50 -0500 Subject: [PATCH 128/289] #6732 - Remove migration --- netbox/ipam/migrations/0052_asn_model.py | 40 ------------------------ 1 file changed, 40 deletions(-) delete mode 100644 netbox/ipam/migrations/0052_asn_model.py diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py deleted file mode 100644 index 1a69f0e42..000000000 --- a/netbox/ipam/migrations/0052_asn_model.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-25 04:34 - -import dcim.fields -import django.core.serializers.json -from django.db import migrations, models -import django.db.models.deletion -import taggit.managers - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0062_clear_secrets_changelog'), - ('tenancy', '0003_contacts'), - ('dcim', '0137_relax_uniqueness_constraints'), - ('ipam', '0051_extend_tag_support'), - ] - - operations = [ - migrations.CreateModel( - name='ASN', - fields=[ - ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('asn', dcim.fields.ASNField(blank=False, null=False, 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')), - ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), - ('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='asns', to='tenancy.tenant')), - ], - options={ - 'verbose_name': 'ASN', - 'verbose_name_plural': 'ASNs', - 'ordering': ['asn'], - }, - ), - ] From aeb4996ac2f50c8c9f298a7e598b3f9ae744b0f1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 13:06:58 -0400 Subject: [PATCH 129/289] Allow users to create new FHRP group directly from the interface view --- netbox/ipam/views.py | 11 +++++++++++ netbox/templates/ipam/inc/panels/fhrp_groups.html | 13 +++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8746787fe..14849c91f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,6 +3,7 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from dcim.models import Device, Interface from netbox.views import generic @@ -867,6 +868,16 @@ class FHRPGroupEditView(generic.ObjectEditView): queryset = FHRPGroup.objects.all() model_form = forms.FHRPGroupForm + def get_return_url(self, request, obj=None): + return_url = super().get_return_url(request, obj) + + # If we're redirecting the user to the FHRPGroupAssignment creation form, + # initialize the group field with the FHRPGroup we just saved. + if return_url.startswith(reverse('ipam:fhrpgroupassignment_add')): + return_url += f'&group={obj.pk}' + + return return_url + class FHRPGroupDeleteView(generic.ObjectDeleteView): queryset = FHRPGroup.objects.all() diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html index b3168b61c..3ed4f1761 100644 --- a/netbox/templates/ipam/inc/panels/fhrp_groups.html +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -35,11 +35,16 @@ - {% if perms.ipam.add_fhrpgroupassignment %} - From 93da5a39be9613e9cd49ce313d58e2a68629f307 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 13:15:23 -0400 Subject: [PATCH 130/289] Make assignment priority a required field --- netbox/ipam/constants.py | 8 ++++++++ netbox/ipam/migrations/0052_fhrpgroup.py | 3 ++- netbox/ipam/models/fhrp.py | 8 ++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 1c370a65d..fdb1dc6d9 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -52,6 +52,14 @@ IPADDRESS_ROLES_NONUNIQUE = ( ) +# +# FHRP groups +# + +FHRPGROUPASSIGNMENT_PRIORITY_MIN = 0 +FHRPGROUPASSIGNMENT_PRIORITY_MAX = 255 + + # # VLANs # diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 17d4ec9ca..9a3f41aab 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -1,4 +1,5 @@ import django.core.serializers.json +import django.core.validators from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -44,7 +45,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('object_id', models.PositiveIntegerField()), - ('priority', models.PositiveSmallIntegerField(blank=True, null=True)), + ('priority', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(255)])), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), ], diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 01ab6b5f8..3544c0a00 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -1,11 +1,13 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from extras.utils import extras_features from netbox.models import ChangeLoggedModel, PrimaryModel from ipam.choices import * +from ipam.constants import * from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -81,8 +83,10 @@ class FHRPGroupAssignment(ChangeLoggedModel): on_delete=models.CASCADE ) priority = models.PositiveSmallIntegerField( - blank=True, - null=True + validators=( + MinValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MIN), + MaxValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MAX) + ) ) objects = RestrictedQuerySet.as_manager() From 7625a2dd3c474fc41bc0b8342422e097ff8492a8 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Nov 2021 12:26:06 -0500 Subject: [PATCH 131/289] #6732 - Swap ASN M2M to Site model and update some templates/filters --- netbox/dcim/forms/bulk_edit.py | 2 +- netbox/dcim/forms/models.py | 11 ------- netbox/dcim/migrations/0141_asn_model.py | 19 ++++++++++++ netbox/dcim/models/sites.py | 5 ++++ netbox/dcim/tests/test_filtersets.py | 2 +- netbox/dcim/views.py | 7 ++++- netbox/ipam/forms/models.py | 19 ++++++++++-- netbox/ipam/migrations/0052_asn_model.py | 38 ++++++++++++++++++++++++ netbox/ipam/models/ip.py | 6 +--- netbox/ipam/views.py | 7 ++--- netbox/templates/dcim/site.html | 14 +++++++++ netbox/templates/ipam/asn.html | 19 ++++++++++-- 12 files changed, 120 insertions(+), 29 deletions(-) create mode 100644 netbox/dcim/migrations/0141_asn_model.py create mode 100644 netbox/ipam/migrations/0052_asn_model.py diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 57c74cf84..453cead1c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -117,7 +117,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd required=False, label='ASN' ) - asns = DynamicModelChoiceField( + asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), required=False diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a9fdb3652..36c349740 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -172,17 +172,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'longitude': "Longitude in decimal format (xx.yyyyyy)" } - def __init__(self, data=None, instance=None, *args, **kwargs): - super().__init__(data=data, instance=instance, *args, **kwargs) - - if self.instance and self.instance.pk is not None: - self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) - - def save(self, *args, **kwargs): - instance = super().save(*args, **kwargs) - instance.asns.set(self.cleaned_data['asns']) - return instance - class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( diff --git a/netbox/dcim/migrations/0141_asn_model.py b/netbox/dcim/migrations/0141_asn_model.py new file mode 100644 index 000000000..7650679f1 --- /dev/null +++ b/netbox/dcim/migrations/0141_asn_model.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-11-02 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0052_asn_model'), + ('dcim', '0140_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='site', + name='asns', + field=models.ManyToManyField(blank=True, related_name='sites', to='ipam.ASN'), + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a978e69e6..79f8921d5 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -195,6 +195,11 @@ class Site(PrimaryModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) + asns = models.ManyToManyField( + to='ipam.ASN', + related_name='sites', + blank=True + ) time_zone = TimeZoneField( blank=True ) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d05d2b2f2..0cbd892f5 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -183,7 +183,7 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asns(self): - params = {'asns': [65001, 65002]} + params = {'asns': [64512, 65002]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_latitude(self): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a05f62621..9b8ac3e45 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -310,7 +310,6 @@ class SiteView(generic.ObjectView): def get_extra_context(self, request, instance): stats = { - 'asn_count': ASN.objects.restrict(request.user, 'view').filter(sites=instance).count(), 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), @@ -333,9 +332,15 @@ class SiteView(generic.ObjectView): cumulative=True ).restrict(request.user, 'view').filter(site=instance) + asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance) + asn_count = asns.count() + + stats.update({'asn_count': asn_count}) + return { 'stats': stats, 'locations': locations, + 'asns': asns, } diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index abf2aa4a1..ea00b6914 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -134,14 +134,18 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): label='Sites', required=False ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ASN fields = [ - 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description' + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' ] fieldsets = ( - ('ASN', ('asn', 'rir', 'sites', 'description')), + ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) help_texts = { @@ -152,6 +156,17 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'date_added': DatePicker(), } + def __init__(self, data=None, instance=None, *args, **kwargs): + super().__init__(data=data, instance=instance, *args, **kwargs) + + if self.instance and self.instance.pk is not None: + self.fields['sites'].initial = self.instance.sites.all().values_list('id', flat=True) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + instance.sites.set(self.cleaned_data['sites']) + return instance + class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py new file mode 100644 index 000000000..04eac76c3 --- /dev/null +++ b/netbox/ipam/migrations/0052_asn_model.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.8 on 2021-11-02 16:16 + +import dcim.fields +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ('extras', '0064_configrevision'), + ('ipam', '0051_extend_tag_support'), + ] + + operations = [ + migrations.CreateModel( + name='ASN', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('asn', dcim.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')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'ASN', + 'verbose_name_plural': 'ASNs', + 'ordering': ['asn'], + }, + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index d61ad4c25..ad707dda1 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -71,6 +71,7 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ASN(PrimaryModel): asn = ASNField( @@ -98,11 +99,6 @@ class ASN(PrimaryModel): blank=True, null=True ) - sites = models.ManyToManyField( - to='dcim.Site', - related_name='asns', - blank=True - ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 73b228ac4..7801eec23 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -214,13 +214,10 @@ class ASNView(generic.ObjectView): queryset = ASN.objects.all() def get_extra_context(self, request, instance): - sites_table = SiteTable( - list(instance.sites.all()), - orderable=False - ) + sites = instance.sites.restrict(request.user, 'view').all() return { - 'sites_table': sites_table, + 'sites': sites, } diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 0364dee64..308b09816 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -260,6 +260,20 @@ {% endif %} +
+
+ ASNs +
+
+ {% if asns %} + {% for asn in asns %} + {{ asn }} + {% endfor %} + {% else %} + None + {% endif %} +
+
{% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 8be09c660..8eafe7633 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -47,17 +47,30 @@ + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} +
+
+ Sites +
+
+ {% if sites %} + {% for site in sites %} + {{ site }} + {% endfor %} + {% else %} + None + {% endif %} +
+
{% plugin_right_page object %}
- {% include 'inc/panel_table.html' with table=sites_table heading='Sites' %} {% plugin_full_width_page object %}
From 2cb53a0f7efd62e14a06b5627096460c0f24c657 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 13:32:41 -0400 Subject: [PATCH 132/289] Clean up FHRP group templates, forms --- netbox/ipam/constants.py | 10 ++++- netbox/ipam/forms/models.py | 8 +--- netbox/ipam/views.py | 2 + netbox/templates/ipam/fhrpgroup.html | 2 +- netbox/templates/ipam/fhrpgroup_edit.html | 40 +++++++++++++++++++ .../ipam/fhrpgroupassignment_edit.html | 18 +++++++++ 6 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 netbox/templates/ipam/fhrpgroup_edit.html create mode 100644 netbox/templates/ipam/fhrpgroupassignment_edit.html diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index fdb1dc6d9..b19d4061b 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,6 +1,6 @@ from django.db.models import Q -from .choices import IPAddressRoleChoices +from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices # BGP ASN bounds BGP_ASN_MIN = 1 @@ -59,6 +59,14 @@ IPADDRESS_ROLES_NONUNIQUE = ( FHRPGROUPASSIGNMENT_PRIORITY_MIN = 0 FHRPGROUPASSIGNMENT_PRIORITY_MAX = 255 +FHRP_PROTOCOL_ROLE_MAPPINGS = { + FHRPGroupProtocolChoices.PROTOCOL_VRRP2: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_VRRP3: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, + FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, + FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, +} + # # VLANs diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index c605a7b7c..d421bdbcd 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -521,13 +521,7 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm): status=self.cleaned_data['ip_status'], assigned_object=instance ) - ipaddress.role = { - FHRPGroupProtocolChoices.PROTOCOL_VRRP2: IPAddressRoleChoices.ROLE_VRRP, - FHRPGroupProtocolChoices.PROTOCOL_VRRP3: IPAddressRoleChoices.ROLE_VRRP, - FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, - FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, - FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, - }[self.cleaned_data['protocol']] + ipaddress.role = FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']] ipaddress.save() # Check that the new IPAddress conforms with any assigned object-level permissions diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 14849c91f..d9bd1977a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -867,6 +867,7 @@ class FHRPGroupView(generic.ObjectView): class FHRPGroupEditView(generic.ObjectEditView): queryset = FHRPGroup.objects.all() model_form = forms.FHRPGroupForm + template_name = 'ipam/fhrpgroup_edit.html' def get_return_url(self, request, obj=None): return_url = super().get_return_url(request, obj) @@ -909,6 +910,7 @@ class FHRPGroupBulkDeleteView(generic.BulkDeleteView): class FHRPGroupAssignmentEditView(generic.ObjectEditView): queryset = FHRPGroupAssignment.objects.all() model_form = forms.FHRPGroupAssignmentForm + template_name = 'ipam/fhrpgroupassignment_edit.html' def alter_obj(self, instance, request, args, kwargs): if not instance.pk: diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index 7f0b5d56e..60d6a4bff 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -60,7 +60,7 @@
-
IP Addresses
+
Virtual IP Addresses
{% if ipaddress_table.rows %} {% render_table ipaddress_table 'inc/table.html' %} diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html new file mode 100644 index 000000000..858d265ab --- /dev/null +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -0,0 +1,40 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
FHRP Group
+
+ {% render_field form.protocol %} + {% render_field form.group_id %} + {% render_field form.description %} + {% render_field form.tags %} +
+ +
+
+
Authentication
+
+ {% render_field form.auth_type %} + {% render_field form.auth_key %} +
+ + {% if not form.instance.pk %} +
+
+
Virtual IP Address
+
+ {% render_field form.ip_vrf %} + {% render_field form.ip_address %} + {% render_field form.ip_status %} +
+ {% endif %} + + {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} + {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/fhrpgroupassignment_edit.html b/netbox/templates/ipam/fhrpgroupassignment_edit.html new file mode 100644 index 000000000..730d2a15a --- /dev/null +++ b/netbox/templates/ipam/fhrpgroupassignment_edit.html @@ -0,0 +1,18 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
FHRP Group Assignment
+
+
+ +
+ +
+
+ {% render_field form.group %} + {% render_field form.priority %} +
+{% endblock %} From 264652f2c38c17f13740f6115e930699b956f79c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 14:08:36 -0400 Subject: [PATCH 133/289] REST API optimizations --- netbox/ipam/api/serializers.py | 5 +++-- netbox/ipam/api/views.py | 4 ++-- netbox/ipam/models/fhrp.py | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e2a3c1954..525ea393e 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -98,12 +98,13 @@ class AggregateSerializer(PrimaryModelSerializer): class FHRPGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + ip_addresses = NestedIPAddressSerializer(many=True, read_only=True) class Meta: model = FHRPGroup fields = [ - 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 6199c0caf..dffe555e9 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -124,13 +124,13 @@ class IPAddressViewSet(CustomFieldModelViewSet): # class FHRPGroupViewSet(CustomFieldModelViewSet): - queryset = FHRPGroup.objects.prefetch_related('tags') + queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') serializer_class = serializers.FHRPGroupSerializer filterset_class = filtersets.FHRPGroupFilterSet class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): - queryset = FHRPGroupAssignment.objects.prefetch_related('group') + queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'object') serializer_class = serializers.FHRPGroupAssignmentSerializer filterset_class = filtersets.FHRPGroupAssignmentFilterSet diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 3544c0a00..ee5a9a2be 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -95,3 +95,6 @@ class FHRPGroupAssignment(ChangeLoggedModel): ordering = ('priority', 'pk') unique_together = ('content_type', 'object_id', 'group') verbose_name = 'FHRP group assignment' + + def __str__(self): + return f'{self.object}: {self.group} ({self.priority})' From 131e433880fc369290cd21208b4befc029ccaa6c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 15:10:02 -0400 Subject: [PATCH 134/289] Rename FHRPGroupAssignment object to interface --- netbox/dcim/models/device_components.py | 6 +++--- netbox/ipam/api/serializers.py | 15 ++++++++------- netbox/ipam/api/views.py | 2 +- netbox/ipam/filtersets.py | 8 ++++++-- netbox/ipam/forms/models.py | 2 +- netbox/ipam/migrations/0052_fhrpgroup.py | 8 ++++---- netbox/ipam/models/fhrp.py | 14 +++++++------- netbox/ipam/tables/fhrp.py | 12 ++++++------ netbox/ipam/views.py | 10 +++++----- netbox/templates/dcim/interface.html | 2 +- .../templates/ipam/fhrpgroupassignment_edit.html | 2 +- netbox/templates/ipam/inc/panels/fhrp_groups.html | 10 +++++++--- netbox/templates/virtualization/vminterface.html | 2 +- netbox/virtualization/models.py | 6 +++--- 14 files changed, 54 insertions(+), 45 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 0d01435a3..a957aba41 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -601,9 +601,9 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', - content_type_field='content_type', - object_id_field='object_id', - related_query_name='interface' + content_type_field='interface_type', + object_id_field='interface_id', + related_query_name='+' ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 525ea393e..25c2297ab 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -110,24 +110,25 @@ class FHRPGroupSerializer(PrimaryModelSerializer): class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') - content_type = ContentTypeField( + interface_type = ContentTypeField( queryset=ContentType.objects.all() ) - object = serializers.SerializerMethodField(read_only=True) + interface = serializers.SerializerMethodField(read_only=True) class Meta: model = FHRPGroupAssignment fields = [ - 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'priority', 'created', 'last_updated', + 'id', 'url', 'display', 'interface_type', 'interface_id', 'interface', 'priority', 'created', + 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_object(self, obj): - if obj.object is None: + def get_interface(self, obj): + if obj.interface is None: return None - serializer = get_serializer_for_model(obj.object, prefix='Nested') + serializer = get_serializer_for_model(obj.interface, prefix='Nested') context = {'request': self.context['request']} - return serializer(obj.object, context=context).data + return serializer(obj.interface, context=context).data # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dffe555e9..a0ad4f375 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -130,7 +130,7 @@ class FHRPGroupViewSet(CustomFieldModelViewSet): class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): - queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'object') + queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface') serializer_class = serializers.FHRPGroupAssignmentSerializer filterset_class = filtersets.FHRPGroupAssignmentFilterSet diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 5d385c7ef..db2f5aaea 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -663,11 +663,15 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): - content_type = ContentTypeFilter() + interface_type = ContentTypeFilter() + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=FHRPGroup.objects.all(), + label='Group (ID)', + ) class Meta: model = FHRPGroupAssignment - fields = ['id', 'content_type_id', 'priority'] + fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] class VLANGroupFilterSet(OrganizationalModelFilterSet): diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d421bdbcd..70094a07a 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -543,7 +543,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - ipaddresses = self.instance.object.ip_addresses.all() + ipaddresses = self.instance.interface.ip_addresses.all() for ipaddress in ipaddresses: self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk) diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 9a3f41aab..976084b47 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -8,8 +8,8 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('extras', '0064_configrevision'), ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0064_configrevision'), ('ipam', '0051_extend_tag_support'), ] @@ -44,15 +44,15 @@ class Migration(migrations.Migration): ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('object_id', models.PositiveIntegerField()), + ('interface_id', models.PositiveIntegerField()), ('priority', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(255)])), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), + ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'verbose_name': 'FHRP group assignment', 'ordering': ('priority', 'pk'), - 'unique_together': {('content_type', 'object_id', 'group')}, + 'unique_together': {('interface_type', 'interface_id', 'group')}, }, ), ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index ee5a9a2be..95c907cfd 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -69,14 +69,14 @@ class FHRPGroup(PrimaryModel): @extras_features('webhooks') class FHRPGroupAssignment(ChangeLoggedModel): - content_type = models.ForeignKey( + interface_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() - object = GenericForeignKey( - ct_field='content_type', - fk_field='object_id' + interface_id = models.PositiveIntegerField() + interface = GenericForeignKey( + ct_field='interface_type', + fk_field='interface_id' ) group = models.ForeignKey( to='ipam.FHRPGroup', @@ -93,8 +93,8 @@ class FHRPGroupAssignment(ChangeLoggedModel): class Meta: ordering = ('priority', 'pk') - unique_together = ('content_type', 'object_id', 'group') + unique_together = ('interface_type', 'interface_id', 'group') verbose_name = 'FHRP group assignment' def __str__(self): - return f'{self.object}: {self.group} ({self.priority})' + return f'{self.interface}: {self.group} ({self.priority})' diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index 8aae4bba7..8a31694bf 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -27,8 +27,8 @@ class FHRPGroupTable(BaseTable): orderable=False, verbose_name='IP Addresses' ) - member_count = tables.Column( - verbose_name='Members' + interface_count = tables.Column( + verbose_name='Interfaces' ) tags = TagColumn( url_name='ipam:fhrpgroup_list' @@ -37,10 +37,10 @@ class FHRPGroupTable(BaseTable): class Meta(BaseTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', + 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', 'tags', ) - default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') + default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') class FHRPGroupAssignmentTable(BaseTable): @@ -51,7 +51,7 @@ class FHRPGroupAssignmentTable(BaseTable): orderable=False, verbose_name='Parent' ) - object = tables.Column( + interface = tables.Column( linkify=True, orderable=False ) @@ -65,4 +65,4 @@ class FHRPGroupAssignmentTable(BaseTable): class Meta(BaseTable.Meta): model = FHRPGroupAssignment - fields = ('pk', 'group', 'object_parent', 'object', 'priority') + fields = ('pk', 'group', 'object_parent', 'interface', 'priority') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d9bd1977a..8592fc931 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -914,24 +914,24 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView): def alter_obj(self, instance, request, args, kwargs): if not instance.pk: - # Assign the object based on URL kwargs + # Assign the interface based on URL kwargs try: - app_label, model = request.GET.get('content_type').split('.') + app_label, model = request.GET.get('interface_type').split('.') except (AttributeError, ValueError): raise Http404("Content type not specified") content_type = get_object_or_404(ContentType, app_label=app_label, model=model) - instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id')) return instance def get_return_url(self, request, obj=None): - return obj.object.get_absolute_url() if obj else super().get_return_url(request) + return obj.interface.get_absolute_url() if obj else super().get_return_url(request) class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): queryset = FHRPGroupAssignment.objects.all() def get_return_url(self, request, obj=None): - return obj.object.get_absolute_url() if obj else super().get_return_url(request) + return obj.interface.get_absolute_url() if obj else super().get_return_url(request) # diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index f4ab30e4d..811bf6257 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -459,7 +459,7 @@
{% if perms.ipam.add_ipaddress %} diff --git a/netbox/templates/ipam/fhrpgroupassignment_edit.html b/netbox/templates/ipam/fhrpgroupassignment_edit.html index 730d2a15a..5801febca 100644 --- a/netbox/templates/ipam/fhrpgroupassignment_edit.html +++ b/netbox/templates/ipam/fhrpgroupassignment_edit.html @@ -9,7 +9,7 @@
- +
{% render_field form.group %} diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html index 3ed4f1761..e5cb26104 100644 --- a/netbox/templates/ipam/inc/panels/fhrp_groups.html +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -7,6 +7,7 @@ Group + Protocol Virtual IPs Priority @@ -15,7 +16,10 @@ {% for assignment in object.fhrp_group_assignments.all %} - {{ assignment.group }} + {{ assignment.group.group_id }} + + + {{ assignment.group.get_protocol_display }} {% for ipaddress in assignment.group.ip_addresses.all %} @@ -37,12 +41,12 @@
{% if perms.ipam.add_ipaddress %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3567b86c5..08df36d4d 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -400,9 +400,9 @@ class VMInterface(PrimaryModel, BaseInterface): ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', - content_type_field='content_type', - object_id_field='object_id', - related_query_name='vminterface' + content_type_field='interface_type', + object_id_field='interface_id', + related_query_name='+' ) objects = RestrictedQuerySet.as_manager() From 412430e1c3eca4adb09050984bf7bf63793e5635 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 15:26:45 -0400 Subject: [PATCH 135/289] Docs & changelog for #6235 --- docs/core-functionality/ipam.md | 4 ++++ docs/models/ipam/fhrpgroup.md | 14 ++++++++++++++ docs/release-notes/version-3.1.md | 4 ++++ 3 files changed, 22 insertions(+) create mode 100644 docs/models/ipam/fhrpgroup.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index c1e77069e..dd05d6a01 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -17,3 +17,7 @@ {!models/ipam/vrf.md!} {!models/ipam/routetarget.md!} + +__ + +{!models/ipam/fhrpgroup.md!} diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md new file mode 100644 index 000000000..ec21ca37f --- /dev/null +++ b/docs/models/ipam/fhrpgroup.md @@ -0,0 +1,14 @@ +# FHRP Group + +A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to present a virtual IP address in a redundant manner. Example of such protocols include: + +* Hot Standby Router Protocol (HSRP) +* Virtual Router Redundancy Protocol (VRRP) +* Common Address Redundancy Protocol (CARP) +* Gateway Load Balancing Protocol (GLBP) + +NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses. + +## FHRP Group Assignments + +Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ff615a92b..65d3627e5 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -37,6 +37,10 @@ Dynamic configuration parameters may also still be defined within `configuration For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md). +#### First Hop Redundancy Protocol (FHRP) Groups ([#6235](https://github.com/netbox-community/netbox/issues/6235)) + +A new FHRP group model has been introduced to aid in modeling the configurations of protocols such as HSRP, VRRP, and GLBP. Each FHRP group may be assigned one or more virtual IP addresses, as well as an authentication type and key. Member device and VM interfaces may be associated with one or more FHRP groups, with each assignment receiving a numeric priority designation. + #### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON: From 25957bfae3086e769f22ee337311154182344f23 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 08:56:04 -0500 Subject: [PATCH 136/289] Fix migration issues --- netbox/ipam/migrations/{0052_asn_model.py => 0053_asn_model.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/ipam/migrations/{0052_asn_model.py => 0053_asn_model.py} (97%) diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py similarity index 97% rename from netbox/ipam/migrations/0052_asn_model.py rename to netbox/ipam/migrations/0053_asn_model.py index 04eac76c3..1c7ee8e23 100644 --- a/netbox/ipam/migrations/0052_asn_model.py +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0004_extend_tag_support'), ('extras', '0064_configrevision'), - ('ipam', '0051_extend_tag_support'), + ('ipam', '0052_fhrpgroup'), ] operations = [ From 0ec0185d844b251c04d190b8d51655ea3ca0c2b3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 09:51:03 -0500 Subject: [PATCH 137/289] Fix Migration --- netbox/dcim/migrations/0141_asn_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/migrations/0141_asn_model.py b/netbox/dcim/migrations/0141_asn_model.py index 7650679f1..6f011f35d 100644 --- a/netbox/dcim/migrations/0141_asn_model.py +++ b/netbox/dcim/migrations/0141_asn_model.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('ipam', '0052_asn_model'), + ('ipam', '0053_asn_model'), ('dcim', '0140_wireless'), ] From 76d73abd81dfb1653565b96d18401ed9a8ae3e9b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 10:04:26 -0500 Subject: [PATCH 138/289] Update ip.py --- netbox/ipam/tables/ip.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 5c41a3f0b..462fc8845 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -12,7 +12,6 @@ from ipam.models import * __all__ = ( 'AggregateTable', 'ASNTable', - 'InterfaceIPAddressTable', 'AssignedIPAddressesTable', 'IPAddressAssignTable', 'IPAddressTable', From 04d145d6d8957956369bdd224a852d7cd172fecf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 11:47:11 -0400 Subject: [PATCH 139/289] Add summary page to release notes --- docs/release-notes/index.md | 114 +++++++++++++++++++++++++++++- docs/release-notes/version-3.1.md | 2 +- mkdocs.yml | 2 + 3 files changed, 116 insertions(+), 2 deletions(-) mode change 120000 => 100644 docs/release-notes/index.md diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md deleted file mode 120000 index 247ba3e1d..000000000 --- a/docs/release-notes/index.md +++ /dev/null @@ -1 +0,0 @@ -version-3.0.md \ No newline at end of file diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md new file mode 100644 index 000000000..7caa1e3ab --- /dev/null +++ b/docs/release-notes/index.md @@ -0,0 +1,113 @@ +# Release Notes + +Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page. + +#### [Version 3.1](./version-3.1.md) (December 2021) + +* Contact Objects ([#1344](https://github.com/netbox-community/netbox/issues/1344)) +* Wireless Networks ([#3979](https://github.com/netbox-community/netbox/issues/3979)) +* Dynamic Configuration Updates ([#5883](https://github.com/netbox-community/netbox/issues/5883)) +* First Hop Redundancy Protocol (FHRP) Groups ([#6235](https://github.com/netbox-community/netbox/issues/6235)) +* Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) +* Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) +* Multiple ASNs per Site ([#6732](https://github.com/netbox-community/netbox/issues/6732)) +* Single Sign-On (SSO) Authentication ([#7649](https://github.com/netbox-community/netbox/issues/7649)) + +#### [Version 3.0](./version-3.0.md) (August 2021) + +* Updated User Interface ([#5893](https://github.com/netbox-community/netbox/issues/5893)) +* GraphQL API ([#2007](https://github.com/netbox-community/netbox/issues/2007)) +* IP Ranges ([#834](https://github.com/netbox-community/netbox/issues/834)) +* Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963)) +* SVG Cable Traces ([#6000](https://github.com/netbox-community/netbox/issues/6000)) +* New Views for Models Previously Under the Admin UI ([#6466](https://github.com/netbox-community/netbox/issues/6466)) +* REST API Token Provisioning ([#5264](https://github.com/netbox-community/netbox/issues/5264)) +* New Housekeeping Command ([#6590](https://github.com/netbox-community/netbox/issues/6590)) +* Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651)) + +#### [Version 2.11](./version-2.11.md) (April 2021) + +* Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151)) +* Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519)) +* Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451)) +* Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648)) +* Allow Assigning Devices to Locations ([#4971](https://github.com/netbox-community/netbox/issues/4971)) +* Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999)) +* Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284)) +* New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892)) +* Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913)) +* Provider Network Modeling ([#5986](https://github.com/netbox-community/netbox/issues/5986)) + +#### [Version 2.10](./version-2.10.md) (December 2020) + +* Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259)) +* REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436)) +* REST API Bulk Update ([#4882](https://github.com/netbox-community/netbox/issues/4882)) +* Reimplementation of Custom Fields ([#4878](https://github.com/netbox-community/netbox/issues/4878)) +* Improved Cable Trace Performance ([#4900](https://github.com/netbox-community/netbox/issues/4900)) + +#### [Version 2.9](./version-2.9.md) (August 2020) + +* Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554)) +* Background Execution of Scripts & Reports ([#2006](https://github.com/netbox-community/netbox/issues/2006)) +* Named Virtual Chassis ([#2018](https://github.com/netbox-community/netbox/issues/2018)) +* Changes to Tag Creation ([#3703](https://github.com/netbox-community/netbox/issues/3703)) +* Dedicated Model for VM Interfaces ([#4721](https://github.com/netbox-community/netbox/issues/4721)) +* REST API Endpoints for Users and Groups ([#4877](https://github.com/netbox-community/netbox/issues/4877)) + +#### [Version 2.8](./version-2.8.md) (April 2020) + +* Remote Authentication Support ([#2328](https://github.com/netbox-community/netbox/issues/2328)) +* Plugins ([#3351](https://github.com/netbox-community/netbox/issues/3351)) + +#### [Version 2.7](./version-2.7.md) (January 2020) + +* Enhanced Device Type Import ([#451](https://github.com/netbox-community/netbox/issues/451)) +* Bulk Import of Device Components ([#822](https://github.com/netbox-community/netbox/issues/822)) +* External File Storage ([#1814](https://github.com/netbox-community/netbox/issues/1814)) +* Rack Elevations Rendered via SVG ([#2248](https://github.com/netbox-community/netbox/issues/2248)) + +#### [Version 2.6](./version-2.6.md) (June 2019) + +* Power Panels and Feeds ([#54](https://github.com/netbox-community/netbox/issues/54)) +* Caching ([#2647](https://github.com/netbox-community/netbox/issues/2647)) +* View Permissions ([#323](https://github.com/netbox-community/netbox/issues/323)) +* Custom Links ([#969](https://github.com/netbox-community/netbox/issues/969)) +* Prometheus Metrics ([#3104](https://github.com/netbox-community/netbox/issues/3104)) + +#### [Version 2.5](./version-2.5.md) (December 2018) + +* Patch Panels and Cables ([#20](https://github.com/netbox-community/netbox/issues/20)) + +#### [Version 2.4](./version-2.4.md) (August 2018) + +* Webhooks ([#81](https://github.com/netbox-community/netbox/issues/81)) +* Tagging ([#132](https://github.com/netbox-community/netbox/issues/132)) +* Contextual Configuration Data ([#1349](https://github.com/netbox-community/netbox/issues/1349)) +* Change Logging ([#1898](https://github.com/netbox-community/netbox/issues/1898)) + +#### [Version 2.3](./version-2.3.md) (February 2018) + +* Virtual Chassis ([#99](https://github.com/netbox-community/netbox/issues/99)) +* Interface VLAN Assignments ([#150](https://github.com/netbox-community/netbox/issues/150)) +* Bulk Object Creation via the API ([#1553](https://github.com/netbox-community/netbox/issues/1553)) +* Automatic Provisioning of Next Available Prefixes ([#1694](https://github.com/netbox-community/netbox/issues/1694)) +* Bulk Renaming of Device/VM Components ([#1781](https://github.com/netbox-community/netbox/issues/1781)) + +#### [Version 2.2](./version-2.2.md) (October 2017) + +* Virtual Machines and Clusters ([#142](https://github.com/netbox-community/netbox/issues/142)) +* Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511)) + +#### [Version 2.1](./version-2.1.md) (July 2017) + +* IP Address Roles ([#819](https://github.com/netbox-community/netbox/issues/819)) +* Automatic Provisioning of Next Available IP ([#1246](https://github.com/netbox-community/netbox/issues/1246)) +* NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348)) + +#### [Version 2.0](./version-2.0.md) (May 2017) + +* API 2.0 ([#113](https://github.com/netbox-community/netbox/issues/113)) +* Image Attachments ([#152](https://github.com/netbox-community/netbox/issues/152)) +* Global Search ([#159](https://github.com/netbox-community/netbox/issues/159)) +* Rack Elevations View ([#951](https://github.com/netbox-community/netbox/issues/951)) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 65d3627e5..aa6b002e1 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -10,7 +10,7 @@ ### New Features -#### Contacts ([#1344](https://github.com/netbox-community/netbox/issues/1344)) +#### Contact Objects ([#1344](https://github.com/netbox-community/netbox/issues/1344)) A set of new models for tracking contact information has been introduced within the tenancy app. Users may now create individual contact objects to be associated with various models within NetBox. Each contact has a name, title, email address, etc. Contacts can be arranged in hierarchical groups for ease of management. diff --git a/mkdocs.yml b/mkdocs.yml index 84a7d1033..a13826ecf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,8 @@ nav: - Web UI: 'development/web-ui.md' - Release Checklist: 'development/release-checklist.md' - Release Notes: + - Summary: 'release-notes/index.md' + - Version 3.1: 'release-notes/version-3.1.md' - Version 3.0: 'release-notes/version-3.0.md' - Version 2.11: 'release-notes/version-2.11.md' - Version 2.10: 'release-notes/version-2.10.md' From cf9eaf2eff576041cff85d739aa22e1fd18ea2de Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 11:36:54 -0500 Subject: [PATCH 140/289] Fix dcim/views.py merge error --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7eef45f1b..f07bc6900 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -15,7 +15,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count From c72f25c69376bd423ab79b9026c58e11e9f97622 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 12:22:44 -0500 Subject: [PATCH 141/289] #6732 - Add documentation --- docs/core-functionality/ipam.md | 6 +++++- docs/models/ipam/asn.md | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 docs/models/ipam/asn.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index dd05d6a01..9fa5e0eb4 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -18,6 +18,10 @@ {!models/ipam/vrf.md!} {!models/ipam/routetarget.md!} -__ +--- {!models/ipam/fhrpgroup.md!} + +--- + +{!models/ipam/asn.md!} diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md new file mode 100644 index 000000000..cfef1da29 --- /dev/null +++ b/docs/models/ipam/asn.md @@ -0,0 +1,15 @@ +# ASN + +ASN is short for Autonomous System Number. This identifier is used in the BGP protocol to identify which "autonomous system" a particular prefix is originating and transiting through. + +The AS number model within NetBox allows you to model some of this real-world relationship. + +Within NetBox: + +* AS numbers are globally unique +* Each AS number must be associated with a RIR (ARIN, RFC 6996, etc) +* Each AS number can be associated with many different sites +* Each site can have many different AS numbers +* Each AS number can be assigned to a single tenant + + From 839afe5ac067f55ad585c8fc63601bbba4f8894f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 14:01:59 -0400 Subject: [PATCH 142/289] Improve webhook tests --- netbox/extras/tests/test_webhooks.py | 36 ++++++++++++++++++++++++++-- netbox/extras/webhooks_worker.py | 20 +++++++++++++--- netbox/templates/extras/webhook.html | 12 ++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 57db6dd02..811260f92 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -9,11 +9,12 @@ from django.urls import reverse from requests import Session from rest_framework import status +from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import Tag, Webhook -from extras.webhooks import enqueue_object, flush_webhooks, generate_signature -from extras.webhooks_worker import process_webhook +from extras.webhooks import enqueue_object, flush_webhooks, generate_signature, serialize_for_webhook +from extras.webhooks_worker import eval_conditions, process_webhook from utilities.testing import APITestCase @@ -251,6 +252,37 @@ class WebhookTest(APITestCase): self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name) self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo']) + def test_webhook_conditions(self): + # Create a conditional Webhook + webhook = Webhook( + name='Conditional Webhook', + type_create=True, + type_update=True, + payload_url='http://localhost/', + conditions={ + 'and': [ + { + 'attr': 'status.value', + 'value': 'active', + } + ] + } + ) + + # Create a Site to evaluate + site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_STAGING) + data = serialize_for_webhook(site) + + # Evaluate the conditions (status='staging') + self.assertFalse(eval_conditions(webhook, data)) + + # Change the site's status + site.status = SiteStatusChoices.STATUS_ACTIVE + data = serialize_for_webhook(site) + + # Evaluate the conditions (status='active') + self.assertTrue(eval_conditions(webhook, data)) + def test_webhooks_worker(self): request_id = uuid.uuid4() diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 6bbfba907..1f0a66b8a 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -12,15 +12,29 @@ from .webhooks import generate_signature logger = logging.getLogger('netbox.webhooks_worker') +def eval_conditions(webhook, data): + """ + Test whether the given data meets the conditions of the webhook (if any). Return True + if met or no conditions are specified. + """ + if not webhook.conditions: + return True + + logger.debug(f'Evaluating webhook conditions: {webhook.conditions}') + if ConditionSet(webhook.conditions).eval(data): + return True + + return False + + @job('default') def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id): """ Make a POST request to the defined Webhook """ # Evaluate webhook conditions (if any) - if webhook.conditions: - if not ConditionSet(webhook.conditions).eval(data): - return + if not eval_conditions(webhook, data): + return # Prepare context data for headers & body templates context = { diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html index c92ec4c99..9aa103cb6 100644 --- a/netbox/templates/extras/webhook.html +++ b/netbox/templates/extras/webhook.html @@ -132,6 +132,18 @@
+
+
+ Conditions +
+
+ {% if object.conditions %} +
{{ object.conditions|render_json }}
+ {% else %} +

None

+ {% endif %} +
+
Additional Headers From dcececf9c03af834f49b5f3be5af56bc3714d1a2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 14:02:43 -0400 Subject: [PATCH 143/289] Improve documentation and testing for conditions --- docs/reference/conditions.md | 37 ++++++++++++++++++++++++-- netbox/extras/conditions.py | 6 ++++- netbox/extras/tests/test_conditions.py | 10 +++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md index c335bf9a8..40b2ccb4b 100644 --- a/docs/reference/conditions.md +++ b/docs/reference/conditions.md @@ -23,14 +23,47 @@ A condition is expressed as a JSON object with the following keys: * `in`: Is present within a list of values * `contains`: Contains the specified value +### Accessing Nested Keys + +To access nested keys, use dots to denote the path to the desired attribute. For example, assume the following data: + +```json +{ + "a": { + "b": { + "c": 123 + } + } +} +``` + +The following condition will evaluate as true: + +```json +{ + "attr": "a.b.c", + "value": 123 +} +``` + ### Examples -`name` equals "foobar": +`name` equals "foo": ```json { "attr": "name", - "value": "foobar" + "value": "foo" +} +``` + +`name` does not equal "foo" + +```json +{ + "attr": "name", + "value": "foo", + "negate": true } ``` diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 6f1b012eb..965488c3a 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -64,7 +64,11 @@ class Condition: """ Evaluate the provided data to determine whether it matches the condition. """ - value = functools.reduce(dict.get, self.attr.split('.'), data) + try: + value = functools.reduce(dict.get, self.attr.split('.'), data) + except TypeError: + # Invalid key path + value = None result = self.eval_func(value) if self.negate: diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index ee6afeaf6..8e02eb75d 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -35,6 +35,16 @@ class ConditionTestCase(TestCase): # 'gt' supports only numeric values Condition('x', 'foo', 'gt') + # + # Nested attrs tests + # + + def test_nested(self): + c = Condition('x.y.z', 1) + self.assertTrue(c.eval({'x': {'y': {'z': 1}}})) + self.assertFalse(c.eval({'x': {'y': {'z': 2}}})) + self.assertFalse(c.eval({'a': {'b': {'c': 1}}})) + # # Operator tests # From 7a97d5d4ebc75f4bfe224284d3ac0e453f78eb0a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 14:40:14 -0400 Subject: [PATCH 144/289] Changelog for #6732; misc fixes --- docs/release-notes/version-3.1.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index aa6b002e1..5c039f38b 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -47,7 +47,7 @@ Webhooks now include a `conditions` field, which may be used to specify conditio ```json { - "attr": "status", + "attr": "status.value", "op": "in", "value": ["active", "staged"] } @@ -61,6 +61,12 @@ A `bridge` field has been added to the interface model for devices and virtual m Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect. +#### Multiple ASNs per Site ([#6732](https://github.com/netbox-community/netbox/issues/6732)) + +With the introduction of the new ASN model, NetBox now supports the assignment of multiple ASNs per site. Each ASN instance must have a 32-bit AS number, and may optionally be assigned to a RIR and/or Tenant. + +The `asn` integer field on the site model has been preserved to maintain backward compatability until a later release. + #### Single Sign-On (SSO) Authentication ([#7649](https://github.com/netbox-community/netbox/issues/7649)) Support for single sign-on (SSO) authentication has been added via the [python-social-auth](https://github.com/python-social-auth) library. NetBox administrators can configure one of the [supported authentication backends](https://python-social-auth.readthedocs.io/en/latest/intro.html#auth-providers) to enable SSO authentication for users. @@ -86,6 +92,8 @@ Support for single sign-on (SSO) authentication has been added via the [python-s ### REST API Changes +* Added the following endpoints for ASNs: + * `/api/ipam/asn/` * Added the following endpoints for contacts: * `/api/tenancy/contact-assignments/` * `/api/tenancy/contact-groups/` @@ -120,16 +128,19 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * Added `airflow` field * dcim.Interface * Added `bridge` field - * Added `rf_role` field * Added `rf_channel` field * Added `rf_channel_frequency` field - * Added `rf_chanel_width` field + * Added `rf_channel_width` field + * Added `rf_role` field * Added `tx_power` field + * Added `wireless_link` field * Added `wwn` field * `cable_peer` has been renamed to `link_peer` * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Location * Added `tenant` field +* dcim.Site + * Added `asns` relationship to ipam.ASN * extras.Webhook * Added the `conditions` field * virtualization.VMInterface From 7a55832a223ec8f92ec0b2514251a0ef13ee9e63 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 15:15:14 -0400 Subject: [PATCH 145/289] #6732: Add asns relationship to SiteSerializer and extend tests --- netbox/dcim/api/serializers.py | 18 +++++++++------ netbox/dcim/api/views.py | 3 +-- netbox/dcim/tests/test_api.py | 12 +++++++++- netbox/dcim/tests/test_views.py | 40 ++++++++++----------------------- netbox/ipam/api/serializers.py | 1 - netbox/ipam/models/ip.py | 10 ++++----- 6 files changed, 40 insertions(+), 44 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6fd67bf69..b85283bdb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -7,8 +6,8 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedASNSerializer -from ipam.models import VLAN +from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer +from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, @@ -113,13 +112,19 @@ class SiteSerializer(PrimaryModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - asns = NestedASNSerializer(many=True, required=False, allow_null=True) time_zone = TimeZoneSerializerField(required=False) + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=NestedASNSerializer, + required=False, + many=True + ) + + # Related object counts circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True) - asn_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) @@ -129,8 +134,7 @@ class SiteSerializer(PrimaryModelSerializer): 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'asn_count', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', - 'vlan_count', + 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e05ccaed2..b6685aba8 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -137,9 +137,8 @@ class SiteGroupViewSet(CustomFieldModelViewSet): class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.prefetch_related( - 'region', 'tenant', 'tags' + 'region', 'tenant', 'asns', 'tags' ).annotate( - asn_count=count_related(ASN, 'sites'), device_count=count_related(Device, 'site'), rack_count=count_related(Rack, 'site'), prefix_count=count_related(Prefix, 'site'), diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 042b4ce28..0c733cc18 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN +from ipam.models import ASN, RIR, VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterType @@ -143,6 +143,13 @@ class SiteTest(APIViewTestCases.APIViewTestCase): ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + cls.create_data = [ { 'name': 'Site 4', @@ -150,6 +157,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, + 'asns': [asns[0].pk, asns[1].pk], }, { 'name': 'Site 5', @@ -157,6 +165,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, + 'asns': [asns[2].pk, asns[3].pk], }, { 'name': 'Site 6', @@ -164,6 +173,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, + 'asns': [asns[4].pk, asns[5].pk], }, ] diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index dc22b18a0..ac1b93274 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, VLAN, RIR +from ipam.models import ASN, RIR, VLAN from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -110,41 +110,24 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for group in groups: group.save() + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + sites = Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), ]) + sites[0].asns.set([asns[0], asns[1]]) + sites[1].asns.set([asns[2], asns[3]]) + sites[2].asns.set([asns[4], asns[5]]) tags = create_tags('Alpha', 'Bravo', 'Charlie') - rir = RIR.objects.create(name='RFC 6996', is_private=True) - - asns = [ - ASN(asn=65000, rir=rir), - ASN(asn=65001, rir=rir), - ASN(asn=65002, rir=rir), - ASN(asn=65003, rir=rir), - ASN(asn=65004, rir=rir), - ASN(asn=65005, rir=rir), - ASN(asn=65006, rir=rir), - ASN(asn=65007, rir=rir), - ASN(asn=65008, rir=rir), - ASN(asn=65009, rir=rir), - ASN(asn=65010, rir=rir), - ] - ASN.objects.bulk_create(asns) - - asns[0].sites.set([sites[0]]) - asns[2].sites.set([sites[0]]) - asns[3].sites.set([sites[1]]) - asns[4].sites.set([sites[2]]) - asns[5].sites.set([sites[1]]) - asns[6].sites.set([sites[2]]) - asns[7].sites.set([sites[2]]) - asns[8].sites.set([sites[2]]) - asns[10].sites.set([sites[0]]) - cls.form_data = { 'name': 'Site X', 'slug': 'site-x', @@ -153,6 +136,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', + 'asns': [asns[6].pk, asns[7].pk], 'time_zone': pytz.UTC, 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index eae653ad7..28be07334 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -23,7 +23,6 @@ from .nested_serializers import * class ASNSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) - site_count = serializers.IntegerField(read_only=True) class Meta: diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index ad707dda1..b6c0a1b6b 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -73,11 +73,12 @@ class RIR(OrganizationalModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') 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, - blank=False, - null=False, verbose_name='ASN', help_text='32-bit autonomous system number' ) @@ -89,8 +90,7 @@ class ASN(PrimaryModel): to='ipam.RIR', on_delete=models.PROTECT, related_name='asns', - blank=False, - null=False + verbose_name='RIR' ) tenant = models.ForeignKey( to='tenancy.Tenant', From ea39c8a4c171ab28f36fb0162fe7ba9da10a82d9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 15:38:17 -0400 Subject: [PATCH 146/289] Optimize interface REST API endpoints --- netbox/dcim/api/views.py | 3 ++- netbox/virtualization/api/views.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b6685aba8..d99e28938 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -547,7 +547,8 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index d07ace3d5..0035a3c13 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) class VMInterfaceViewSet(ModelViewSet): queryset = VMInterface.objects.prefetch_related( - 'virtual_machine', 'parent', 'tags', 'tagged_vlans', 'ip_addresses' + 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'ip_addresses' ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filtersets.VMInterfaceFilterSet From 96cde7d4afb689e781ef806a8f3895d878f21e78 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 15:55:16 -0400 Subject: [PATCH 147/289] Add wireless_lans to InterfaceSerializer; extend tests --- netbox/dcim/api/serializers.py | 11 +++++++++-- netbox/dcim/tests/test_api.py | 10 ++++++++++ netbox/dcim/tests/test_views.py | 9 +++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b85283bdb..8e74099da 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -17,8 +17,9 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer -from wireless.api.nested_serializers import NestedWirelessLinkSerializer +from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer from wireless.choices import * +from wireless.models import WirelessLAN from .nested_serializers import * @@ -628,6 +629,12 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con ) cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) + wireless_lans = SerializedPKRelatedField( + queryset=WirelessLAN.objects.all(), + serializer=NestedWirelessLANSerializer, + required=False, + many=True + ) count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: @@ -636,7 +643,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', - 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', ] diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 0c733cc18..594bbddaf 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -9,6 +9,7 @@ from dcim.models import * from ipam.models import ASN, RIR, VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterType +from wireless.models import WirelessLAN class AppTest(APITestCase): @@ -1202,6 +1203,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ) VLAN.objects.bulk_create(vlans) + wireless_lans = ( + WirelessLAN(ssid='WLAN1'), + WirelessLAN(ssid='WLAN2'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + cls.create_data = [ { 'device': device.pk, @@ -1211,6 +1218,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'tx_power': 10, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], }, { 'device': device.pk, @@ -1221,6 +1229,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'tx_power': 10, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], }, { 'device': device.pk, @@ -1231,6 +1240,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'tx_power': 10, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], }, ] diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index ac1b93274..154ea03c3 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -14,6 +14,7 @@ from dcim.models import * from ipam.models import ASN, RIR, VLAN from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device +from wireless.models import WirelessLAN class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -1602,6 +1603,12 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VLAN.objects.bulk_create(vlans) + wireless_lans = ( + WirelessLAN(ssid='WLAN1'), + WirelessLAN(ssid='WLAN2'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -1620,6 +1627,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'tx_power': 10, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'tags': [t.pk for t in tags], } @@ -1638,6 +1646,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'tags': [t.pk for t in tags], } From 8bb9f4b8a2ac6a35b657a2445804489a66837fa3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 16:27:43 -0400 Subject: [PATCH 148/289] #1344: Add missing object field to ContactAssignmentSerializer --- netbox/tenancy/api/serializers.py | 11 ++++++++++- netbox/tenancy/api/views.py | 2 +- netbox/tenancy/filtersets.py | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 90c13725c..b97c3dac4 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,10 +1,12 @@ from django.contrib.auth.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from netbox.api import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import * +from utilities.api import get_serializer_for_model from .nested_serializers import * @@ -92,6 +94,7 @@ class ContactAssignmentSerializer(PrimaryModelSerializer): content_type = ContentTypeField( queryset=ContentType.objects.all() ) + object = serializers.SerializerMethodField(read_only=True) contact = NestedContactSerializer() role = NestedContactRoleSerializer(required=False, allow_null=True) priority = ChoiceField(choices=ContactPriorityChoices, required=False) @@ -99,6 +102,12 @@ class ContactAssignmentSerializer(PrimaryModelSerializer): class Meta: model = ContactAssignment fields = [ - 'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'priority', 'created', + 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'created', 'last_updated', ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_object(self, instance): + serializer = get_serializer_for_model(instance.content_type.model_class(), prefix='Nested') + context = {'request': self.context['request']} + return serializer(instance.object, context=context).data diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 8c7c33aba..50b188b5f 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -82,6 +82,6 @@ class ContactViewSet(CustomFieldModelViewSet): class ContactAssignmentViewSet(CustomFieldModelViewSet): - queryset = ContactAssignment.objects.prefetch_related('contact', 'role') + queryset = ContactAssignment.objects.prefetch_related('object', 'contact', 'role') serializer_class = serializers.ContactAssignmentSerializer filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index dd73edace..c8af89143 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -190,4 +190,4 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): class Meta: model = ContactAssignment - fields = ['id', 'content_type_id', 'priority'] + fields = ['id', 'content_type_id', 'object_id', 'priority'] From bbb98083ebf4e8407fba9fc47e8f1b4069fd6b66 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Nov 2021 16:58:31 -0400 Subject: [PATCH 149/289] Clean up filtersets --- netbox/dcim/filtersets.py | 2 +- netbox/ipam/filtersets.py | 4 +--- netbox/wireless/filtersets.py | 4 +++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index aad02592e..06b697502 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -173,7 +173,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) -class LocationFilterSet(OrganizationalModelFilterSet): +class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 6b6d14f5b..2e3b6b47b 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -9,7 +9,6 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet -from tenancy.models import Tenant from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) @@ -180,7 +179,6 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): - rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), label='RIR (ID)', @@ -210,7 +208,7 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): def search(self, queryset, name, value): if not value.strip(): return queryset - qs_filter = Q(Q(description__icontains=value) | Q(asn__icontains=value)) + qs_filter = Q(description__icontains=value) return queryset.filter(qs_filter) diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 654dd843f..3fb173b1b 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices from extras.filters import TagFilter from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet -from utilities.filters import TreeNodeMultipleChoiceFilter +from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -78,6 +78,8 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): method='search', label='Search', ) + interface_a_id = MultiValueNumberFilter() + interface_b_id = MultiValueNumberFilter() status = django_filters.MultipleChoiceFilter( choices=LinkStatusChoices ) From 67c73768c1820060d37612952700ee5f782d3b51 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 09:58:33 -0400 Subject: [PATCH 150/289] Add count_fhrp_groups to interface serializers --- docs/release-notes/version-3.1.md | 9 +++++++-- netbox/dcim/api/serializers.py | 9 ++++++--- netbox/dcim/api/views.py | 2 +- netbox/dcim/models/device_components.py | 4 ++++ netbox/ipam/api/nested_serializers.py | 4 ++++ netbox/virtualization/api/serializers.py | 3 ++- netbox/virtualization/api/views.py | 2 +- 7 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 5c039f38b..616313ef2 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -94,6 +94,9 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * Added the following endpoints for ASNs: * `/api/ipam/asn/` +* Added the following endpoints for FHRP groups: + * `/api/ipam/fhrp-groups/` + * `/api/ipam/fhrp-group-assignments/` * Added the following endpoints for contacts: * `/api/tenancy/contact-assignments/` * `/api/tenancy/contact-groups/` @@ -127,6 +130,8 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * dcim.DeviceType * Added `airflow` field * dcim.Interface + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * Added `bridge` field * Added `rf_channel` field * Added `rf_channel_frequency` field @@ -135,8 +140,7 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * Added `tx_power` field * Added `wireless_link` field * Added `wwn` field - * `cable_peer` has been renamed to `link_peer` - * `cable_peer_type` has been renamed to `link_peer_type` + * Added `count_fhrp_groups` read-only field * dcim.Location * Added `tenant` field * dcim.Site @@ -145,3 +149,4 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * Added the `conditions` field * virtualization.VMInterface * Added `bridge` field + * Added `count_fhrp_groups` read-only field diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8e74099da..f8e195a98 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,8 +6,10 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer -from ipam.models import ASN, VLAN +from ipam.api.nested_serializers import ( + NestedASNSerializer, NestedFHRPGroupAssignmentSerializer, NestedIPAddressSerializer, NestedVLANSerializer, +) +from ipam.models import ASN, FHRPGroupAssignment, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, @@ -636,6 +638,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con many=True ) count_ipaddresses = serializers.IntegerField(read_only=True) + count_fhrp_groups = serializers.IntegerField(read_only=True) class Meta: model = Interface @@ -645,7 +648,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - '_occupied', + 'count_fhrp_groups', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d99e28938..f359f0f24 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -548,7 +548,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'tags' + 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a957aba41..3896e5e83 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -498,6 +498,10 @@ class BaseInterface(models.Model): def count_ipaddresses(self): return self.ip_addresses.count() + @property + def count_fhrp_groups(self): + return self.fhrp_group_assignments.count() + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 885982afb..bb0c02a47 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -91,6 +91,10 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'protocol', 'group_id'] +class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') + + # # VLANs # diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 6cdc0e09a..866b8f9bb 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -117,13 +117,14 @@ class VMInterfaceSerializer(PrimaryModelSerializer): many=True ) count_ipaddresses = serializers.IntegerField(read_only=True) + count_fhrp_groups = serializers.IntegerField(read_only=True) class Meta: model = VMInterface fields = [ 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', - 'count_ipaddresses', + 'count_ipaddresses', 'count_fhrp_groups', ] def validate(self, data): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 0035a3c13..894045c1a 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) class VMInterfaceViewSet(ModelViewSet): queryset = VMInterface.objects.prefetch_related( - 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'ip_addresses' + 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filtersets.VMInterfaceFilterSet From c023e5f518afd0b271e47c86a176a351372d6db6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 10:05:47 -0400 Subject: [PATCH 151/289] Fix up FHRPGroupAssignmentTable --- netbox/ipam/tables/fhrp.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index 8a31694bf..94bc50b93 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -45,8 +45,8 @@ class FHRPGroupTable(BaseTable): class FHRPGroupAssignmentTable(BaseTable): pk = ToggleColumn() - object_parent = tables.Column( - accessor=tables.A('object.parent_object'), + interface_parent = tables.Column( + accessor=tables.A('interface.parent_object'), linkify=True, orderable=False, verbose_name='Parent' @@ -65,4 +65,5 @@ class FHRPGroupAssignmentTable(BaseTable): class Meta(BaseTable.Meta): model = FHRPGroupAssignment - fields = ('pk', 'group', 'object_parent', 'interface', 'priority') + fields = ('pk', 'group', 'interface_parent', 'interface', 'priority') + exclude = ('id',) From 3f0a98acbdeac3cd585cbca6afd579a4694a6c77 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 11:15:34 -0400 Subject: [PATCH 152/289] Add nested FHRPGroupAssignment serializer; add missing API tests --- netbox/dcim/api/serializers.py | 6 +- netbox/ipam/api/nested_serializers.py | 5 ++ netbox/ipam/api/serializers.py | 3 +- netbox/ipam/graphql/types.py | 4 +- netbox/ipam/tests/test_api.py | 83 ++++++++++++++++++++++++++- 5 files changed, 92 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f8e195a98..ede7e340f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,10 +6,8 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import ( - NestedASNSerializer, NestedFHRPGroupAssignmentSerializer, NestedIPAddressSerializer, NestedVLANSerializer, -) -from ipam.models import ASN, FHRPGroupAssignment, VLAN +from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer +from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index bb0c02a47..1eb66743b 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -7,6 +7,7 @@ __all__ = [ 'NestedAggregateSerializer', 'NestedASNSerializer', 'NestedFHRPGroupSerializer', + 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', 'NestedPrefixSerializer', @@ -94,6 +95,10 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer): class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') + class Meta: + model = models.FHRPGroupAssignment + fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority'] + # # VLANs diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 28be07334..aa1d2834a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -127,6 +127,7 @@ class FHRPGroupSerializer(PrimaryModelSerializer): class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + group = NestedFHRPGroupSerializer() interface_type = ContentTypeField( queryset=ContentType.objects.all() ) @@ -135,7 +136,7 @@ class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): class Meta: model = FHRPGroupAssignment fields = [ - 'id', 'url', 'display', 'interface_type', 'interface_id', 'interface', 'priority', 'created', + 'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created', 'last_updated', ] diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 72526b3bd..d9aec66b3 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -2,7 +2,7 @@ import graphene from ipam import filtersets, models from netbox.graphql.scalars import BigInt -from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( 'ASNType', @@ -50,7 +50,7 @@ class FHRPGroupType(PrimaryObjectType): return self.auth_type or None -class FHRPGroupAssignmentType(PrimaryObjectType): +class FHRPGroupAssignmentType(BaseObjectType): class Meta: model = models.FHRPGroupAssignment diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5ec0a0177..50eb64060 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -4,11 +4,11 @@ from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from utilities.testing import APITestCase, APIViewTestCases, disable_warnings +from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings class AppTest(APITestCase): @@ -585,6 +585,85 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase): ] +class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase): + model = FHRPGroupAssignment + brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url'] + bulk_update_data = { + 'priority': 100, + } + + @classmethod + def setUpTestData(cls): + + device1 = create_test_device('device1') + device2 = create_test_device('device2') + device3 = create_test_device('device3') + + interfaces = ( + Interface(device=device1, name='eth0', type='other'), + Interface(device=device1, name='eth1', type='other'), + Interface(device=device1, name='eth2', type='other'), + Interface(device=device2, name='eth0', type='other'), + Interface(device=device2, name='eth1', type='other'), + Interface(device=device2, name='eth2', type='other'), + Interface(device=device3, name='eth0', type='other'), + Interface(device=device3, name='eth1', type='other'), + Interface(device=device3, name='eth2', type='other'), + ) + Interface.objects.bulk_create(interfaces) + + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.2/24'), assigned_object=interfaces[0]), + IPAddress(address=IPNetwork('192.168.1.2/24'), assigned_object=interfaces[1]), + IPAddress(address=IPNetwork('192.168.2.2/24'), assigned_object=interfaces[2]), + IPAddress(address=IPNetwork('192.168.0.3/24'), assigned_object=interfaces[3]), + IPAddress(address=IPNetwork('192.168.1.3/24'), assigned_object=interfaces[4]), + IPAddress(address=IPNetwork('192.168.2.3/24'), assigned_object=interfaces[5]), + IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interfaces[6]), + IPAddress(address=IPNetwork('192.168.1.4/24'), assigned_object=interfaces[7]), + IPAddress(address=IPNetwork('192.168.2.4/24'), assigned_object=interfaces[8]), + ) + IPAddress.objects.bulk_create(ip_addresses) + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=20), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + fhrp_group_assignments = ( + FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[0], priority=10), + FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[1], priority=10), + FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[2], priority=10), + FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[3], priority=20), + FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[4], priority=20), + FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[5], priority=20), + ) + FHRPGroupAssignment.objects.bulk_create(fhrp_group_assignments) + + cls.create_data = [ + { + 'group': fhrp_groups[0].pk, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[6].pk, + 'priority': 30, + }, + { + 'group': fhrp_groups[1].pk, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[7].pk, + 'priority': 30, + }, + { + 'group': fhrp_groups[2].pk, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[8].pk, + 'priority': 30, + }, + ] + + class VLANGroupTest(APIViewTestCases.APIViewTestCase): model = VLANGroup brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] From 519884d167fe28cf08de9ca89bbbf5ff33bf04b0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 11:17:10 -0400 Subject: [PATCH 153/289] Invert default priority ordering for FHRPGroupAssignment --- netbox/ipam/migrations/0052_fhrpgroup.py | 2 +- netbox/ipam/models/fhrp.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 976084b47..70219543f 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -51,7 +51,7 @@ class Migration(migrations.Migration): ], options={ 'verbose_name': 'FHRP group assignment', - 'ordering': ('priority', 'pk'), + 'ordering': ('-priority', 'pk'), 'unique_together': {('interface_type', 'interface_id', 'group')}, }, ), diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 95c907cfd..42ab0b530 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -92,7 +92,7 @@ class FHRPGroupAssignment(ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() class Meta: - ordering = ('priority', 'pk') + ordering = ('-priority', 'pk') unique_together = ('interface_type', 'interface_id', 'group') verbose_name = 'FHRP group assignment' From ff3edc98895d7e72d1a0e0f1b1afe9890af1a9fb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 11:55:00 -0400 Subject: [PATCH 154/289] Add NestedContactAssignmentSerializer; add contact assignment API tests --- netbox/tenancy/api/nested_serializers.py | 11 ++++ netbox/tenancy/graphql/types.py | 4 +- netbox/tenancy/tests/test_api.py | 67 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index a072331f5..00ac6ff84 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -5,6 +5,7 @@ from tenancy.models import * __all__ = [ 'NestedContactSerializer', + 'NestedContactAssignmentSerializer', 'NestedContactGroupSerializer', 'NestedContactRoleSerializer', 'NestedTenantGroupSerializer', @@ -62,3 +63,13 @@ class NestedContactSerializer(WritableNestedSerializer): class Meta: model = Contact fields = ['id', 'url', 'display', 'name'] + + +class NestedContactAssignmentSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + contact = NestedContactSerializer() + role = NestedContactRoleSerializer + + class Meta: + model = ContactAssignment + fields = ['id', 'url', 'display', 'contact', 'role', 'priority'] diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index a16d51081..ce00eafa3 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,7 +1,7 @@ import graphene from tenancy import filtersets, models -from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( 'ContactAssignmentType', @@ -68,7 +68,7 @@ class ContactGroupType(OrganizationalObjectType): filterset_class = filtersets.ContactGroupFilterSet -class ContactAssignmentType(OrganizationalObjectType): +class ContactAssignmentType(BaseObjectType): class Meta: model = models.ContactAssignment diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c7c6cf846..467352588 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,5 +1,7 @@ from django.urls import reverse +from dcim.models import Site +from tenancy.choices import * from tenancy.models import * from utilities.testing import APITestCase, APIViewTestCases @@ -201,3 +203,68 @@ class ContactTest(APIViewTestCases.APIViewTestCase): 'group': contact_groups[1].pk, }, ] + + +class ContactAssignmentTest(APIViewTestCases.APIViewTestCase): + model = ContactAssignment + brief_fields = ['contact', 'display', 'id', 'priority', 'role', 'url'] + bulk_update_data = { + 'priority': ContactPriorityChoices.PRIORITY_INACTIVE, + } + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + contacts = ( + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), + Contact(name='Contact 4'), + Contact(name='Contact 5'), + Contact(name='Contact 6'), + ) + Contact.objects.bulk_create(contacts) + + contact_roles = ( + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ) + ContactRole.objects.bulk_create(contact_roles) + + contact_assignments = ( + ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0], priority=ContactPriorityChoices.PRIORITY_PRIMARY), + ContactAssignment(object=sites[0], contact=contacts[1], role=contact_roles[1], priority=ContactPriorityChoices.PRIORITY_SECONDARY), + ContactAssignment(object=sites[0], contact=contacts[2], role=contact_roles[2], priority=ContactPriorityChoices.PRIORITY_TERTIARY), + ) + ContactAssignment.objects.bulk_create(contact_assignments) + + cls.create_data = [ + { + 'content_type': 'dcim.site', + 'object_id': sites[1].pk, + 'contact': contacts[3].pk, + 'role': contact_roles[0].pk, + 'priority': ContactPriorityChoices.PRIORITY_PRIMARY, + }, + { + 'content_type': 'dcim.site', + 'object_id': sites[1].pk, + 'contact': contacts[4].pk, + 'role': contact_roles[1].pk, + 'priority': ContactPriorityChoices.PRIORITY_SECONDARY, + }, + { + 'content_type': 'dcim.site', + 'object_id': sites[1].pk, + 'contact': contacts[5].pk, + 'role': contact_roles[2].pk, + 'priority': ContactPriorityChoices.PRIORITY_TERTIARY, + }, + ] From 06f1d152838da9c8f433e5570a6af71b6091b19a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 13:22:21 -0400 Subject: [PATCH 155/289] Introduce create_test_virtualmachine() --- netbox/utilities/testing/utils.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index bec3bc762..466b5e22b 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -7,6 +7,7 @@ from django.utils.text import slugify from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from extras.models import Tag +from virtualization.models import Cluster, ClusterType, VirtualMachine def post_data(data): @@ -46,6 +47,17 @@ def create_test_device(name): return device +def create_test_virtualmachine(name): + """ + Convenience method for creating a VirtualMachine. + """ + cluster_type, _ = ClusterType.objects.get_or_create(name='Cluster Type 1', slug='cluster-type-1') + cluster, _ = Cluster.objects.get_or_create(name='Cluster 1', type=cluster_type) + virtual_machine = VirtualMachine.objects.create(name=name, cluster=cluster) + + return virtual_machine + + def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. From 734a00237a28620b92f8c84dfae3dece6c141092 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 13:23:02 -0400 Subject: [PATCH 156/289] Clean up FHRP group filterset tests --- netbox/ipam/filtersets.py | 2 +- netbox/ipam/tests/test_filtersets.py | 89 +++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 2e3b6b47b..df6ee1055 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -667,7 +667,7 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): class Meta: model = FHRPGroup - fields = ['id', 'protocol', 'group_id', 'auth_type'] + fields = ['id', 'group_id', 'auth_key'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 19b6a8e8f..773737dea 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1,10 +1,11 @@ from django.test import TestCase +from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.filtersets import * from ipam.models import * -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup @@ -879,12 +880,22 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.1.1/24')), + IPAddress(address=IPNetwork('192.168.2.1/24')), + IPAddress(address=IPNetwork('192.168.3.1/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + fhrp_groups = ( - FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), - FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foo123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='bar456'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), ) FHRPGroup.objects.bulk_create(fhrp_groups) + fhrp_groups[0].ip_addresses.set([ip_addresses[0]]) + fhrp_groups[1].ip_addresses.set([ip_addresses[1]]) + fhrp_groups[2].ip_addresses.set([ip_addresses[2]]) def test_protocol(self): params = {'protocol': [FHRPGroupProtocolChoices.PROTOCOL_VRRP2, FHRPGroupProtocolChoices.PROTOCOL_VRRP3]} @@ -898,6 +909,78 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'auth_type': [FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_auth_key(self): + params = {'auth_key': ['foo123', 'bar456']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_related_ip(self): + # Create some regular IPs to query for related IPs + ipaddresses = ( + IPAddress.objects.create(address=IPNetwork('192.168.1.2/24')), + IPAddress.objects.create(address=IPNetwork('192.168.2.2/24')), + ) + params = {'related_ip': [ipaddresses[0].pk, ipaddresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = FHRPGroupAssignment.objects.all() + filterset = FHRPGroupAssignmentFilterSet + + @classmethod + def setUpTestData(cls): + + device = create_test_device('device1') + interfaces = ( + Interface(device=device, name='eth0'), + Interface(device=device, name='eth1'), + Interface(device=device, name='eth2'), + ) + Interface.objects.bulk_create(interfaces) + + virtual_machine = create_test_virtualmachine('virtual_machine1') + vm_interfaces = ( + VMInterface(virtual_machine=virtual_machine, name='eth0'), + VMInterface(virtual_machine=virtual_machine, name='eth1'), + VMInterface(virtual_machine=virtual_machine, name='eth2'), + ) + VMInterface.objects.bulk_create(vm_interfaces) + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + fhrp_group_assignments = ( + FHRPGroupAssignment(group=fhrp_groups[0], interface=interfaces[0], priority=10), + FHRPGroupAssignment(group=fhrp_groups[1], interface=interfaces[1], priority=20), + FHRPGroupAssignment(group=fhrp_groups[2], interface=interfaces[2], priority=30), + FHRPGroupAssignment(group=fhrp_groups[0], interface=vm_interfaces[0], priority=10), + FHRPGroupAssignment(group=fhrp_groups[1], interface=vm_interfaces[1], priority=20), + FHRPGroupAssignment(group=fhrp_groups[2], interface=vm_interfaces[2], priority=30), + ) + FHRPGroupAssignment.objects.bulk_create(fhrp_group_assignments) + + def test_group_id(self): + fhrp_groups = FHRPGroup.objects.all()[:2] + params = {'group_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_interface_type(self): + params = {'interface_type': 'dcim.interface'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_interface(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_type': 'dcim.interface', 'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_priority(self): + params = {'priority': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANGroup.objects.all() From a5024a65a0b47bdb3c42783017e322373ea378b5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 13:32:52 -0400 Subject: [PATCH 157/289] Add warning for Python 3.7 --- netbox/netbox/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6efd4d375..e8ae1ef69 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -31,6 +31,10 @@ if platform.python_version_tuple() < ('3', '7'): raise RuntimeError( f"NetBox requires Python 3.7 or higher (current: Python {platform.python_version()})" ) +if platform.python_version_tuple() < ('3', '8'): + warnings.warn( + f"NetBox v3.2 will require Python 3.8 or later. (Currently installed: Python {platform.python_version()})" + ) # From 803e0bfe72fab200c8e54929448eb36cf8bb3a07 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 13:44:02 -0400 Subject: [PATCH 158/289] #1344: Show object type & name when assigning a contact --- .../tenancy/contactassignment_edit.html | 23 +++++++++++++++++++ netbox/tenancy/views.py | 1 + 2 files changed, 24 insertions(+) create mode 100644 netbox/templates/tenancy/contactassignment_edit.html diff --git a/netbox/templates/tenancy/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html new file mode 100644 index 000000000..4d1747e72 --- /dev/null +++ b/netbox/templates/tenancy/contactassignment_edit.html @@ -0,0 +1,23 @@ +{% extends 'generic/object_edit.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block form %} +
+
+
Contact Assignment
+
+
+ +
+ +
+
+ {% render_field form.group %} + {% render_field form.contact %} + {% render_field form.role %} + {% render_field form.priority %} +
+{% endblock %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index cdbaebdb1..604b3ed70 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -338,6 +338,7 @@ class ContactBulkDeleteView(generic.BulkDeleteView): class ContactAssignmentEditView(generic.ObjectEditView): queryset = ContactAssignment.objects.all() model_form = forms.ContactAssignmentForm + template_name = 'tenancy/contactassignment_edit.html' def alter_obj(self, instance, request, args, kwargs): if not instance.pk: From e4b0359b8ed580f14102f79a4a48dd3b458729ee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 14:01:07 -0400 Subject: [PATCH 159/289] Include action buttons on contact assignments table --- netbox/templates/tenancy/contact.html | 9 +++++++-- netbox/tenancy/tables.py | 8 ++++++-- netbox/tenancy/views.py | 8 ++++---- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 3c6ada5a0..af466cb30 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block breadcrumbs %} {{ block.super }} @@ -69,10 +70,14 @@
Assignments
- {% include 'inc/table.html' with table=contacts_table %} + {% if assignments_table.rows %} + {% render_table assignments_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %}
- {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %} + {% include 'inc/paginator.html' with paginator=assignments_table.paginator page=assignments_table.page %} {% plugin_full_width_page object %}
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 541087cff..ff9f826ae 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -160,8 +160,12 @@ class ContactAssignmentTable(BaseTable): role = tables.Column( linkify=True ) + actions = ButtonsColumn( + model=ContactAssignment, + buttons=('edit', 'delete') + ) class Meta(BaseTable.Meta): model = ContactAssignment - fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') - default_columns = ('pk', 'object', 'contact', 'role', 'priority') + fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') + default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 604b3ed70..dcef3d8be 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -293,12 +293,12 @@ class ContactView(generic.ObjectView): contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( contact=instance ) - contacts_table = tables.ContactAssignmentTable(contact_assignments) - contacts_table.columns.hide('contact') - paginate_table(contacts_table, request) + assignments_table = tables.ContactAssignmentTable(contact_assignments) + assignments_table.columns.hide('contact') + paginate_table(assignments_table, request) return { - 'contacts_table': contacts_table, + 'assignments_table': assignments_table, 'assignment_count': ContactAssignment.objects.filter(contact=instance).count(), } From 2e75a111ed0e72e599c74bc057ee3c51a4bbcc5e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 14:09:12 -0400 Subject: [PATCH 160/289] Fix return URLs for contact assignment edit/delete --- netbox/templates/inc/panels/contacts.html | 6 +++--- netbox/tenancy/views.py | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/netbox/templates/inc/panels/contacts.html b/netbox/templates/inc/panels/contacts.html index 33788a561..872c11153 100644 --- a/netbox/templates/inc/panels/contacts.html +++ b/netbox/templates/inc/panels/contacts.html @@ -21,12 +21,12 @@ {{ contact.get_priority_display|placeholder }} {% if perms.tenancy.change_contactassignment %} - + {% endif %} {% if perms.tenancy.delete_contactassignment %} - + {% endif %} @@ -41,7 +41,7 @@ {% if perms.tenancy.add_contactassignment %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index dcef3d8be..5dadf86fb 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -351,12 +351,6 @@ class ContactAssignmentEditView(generic.ObjectEditView): instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) return instance - def get_return_url(self, request, obj=None): - return obj.object.get_absolute_url() if obj else super().get_return_url(request) - class ContactAssignmentDeleteView(generic.ObjectDeleteView): queryset = ContactAssignment.objects.all() - - def get_return_url(self, request, obj=None): - return obj.object.get_absolute_url() if obj else super().get_return_url(request) From bd3d2c60d97897cbbec5f992a857e76529d02f44 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 14:19:10 -0400 Subject: [PATCH 161/289] FHRP groups cleanup --- netbox/ipam/forms/models.py | 2 +- netbox/ipam/models/fhrp.py | 2 +- netbox/ipam/views.py | 6 ------ netbox/templates/ipam/fhrpgroup.html | 3 ++- .../templates/ipam/inc/panels/fhrp_groups.html | 17 +++++++++++++++-- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d69800aa5..afbe33282 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -529,7 +529,7 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) - # Optionally create a new IPAddress along with the NHRPGroup + # Optionally create a new IPAddress along with the FHRPGroup ip_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 42ab0b530..5a12a54bb 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -47,7 +47,7 @@ class FHRPGroup(PrimaryModel): to='ipam.IPAddress', content_type_field='assigned_object_type', object_id_field='assigned_object_id', - related_query_name='nhrp_group' + related_query_name='fhrp_group' ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f869a75c1..c9d126f45 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -981,16 +981,10 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView): instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id')) return instance - def get_return_url(self, request, obj=None): - return obj.interface.get_absolute_url() if obj else super().get_return_url(request) - class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): queryset = FHRPGroupAssignment.objects.all() - def get_return_url(self, request, obj=None): - return obj.interface.get_absolute_url() if obj else super().get_return_url(request) - # # VLANs diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index 60d6a4bff..a7fa1a248 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -72,12 +72,13 @@
Members
- {% if ipaddress_table.rows %} + {% if members_table.rows %} {% render_table members_table 'inc/table.html' %} {% else %}
None
{% endif %}
+
{% plugin_full_width_page object %} diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html index e5cb26104..9692927a3 100644 --- a/netbox/templates/ipam/inc/panels/fhrp_groups.html +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -1,7 +1,7 @@ {% load helpers %}
-
NHRP Groups
+
FHRP Groups
@@ -10,6 +10,7 @@ + @@ -30,6 +31,18 @@ + {% empty %} @@ -46,7 +59,7 @@ {% endif %} {% if perms.ipam.add_fhrpgroupassignment %} - + Assign Group {% endif %} From 54233aba1b127fc84c2dbb2d3b8478cfde37b30b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 14:23:24 -0400 Subject: [PATCH 162/289] Remove extraneous slug field from contact import form --- netbox/tenancy/forms/bulk_import.py | 1 - netbox/tenancy/tests/test_views.py | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 73e152a29..51b863cac 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -70,7 +70,6 @@ class ContactRoleCSVForm(CustomFieldModelCSVForm): class ContactCSVForm(CustomFieldModelCSVForm): - slug = SlugField() group = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index dcfcc1652..881802a7b 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -176,10 +176,10 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,slug", - "Contact 4,contact-4", - "Contact 5,contact-5", - "Contact 6,contact-6", + "group,name", + "Contact Group 1,Contact 4", + "Contact Group 1,Contact 5", + "Contact Group 1,Contact 6", ) cls.bulk_edit_data = { From 0aae155c809acaca71412894ba08e7e21716dfc7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 15:18:58 -0400 Subject: [PATCH 163/289] Refresh installation docs --- docs/installation/1-postgresql.md | 10 ++++++++-- docs/installation/2-redis.md | 8 +++++++- docs/installation/3-netbox.md | 16 ++++++++-------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 36a4f87b9..97a44c20e 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,7 +2,7 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning +!!! warning "PostgreSQL 10 or later required" NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -38,6 +38,12 @@ sudo systemctl start postgresql sudo systemctl enable postgresql ``` +Before continuing, verify that you have installed PostgreSQL 10 or later: + +```no-highlight +psql -V +``` + ## Database Creation At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. Start by invoking the PostgreSQL shell as the system Postgres user. @@ -54,7 +60,7 @@ CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K'; GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox; ``` -!!! danger +!!! danger "Use a strong password" **Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation. Once complete, enter `\q` to exit the PostgreSQL shell. diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 14dda60f1..fcdfa9ceb 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -4,7 +4,7 @@ [Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md). -!!! note +!!! warning "Redis v4.0 or later required" NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details. === "Ubuntu" @@ -21,6 +21,12 @@ sudo systemctl enable redis ``` +Before continuing, verify that your installed version of Redis is at least v4.0: + +```no-highlight +redis-server -v +``` + You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient. ## Verify Service Status diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 87a64b325..c864ccd07 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -6,8 +6,8 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. -!!! note - NetBox v3.0 and later require Python 3.7, 3.8, or 3.9. +!!! warning "Python 3.7 or later required" + NetBox v3.0 and v3.1 require Python 3.7, 3.8, or 3.9. It is recommended to install at least Python v3.8, as this will become the minimum supported Python version in NetBox v3.2. === "Ubuntu" @@ -21,10 +21,10 @@ Begin by installing all system packages required by NetBox and its dependencies. sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config ``` -Before continuing with either platform, update pip (Python's package management tool) to its latest release: +Before continuing, check that your installed Python version is at least 3.7: ```no-highlight -sudo pip3 install --upgrade pip +python3 -V ``` ## Download NetBox @@ -89,7 +89,7 @@ Resolving deltas: 100% (148/148), done. ``` !!! note - Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release. + Installation via git also allows you to easily try out different versions of NetBox. To check out a [specific NetBox release](https://github.com/netbox-community/netbox/releases), use the `git checkout` command with the desired release tag. For example, `git checkout v3.0.8`. ## Create the NetBox System User @@ -190,7 +190,7 @@ A simple Python script named `generate_secret_key.py` is provided in the parent python3 ../generate_secret_key.py ``` -!!! warning +!!! warning "SECRET_KEY values must match" In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state. When you have finished modifying the configuration, remember to save the file. @@ -229,7 +229,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa sudo /opt/netbox/upgrade.sh ``` -Note that **Python 3.7 or later is required** for NetBox v3.0 and later releases. If the default Python installation on your server does not meet this requirement, you'll need to install Python 3.7 or later separately, and pass the path to the support installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) +Note that **Python 3.7 or later is required** for NetBox v3.0 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) ```no-highlight sudo PYTHON=/usr/bin/python3.7 /opt/netbox/upgrade.sh @@ -297,7 +297,7 @@ Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on firewall-cmd --zone=public --add-port=8000/tcp ``` -!!! danger +!!! danger "Not for production use" The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.** !!! warning From 9094f072906e7b488f2e3c5d63cde360348a6af0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 15:35:17 -0400 Subject: [PATCH 164/289] Fix config revision form help texts --- netbox/extras/forms/config.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py index fab6fdbd1..4a7dba614 100644 --- a/netbox/extras/forms/config.py +++ b/netbox/extras/forms/config.py @@ -48,12 +48,15 @@ class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass): value = getattr(config, param.name) is_static = hasattr(settings, param.name) if value: - help_text = f'
Current value: {value}' + help_text = self.fields[param.name].help_text + if help_text: + help_text += '
' # Line break + help_text += f'Current value: {value}' if is_static: help_text += ' (defined statically)' elif value == param.default: help_text += ' (default)' - self.fields[param.name].help_text += help_text + self.fields[param.name].help_text = help_text if is_static: self.fields[param.name].disabled = True From 93772e7265c290ac89883e34c4c92d98558fb528 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 15:41:46 -0400 Subject: [PATCH 165/289] Move remote authentication config parameters to a separate page --- docs/configuration/index.md | 1 + docs/configuration/optional-settings.md | 107 ------------------- docs/configuration/remote-authentication.md | 110 ++++++++++++++++++++ mkdocs.yml | 1 + 4 files changed, 112 insertions(+), 107 deletions(-) create mode 100644 docs/configuration/remote-authentication.md diff --git a/docs/configuration/index.md b/docs/configuration/index.md index c568fc7f0..95ed3fc37 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -9,6 +9,7 @@ Some configuration parameters may alternatively be defined either in `configurat * [Required settings](required-settings.md) * [Optional settings](optional-settings.md) * [Dynamic settings](dynamic-settings.md) +* [Remote authentication settings](remote-authentication.md) ## Changing the Configuration diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 3c1e24e9b..d3b82e995 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -313,113 +313,6 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff --- -## REMOTE_AUTH_AUTO_CREATE_USER - -Default: `False` - -If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.) - ---- - -## REMOTE_AUTH_BACKEND - -Default: `'netbox.authentication.RemoteUserBackend'` - -This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. - -* `netbox.authentication.RemoteUserBackend` -* `netbox.authentication.LDAPBackend` - ---- - -## REMOTE_AUTH_DEFAULT_GROUPS - -Default: `[]` (Empty list) - -The list of groups to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.) - ---- - -## REMOTE_AUTH_DEFAULT_PERMISSIONS - -Default: `{}` (Empty dictionary) - -A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.) - ---- - -## REMOTE_AUTH_ENABLED - -Default: `False` - -NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) - ---- - -## REMOTE_AUTH_GROUP_SYNC_ENABLED - -Default: `False` - -NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.) - ---- - -## REMOTE_AUTH_HEADER - -Default: `'HTTP_REMOTE_USER'` - -When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.) - ---- - -## REMOTE_AUTH_GROUP_HEADER - -Default: `'HTTP_REMOTE_USER_GROUP'` - -When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_SUPERUSER_GROUPS - -Default: `[]` (Empty list) - -The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_SUPERUSERS - -Default: `[]` (Empty list) - -The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_STAFF_GROUPS - -Default: `[]` (Empty list) - -The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_STAFF_USERS - -Default: `[]` (Empty list) - -The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_GROUP_SEPARATOR - -Default: `|` (Pipe) - -The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - ## RELEASE_CHECK_URL Default: None (disabled) diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md new file mode 100644 index 000000000..c00da8b67 --- /dev/null +++ b/docs/configuration/remote-authentication.md @@ -0,0 +1,110 @@ +# Remote Authentication Settings + +The configuration parameters listed here control remote authentication for NetBox. Note that `REMOTE_AUTH_ENABLED` must be true in order for these settings to take effect. + +--- + +## REMOTE_AUTH_AUTO_CREATE_USER + +Default: `False` + +If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_BACKEND + +Default: `'netbox.authentication.RemoteUserBackend'` + +This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. + +* `netbox.authentication.RemoteUserBackend` +* `netbox.authentication.LDAPBackend` + +--- + +## REMOTE_AUTH_DEFAULT_GROUPS + +Default: `[]` (Empty list) + +The list of groups to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_DEFAULT_PERMISSIONS + +Default: `{}` (Empty dictionary) + +A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_ENABLED + +Default: `False` + +NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) + +--- + +## REMOTE_AUTH_GROUP_SYNC_ENABLED + +Default: `False` + +NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_HEADER + +Default: `'HTTP_REMOTE_USER'` + +When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + +## REMOTE_AUTH_GROUP_HEADER + +Default: `'HTTP_REMOTE_USER_GROUP'` + +When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + +## REMOTE_AUTH_SUPERUSER_GROUPS + +Default: `[]` (Empty list) + +The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + +## REMOTE_AUTH_SUPERUSERS + +Default: `[]` (Empty list) + +The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + +## REMOTE_AUTH_STAFF_GROUPS + +Default: `[]` (Empty list) + +The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + +## REMOTE_AUTH_STAFF_USERS + +Default: `[]` (Empty list) + +The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + +## REMOTE_AUTH_GROUP_SEPARATOR + +Default: `|` (Pipe) + +The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) diff --git a/mkdocs.yml b/mkdocs.yml index a13826ecf..3fb838ffd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,7 @@ nav: - Required Settings: 'configuration/required-settings.md' - Optional Settings: 'configuration/optional-settings.md' - Dynamic Settings: 'configuration/dynamic-settings.md' + - Remote Authentication: 'configuration/remote-authentication.md' - Core Functionality: - IP Address Management: 'core-functionality/ipam.md' - VLAN Management: 'core-functionality/vlans.md' From 806dcd74ec518bd8f55bcb5861cf66a9b3e8d39f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 15:58:17 -0400 Subject: [PATCH 166/289] Split circuits models into submodules --- netbox/circuits/models/__init__.py | 2 + .../{models.py => models/circuits.py} | 120 +----------------- netbox/circuits/models/providers.py | 116 +++++++++++++++++ 3 files changed, 122 insertions(+), 116 deletions(-) create mode 100644 netbox/circuits/models/__init__.py rename netbox/circuits/{models.py => models/circuits.py} (69%) create mode 100644 netbox/circuits/models/providers.py diff --git a/netbox/circuits/models/__init__.py b/netbox/circuits/models/__init__.py new file mode 100644 index 000000000..7bbaf75d3 --- /dev/null +++ b/netbox/circuits/models/__init__.py @@ -0,0 +1,2 @@ +from .circuits import * +from .providers import * diff --git a/netbox/circuits/models.py b/netbox/circuits/models/circuits.py similarity index 69% rename from netbox/circuits/models.py rename to netbox/circuits/models/circuits.py index 089d6cb2d..3af3c6bc0 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models/circuits.py @@ -3,131 +3,19 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from dcim.fields import ASNField -from dcim.models import LinkTermination, PathEndpoint -from extras.models import ObjectChange +from circuits.choices import * +from dcim.models import LinkTermination from extras.utils import extras_features -from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet -from .choices import * - __all__ = ( 'Circuit', 'CircuitTermination', 'CircuitType', - 'ProviderNetwork', - 'Provider', ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Provider(PrimaryModel): - """ - Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model - stores information pertinent to the user's relationship with the Provider. - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) - account = models.CharField( - max_length=30, - blank=True, - verbose_name='Account number' - ) - portal_url = models.URLField( - blank=True, - verbose_name='Portal URL' - ) - noc_contact = models.TextField( - blank=True, - verbose_name='NOC contact' - ) - admin_contact = models.TextField( - blank=True, - verbose_name='Admin contact' - ) - comments = models.TextField( - blank=True - ) - - # Generic relations - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - objects = RestrictedQuerySet.as_manager() - - clone_fields = [ - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', - ] - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('circuits:provider', args=[self.pk]) - - -# -# Provider networks -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ProviderNetwork(PrimaryModel): - """ - This represents a provider network which exists outside of NetBox, the details of which are unknown or - unimportant to the user. - """ - name = models.CharField( - max_length=100 - ) - provider = models.ForeignKey( - to='circuits.Provider', - on_delete=models.PROTECT, - related_name='networks' - ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - ordering = ('provider', 'name') - constraints = ( - models.UniqueConstraint( - fields=('provider', 'name'), - name='circuits_providernetwork_provider_name' - ), - ) - unique_together = ('provider', 'name') - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('circuits:providernetwork', args=[self.pk]) - - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ @@ -275,7 +163,7 @@ class CircuitTermination(ChangeLoggedModel, LinkTermination): null=True ) provider_network = models.ForeignKey( - to=ProviderNetwork, + to='circuits.ProviderNetwork', on_delete=models.PROTECT, related_name='circuit_terminations', blank=True, diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py new file mode 100644 index 000000000..1449e9520 --- /dev/null +++ b/netbox/circuits/models/providers.py @@ -0,0 +1,116 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models +from django.urls import reverse + +from dcim.fields import ASNField +from extras.utils import extras_features +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'ProviderNetwork', + 'Provider', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Provider(PrimaryModel): + """ + Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model + stores information pertinent to the user's relationship with the Provider. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN', + help_text='32-bit autonomous system number' + ) + account = models.CharField( + max_length=30, + blank=True, + verbose_name='Account number' + ) + portal_url = models.URLField( + blank=True, + verbose_name='Portal URL' + ) + noc_contact = models.TextField( + blank=True, + verbose_name='NOC contact' + ) + admin_contact = models.TextField( + blank=True, + verbose_name='Admin contact' + ) + comments = models.TextField( + blank=True + ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + ] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('circuits:provider', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ProviderNetwork(PrimaryModel): + """ + This represents a provider network which exists outside of NetBox, the details of which are unknown or + unimportant to the user. + """ + name = models.CharField( + max_length=100 + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='networks' + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('provider', 'name') + constraints = ( + models.UniqueConstraint( + fields=('provider', 'name'), + name='circuits_providernetwork_provider_name' + ), + ) + unique_together = ('provider', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('circuits:providernetwork', args=[self.pk]) From 0bbd18663547a25e53393ed6aec637529f64f60a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 15:58:52 -0400 Subject: [PATCH 167/289] Rearrange site view layout --- netbox/templates/dcim/site.html | 71 ++++++++++++++++----------------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 308b09816..fe7f86d67 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -95,49 +95,48 @@ {% endif %}
- - - - - - - - - - - -
Protocol Virtual IPs Priority
{{ assignment.priority }} + {% if perms.ipam.change_fhrpgroupassignment %} + + + + {% endif %} + {% if perms.ipam.delete_fhrpgroupassignment %} + + + + {% endif %} +
Physical Address - {% if object.physical_address %} - - {{ object.physical_address|linebreaksbr }} - {% else %} - - {% endif %} -
Shipping Address{{ object.shipping_address|linebreaksbr|placeholder }}
GPS Coordinates - {% if object.latitude and object.longitude %} - - {{ object.latitude }}, {{ object.longitude }} - {% else %} - - {% endif %} -
- {% include 'inc/panels/contacts.html' %}
Contact Info
{% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %} + + + + + + + + + + + + {% for location in locations %} - {% empty %} - + {% endfor %} @@ -54,12 +54,12 @@ - + - + diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index ff9f826ae..0ae1139bf 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,7 +1,8 @@ import django_tables2 as tables from utilities.tables import ( - BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, + TagColumn, ToggleColumn, ) from .models import * @@ -131,6 +132,9 @@ class ContactTable(BaseTable): group = tables.Column( linkify=True ) + phone = tables.Column( + linkify=linkify_phone, + ) comments = MarkdownColumn() assignment_count = tables.Column( verbose_name='Assignments' diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 6ad8ce6ca..7b348b5ac 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -489,3 +489,19 @@ def paginate_table(table, request): 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(table) + + +# +# Callables +# + +def linkify_email(value): + if value is None: + return None + return f"mailto:{value}" + + +def linkify_phone(value): + if value is None: + return None + return f"tel:{value}" From a99d14c13ffa68096073091372bae1ba170e9f5e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 3 Dec 2021 11:00:00 -0500 Subject: [PATCH 278/289] Closes #7924: Include child groups on contact group view --- docs/release-notes/version-3.1.md | 1 + netbox/templates/tenancy/contactgroup.html | 21 ++++++++++++++++++--- netbox/tenancy/views.py | 12 ++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 11e034fca..86058f6e3 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -15,6 +15,7 @@ * [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments * [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions * [#7884](https://github.com/netbox-community/netbox/issues/7884) - Add FHRP groups column to interface tables +* [#7924](https://github.com/netbox-community/netbox/issues/7924) - Include child groups on contact group view * [#7925](https://github.com/netbox-community/netbox/issues/7925) - Linkify contact phone and email attributes ### Bug Fixes diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index efb86af91..05ec88022 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -50,15 +50,30 @@
{% include 'inc/panels/custom_fields.html' %} +
+
+ Child Groups +
+
+ {% include 'inc/table.html' with table=child_groups_table %} +
+ {% if perms.tenancy.add_contactgroup %} + + {% endif %} +
{% plugin_right_page object %}
-
- Tenants -
+
+ Contacts +
{% include 'inc/table.html' with table=contacts_table %}
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index a3e680401..5991d34d9 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -168,6 +168,17 @@ class ContactGroupView(generic.ObjectView): queryset = ContactGroup.objects.all() def get_extra_context(self, request, instance): + child_groups = ContactGroup.objects.add_related_count( + ContactGroup.objects.all(), + Contact, + 'group', + 'contact_count', + cumulative=True + ).restrict(request.user, 'view').filter( + parent__in=instance.get_descendants(include_self=True) + ) + child_groups_table = tables.ContactGroupTable(child_groups) + contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance ) @@ -175,6 +186,7 @@ class ContactGroupView(generic.ObjectView): paginate_table(contacts_table, request) return { + 'child_groups_table': child_groups_table, 'contacts_table': contacts_table, } From 09b612546b9b0c70be9ffa8c857d927c5adaabf3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 3 Dec 2021 11:07:16 -0500 Subject: [PATCH 279/289] Omit actions column from non-paginated child object tables --- netbox/dcim/views.py | 2 ++ netbox/tenancy/views.py | 1 + 2 files changed, 3 insertions(+) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f07bc6900..e353f24d0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -157,6 +157,7 @@ class RegionView(generic.ObjectView): parent__in=instance.get_descendants(include_self=True) ) child_regions_table = tables.RegionTable(child_regions) + child_regions_table.columns.hide('actions') sites = Site.objects.restrict(request.user, 'view').filter( region=instance @@ -241,6 +242,7 @@ class SiteGroupView(generic.ObjectView): parent__in=instance.get_descendants(include_self=True) ) child_groups_table = tables.SiteGroupTable(child_groups) + child_groups_table.columns.hide('actions') sites = Site.objects.restrict(request.user, 'view').filter( group=instance diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 5991d34d9..c848de47f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -178,6 +178,7 @@ class ContactGroupView(generic.ObjectView): parent__in=instance.get_descendants(include_self=True) ) child_groups_table = tables.ContactGroupTable(child_groups) + child_groups_table.columns.hide('actions') contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance From b43980d6607babee9222d4c5d19b0896c8a72914 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 3 Dec 2021 15:09:56 -0500 Subject: [PATCH 280/289] Fixes #7960: Prevent creation of regions/site groups/locations with duplicate names (see #7354) --- docs/release-notes/version-3.1.md | 1 + .../0137_relax_uniqueness_constraints.py | 56 +++++++-- netbox/dcim/models/sites.py | 110 ++++++++++++++++-- 3 files changed, 148 insertions(+), 19 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 86058f6e3..0ab4a611d 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -27,6 +27,7 @@ * [#7771](https://github.com/netbox-community/netbox/issues/7771) - Group assignment should be optional when creating contacts via REST API * [#7849](https://github.com/netbox-community/netbox/issues/7849) - Fix exception when creating an FHRPGroup with an invalid IP address * [#7880](https://github.com/netbox-community/netbox/issues/7880) - Include assigned IP addresses in FHRP group object representation +* [#7960](https://github.com/netbox-community/netbox/issues/7960) - Prevent creation of regions/site groups/locations with duplicate names (see #7354) ### REST API Changes diff --git a/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py index 8f7d40026..7cedb1b08 100644 --- a/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py +++ b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.8 on 2021-10-19 17:41 - from django.db import migrations, models @@ -32,14 +30,54 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='location', - unique_together={('site', 'parent', 'name'), ('site', 'parent', 'slug')}, + unique_together=set(), ), - migrations.AlterUniqueTogether( - name='region', - unique_together={('parent', 'slug'), ('parent', 'name')}, + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(fields=('site', 'parent', 'name'), name='dcim_location_parent_name'), ), - migrations.AlterUniqueTogether( - name='sitegroup', - unique_together={('parent', 'slug'), ('parent', 'name')}, + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'name'), name='dcim_location_name'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(fields=('site', 'parent', 'slug'), name='dcim_location_parent_slug'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'slug'), name='dcim_location_slug'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_region_parent_name'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_region_name'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_region_parent_slug'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_region_slug'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_sitegroup_parent_name'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_sitegroup_name'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_sitegroup_parent_slug'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_sitegroup_slug'), ), ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index fd40b30c4..a19ae8050 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -62,11 +62,41 @@ class Region(NestedGroupModel): ) class Meta: - unique_together = ( - ('parent', 'name'), - ('parent', 'slug'), + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='dcim_region_parent_name' + ), + models.UniqueConstraint( + fields=('name',), + name='dcim_region_name', + condition=Q(parent=None) + ), + models.UniqueConstraint( + fields=('parent', 'slug'), + name='dcim_region_parent_slug' + ), + models.UniqueConstraint( + fields=('slug',), + name='dcim_region_slug', + condition=Q(parent=None) + ), ) + def validate_unique(self, exclude=None): + if self.parent is None: + regions = Region.objects.exclude(pk=self.pk) + if regions.filter(name=self.name, parent__isnull=True).exists(): + raise ValidationError({ + 'name': 'A region with this name already exists.' + }) + if regions.filter(slug=self.slug, parent__isnull=True).exists(): + raise ValidationError({ + 'name': 'A region with this slug already exists.' + }) + + super().validate_unique(exclude=exclude) + def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -119,11 +149,41 @@ class SiteGroup(NestedGroupModel): ) class Meta: - unique_together = ( - ('parent', 'name'), - ('parent', 'slug'), + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='dcim_sitegroup_parent_name' + ), + models.UniqueConstraint( + fields=('name',), + name='dcim_sitegroup_name', + condition=Q(parent=None) + ), + models.UniqueConstraint( + fields=('parent', 'slug'), + name='dcim_sitegroup_parent_slug' + ), + models.UniqueConstraint( + fields=('slug',), + name='dcim_sitegroup_slug', + condition=Q(parent=None) + ), ) + def validate_unique(self, exclude=None): + if self.parent is None: + site_groups = SiteGroup.objects.exclude(pk=self.pk) + if site_groups.filter(name=self.name, parent__isnull=True).exists(): + raise ValidationError({ + 'name': 'A site group with this name already exists.' + }) + if site_groups.filter(slug=self.slug, parent__isnull=True).exists(): + raise ValidationError({ + 'name': 'A site group with this slug already exists.' + }) + + super().validate_unique(exclude=exclude) + def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -335,10 +395,40 @@ class Location(NestedGroupModel): class Meta: ordering = ['site', 'name'] - unique_together = ([ - ('site', 'parent', 'name'), - ('site', 'parent', 'slug'), - ]) + constraints = ( + models.UniqueConstraint( + fields=('site', 'parent', 'name'), + name='dcim_location_parent_name' + ), + models.UniqueConstraint( + fields=('site', 'name'), + name='dcim_location_name', + condition=Q(parent=None) + ), + models.UniqueConstraint( + fields=('site', 'parent', 'slug'), + name='dcim_location_parent_slug' + ), + models.UniqueConstraint( + fields=('site', 'slug'), + name='dcim_location_slug', + condition=Q(parent=None) + ), + ) + + def validate_unique(self, exclude=None): + if self.parent is None: + locations = Location.objects.exclude(pk=self.pk) + if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists(): + raise ValidationError({ + "name": f"A location with this name in site {self.site} already exists." + }) + if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists(): + raise ValidationError({ + "name": f"A location with this slug in site {self.site} already exists." + }) + + super().validate_unique(exclude=exclude) def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) From 7cb9cedfe1335b00307145312edf73852c8a6444 Mon Sep 17 00:00:00 2001 From: thatmattlove Date: Fri, 3 Dec 2021 16:20:05 -0700 Subject: [PATCH 281/289] Fixes #7823: Properly handle return_url when Save & Continue button is present --- docs/release-notes/version-3.0.md | 1 + netbox/project-static/dist/netbox.js | Bin 322575 -> 322806 bytes netbox/project-static/dist/netbox.js.map | Bin 310863 -> 311094 bytes netbox/project-static/src/forms/elements.ts | 40 +++++++++++++++++- netbox/templates/dcim/interface_edit.html | 2 +- .../virtualization/vminterface_edit.html | 2 +- 6 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index b84f2a3c0..9be270999 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -5,6 +5,7 @@ ### Enhancements * [#7751](https://github.com/netbox-community/netbox/issues/7751) - Get API user from LDAP only when `FIND_GROUP_PERMS` is enabled +* [#7823](https://github.com/netbox-community/netbox/issues/7823) - Fix issue where `return_url` is not honored when 'Save & Continue' button is present * [#7885](https://github.com/netbox-community/netbox/issues/7885) - Linkify VLAN name in VLANs table * [#7892](https://github.com/netbox-community/netbox/issues/7892) - Add L22-30 power port & outlet types * [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index b8567f0606eb5fb62dc8f125a33623f5466bf28c..c4a8e802d42c5888f200c459000fc7cbadd2e2b8 100644 GIT binary patch delta 5490 zcmZ`-d3Y36w*St(l^qBW5=df5(uB|rMMwk{(^MdpPLYJpo**QI&`Bzt&e9z^m7PFP zTv&XNDa@5cMQ0Qd3`-$}vP2OA3~snL*|0=dO&S?^Lrm^S)2HseXTQI5 z?!CSKLf8)%!}g5xgLEy@FA7333i#Ysi;&ix+p0c2DqJo0PX@cT%Ku(~T=i&x0@>>H zzzS$k`vQ+ZftnxG0D0=6pu&)%0$%h9iiY4F>ek>8H7$5LS6{$OYHP4Wq*gUPHeCJN z$RPDna4KY}5h3$nsa6#d1(2Zj3_A>>nmT+jNb08Hd!S3p88I8+K6Oo~3FfOWhHi$1 zYSzeVC{=fj{0Qc$Ye(h6T=n9pHdvq*j9vqU+NIHc06E%!hh;!SSplC}V}#M>@WzS7 zLT8QHf_|iiC!m$e@bGx4*eqLS%c;eR*X4HNcTbPSYA`pn2pwj#oTok(zMd#~T4KZl z050u?u}C~>QsioAQ4dBw4-VB4)dG#`yHWK}ti+m{Xws5RCK%z&;|p)AmeeH^Vo{=ZLPm(xViw!^7E5BH zq`oy_0_OIc307!RlM)`o@8b!z_&a6dT*y>gCT@jFb!6gpDAo=pMgz2Kf0^`0fD*OC z(gPjZ50+wpd^KlE1*EIbPx%fq)F=4D0MTn?%dUOTR}#*a$9JkJ)(F)-t((iu<1>u` z*IpusYC!S^XwV){4krkgfjc{(NS!qO9D?Bb^ooe3dAz;$_GXm`MT&2;nr4_Vj14nN z(8((^rXWa0-DL++TYT3gh>*N|mshB5byq7c59Y30@pg7gYW_XtVUl-tR;^j`T2gaL z%$8K+TRn2my8&XY;VZ&x=E2~uJYMpdl+^NjBN1k;_ilpuT4?HcfQ4F;bu%E)PS5%i z0?jR4L!gzXt%n8L7iqJAE6n3-)upxwEqxBpL5}LSA*eLf_Gf@HZLzeH%utH>_PW~v zbqK}AR!bd%*kLv)o*R%WaX3vDzPL{4?y(lvl}l<^_DpbV_UuOBTt$3`VL?5Yvjjmm zecpNm-RtwVfJ3Xa4+BJ9W9}uyTUOqDKvX=HU(MAOA-w$yCS%4@3N}WGbxAFau4=T~ z(_@wDlH`tNk5_3)sukGYC=!NT-xj1Hi0&+GMG!q-s8DdKF{NrmTM=LQyX*NvmBY6e zL#0*Yr2KL*->g_FZk0+|osrNgbO;o7POcoJl%;bqqzT^ZBkV=|QlDv~mL+XUixY_C zQ2VT5>UL!*NLplTJ80M3sANLNUjc0D_0EZCI<9LtAkLDye?p!~%hGY6xq1?fa^A39 zLk8Q5sz4;$AMP^vOsjJdF3qcl44mAvb_3$%)Y=_>bL;usCLwjSta9JsQI*!Ju&MgfvAE+^vm`zq>J4v;B5~zydu6n+1jWtkDnBU?JP-2OmJ8{)j(} zG*&kS!gLDr*qb3>hPnC=A+VD`A$w*7T!&KqiBM?D0*QqzfH)-l0=OR}efI)*oI{#b z0a!+&Ub+~tg1q{!N?7g>BAZhKQEa&kA*`SlCWeVdFV6C`dRxpE3tuG&g2-NzAtKt* zVb0`ztGOvupn8?1w=qeVXRrj~-lL?5sIYn>6H zm*qCX59p<>3EoH1ST{UF0wo2x-LjdT*$3nGe>B4qf}FoS0pr+`7U1#w(H5A5!MxN0 z8xfj%&N9FrEwU$j;2m7`EQhafRJj7;adG7e2s9Sg^up{&F{!%F)mY<|-ARqI+o{w| zO-W7WOEJ33y^xCBF)LveLGHgj3S<1!Quz6}boc?xv#4Kr0M7ddix%I>YRn&71G6!O zWG(zM`i6rsIE;A`9>vTWk&R`?o`R?GyJj0C;gY%yN&-SqgI88sz3w2}qcMWLv?pn_LAn`KXJoG1vUTM#xSCW_{E zi{++fo7LcEJ-c8$2D5b+W=&+LcL8=u_T4V9-DSvns_Nxx1tr_#g_}lEOH!@Z(_~P^ zgDVCON?c9YPSY^rrWbws*1ZT@Fd^mv=r?jTxCicu7A<_bVy;%qDHcB2VoAlpjW~Ew zAGH^p9LC@I60{pz@$~@^gT!UaO4CHkY>~~&qW--O zuNpE3UIqoXe99{z8`d`8B5WwXiSE^v5Tx4){iC-anD|MJcrvr}N(eh}77mdh zQ4l@mB8ydI$Its<`SLG!x`4$rO)89kC4b#3Pl}tynF_o;sd`4PH(1-v@=&p6SYgk`4nW*PwlVuz# zK9fsc#)CkAFrRc$kot?sfk06<`dPQcjuw-v$g{nK%vrO-?4SAnQYJFy>>ANnnm% z63wD|$!yeF)=RKg+|=2#k|g^K-A}&s$H^yFlX7(N=hdXs;PLrXdy0fH&l%#+2G)|P zIQzj`Qi){NIz!*~W$Or{llA-;x+>|@*OPz%q}F~i8#BkcH=$c1OWSPdWt%sXEx361 z77}7C8tV0$C&^DiI9c&L*@~0;^_^rRj25eJ)w#7%R^r@(q;GqX^cwk#-%Y+Td>*rx zOg5aHz0dH2t=LD>arL!*B-amDLl2Uo04in*Y|UvJMRJ=r-L=XVCuOGb1L52FU!4h^GqND_wAUSxW9IGtFQV z&fMaI=s+~ZZlj|XK7^Q`H`~BD#HHviR zBHD!HxkYp`lCz62Mu}Bb&=p87S5N~T`HLxHoPDvF8ohi;B`q-0v3CjWHuimLH60fc zBr0Z4lH6#qvUfZ*QCHk_C;%6&Z8T*B+DzLi$|StY|8lU z%yTr!*gH*PX-P?bp6LdkTabB|;5N?0bOoQ!Rd@k1n{FJrQ(L`_*quGNjCgCZdhkp& zZg+`^F4^qDcPK{X@IBcC7w<`GX{~BiPWyYSL3)p{HPy(~gG+YB3wmDRFqz z*PBgfO!2tOf45e_Vh?iTX|vtJzB)u#4R5lWT|8c$dJJ)W*vT7gvB&8d z7B)l+BSjZpUVZVD%e=cnP^^;6?8a9bv!9?-*bd|g|4$y?<>O+ZClR5U?1drRHj&9g z)CQHl(f`lC5IWh)lQf)~QZtQ@=&(w3&RXS_9SzoMM~iGSrB>;?Pf`M`?KB<3PMo3( zp`FE^#*!{E$V7eXX*$UdI@s~I=m5#Lvz3Ef0(<;z`X!|655Gf)K}x%wFR}B*cD}>T zr`!2_dxZrb(mlpQsJ&6J%jRM{jV-_5v+eBoS^6A8qv0Gj9vi!Mjy@D-x0~I(jOn|5 zA;@JX-=%T-v*&3%fovTIvq*r=&S%^CTo!zRGSXmIm;92zPF|peu#`n!q<299%fCn; zg*^7*MYOg+eC$tzfw|znrAwhrQ6Dk-j;?k$6vw@Xep?8mvjJL?`3nhE<3Kq7c4_=`s z*0H~Oxp?-hmrG%*@Js*E%Y{*lI;4$rB8l9>#jp)++)>EU^V>P>Hln_+lUs^^`mkTS zxO;-a4JQEY5rFV{EPZA0Atd;Pdr;XdvmbpOIWZu}^(O)%MA&N?M7 z*Ha^#cn=HExH-_mvNUcgI9Qv;B|{_Iu5tH+hfUtkMdK|>+RxQPgu})++A1uv@yxpo zhibOy0B2%XU*cMz)%ac%h@~xKX}j2~2e^kv;z+?!{LR@GcAw5|fg1L$&gDQgOFPIV zk88Cd4*#vP%WKdMf|bP`=FDv1AomLPeAmmIM4*oCImC5AGwVCZ#pqdwxiWwz-+;Y( zgu99hJC1V2$o8+J7*>;>HptB*;ACrFYZ1(e*gak&eJvN delta 5384 zcmai2d3Y36w*St(m0i|sBrzcgA#_U-5SX_N z$W=G^ABSu;KVTs&RF4J}1ljGpb1aBC{)9Oa-c!03W@}nsdnFU3|iFGA@dC0&~=v z!>eJox@-7{@UXgOL@t!5S4S*@9MwK@HO$bijr0MStNmASCWIB(`K%fvj7631SgA

Rsag^UCh&4%dvchNJoBCbM>nPhldJ8^F zV;4iK`fjWXYSbBVjZmu|i0guSEjd0OhBoK%1$R}8>Vh$&QKDx|W{~JHNo~BxoR}!8 zZ;Tm(>HKbt1)9{PgiZKtnua0uD&q-2gp~S;tTvFw~?(3?MJ?XaG800hnivuQ=JpLI9nc{Wem8s zVnI^LAdOh)DCua+~ms$f?Jcz!y58DL?z$9rN0+GPKUfwlx~I*oHHmI>>a1du zIn{Vqk3aa1pHyr3itw6xC{W7dMXyOwEqf>e4YWSA89KFLsWAX^v?R+GK%kvV`zr#? zDSVAUD@$JoIoen0X~50M<7?FxYnYZXo#$Y#>a-%LG}U?@pg@~1EhAG(^Z2&Ay-^k7jNc^>V>XuOHqB9s0L?G1zEFYHv*>=^6iEN_42F*2)fBR z>kxFW=Up^{eAXTsM4;^`GN#>6!NZSBYsyi)_l17NvVvm z$}aODtkmQ4_Ct~8DnH5jv=#D2&1%JlP&KD&8s>0K)$5#3aUq}KozzqwzSy-L`r6H3Ky>KZTDBNwambn#<*_{%_!tsyta*E7Pax7Iv`@D zy1^2x_9-nOY7wn%pndH`Eo0jM4v?wd>KKc*V>^c!?Ix+~Ci0A1oPh()(Vb}2^ZF$k zGFX>Z`6K%JR(1mRsQ6X6h?nM70|rnYT(cg5a(2y&J|zu&Zj%v_wq|Xn4|bzBH*6(P zpf23#B(oZ{Ya8>yPiivKCaWpEA;Y9hA-7VgPpWNnx!kye#l1U0{4@Iy?R~^v4FtQk zeaBz?xV#iTU;Xw%p4R))3g8M;_zWC{s4)j7YL&a5^Wz#)_)KF)i#hOSILy?(xeyl1 zwWRRYyKFgMM+U`prtpjEytu5pg9sttX1P;EvH99#KhFn5`;D7V_~f9KI&Uaze+e|w zz;3O@1SS0YF?4R{zg-Y2+7J!hchF$1Gs2poP5d8ZlP&hN!a*l3_VMG*U5;9lBovm}%Xy`vS$5UNN`er72g31*iIS}bM%nvM}S?e^+hyBoG*LQH$0*T_}>K6o%nGV>XVsai3mnE7P0ITZ)D5HqtXI+`{YM@C zX2={o3<_@f_#+@2+1+py`cUIT$6y`7>2oJwyAP2BRxkj;Z0RX@0Y!d!4JNRX(=Y{n zU40t9Bry)z6z_7{E8QMB)8(vn;7QbdCxf^f8_vRC2}LIMQ5tSu{snjwMMu95DG(^d zd5aYj!%8p0N*~cB)F|xuMHs^Z-hdNSbeY+Pb_mfwd;`uTO@YsiWiojT>+O7hyiO1#PyC*+mhb%E)@F=qDTMwI&edV-*!5)zS3DI53m90K##bg-MfBy{)#g%3~^-Aeqzc zW|qgPEUC?dDeC#m^tg?qh zvB(}W4Rx0G5bPC$I=h#VWUry+x))F-baett+Ty<{3@j&*HDworf4n*lgk{sP&CllrZlWFw4}s_)dfrBPO5or0)8yPNbF`HS02zB7Cd*-!2> zoSgQu;Rjp#GReTzS6?Q%KDavU5GnMdQkKA4&eKT3i>#-gOuTJ0+c48lN{p(qpCrSK zLu0`J*%yc|j<`hdVy=I9iQxUg>!PUte1+`r$HA8Cq!EeV2V^R;O#gsf3Pj0~Uy!ed zApQMEvU4QP@ARkFjObS0Lk+YYy@%E#Nf<(VkmQzxszf2{0O?<$YI>Q zG>o-`P;3$U>JZ8sJEBL@WF%ii(m6<`N6{!G^P=d~NHM9|EiW>;_=Oebq*|9dtFpS@ zym5mPFI+ky3@Qpx#kaBRdniDjeMlcu~I^P4nOmOj+h= z`U}bku@o;ry<$8=$CXCD7f* zE`2|iuD_eWfpN6JIK)OJ(+6;o<)_eRz@`5yg{Bc`)%huOvwyIZAxxaFR4ra4yA?-u zW#dHLv-i?y9r8~X=z5c6d_`5c+%>XWbhQ^bI^|UFRJF^g$WG<%9J?vdi|G15I*mn} zpQlr!>#&>CX+b0kl=7YU(Zv5>DZM&_CWb?kzIrZ=H8x{sDUC$2fl_o;WVL0~DAK9( zXcLm>=h6F+T$+b5ima-fE=BTjIW^FcKc6DT*;n(a(aRT9P`i8PLp zNin&S;1#O_X67e|QH3I3tW}0WH z3Ef6bMx#Q`jA7trJ$va<6inMkGtlUgeH5$NtvBiP4AD;?p{D_ytYrrm!uFlvIQHW) zS{c%8GdXy?g>?UMIyk&M`tsxSf-f|&yi;^9lHX3z3sA>4oT2sz$$_^*a~GCmnan%O z1;rvdOiuj%VUq{wcv$4+8ug!Cykn40W({X(6bm^^N3nCKX#qNHJ43B({s1lb|79$w z-F}vaLzO{7BPLWfHpZuR*i2?im0PY{XsNFB$no*1b$ahvN`Pe9*!Jfz;yvfEaw^#T z^K>j5f1XZaPoAS8EaE&JsXua_j`M*ww)0K82a5I5w`eescCTD8TYib&9A07Li)?(B zjc>E@?KZxct-g$)%3wP$)8{emg;!_~$s9EF#TD8ToMAILc^R{M_sF%eJ%6XM`r5Z? z909A2gEZooY2&Rn-p0Otk1~>LQy2W2!1i3llr;3dPw$6p_Q3n}30TNZy-!zxo#kDl zOCXP3yhhuhkV)6+MwrR2U8mWQt&jbH2KXZD#82o{WJNNXt;7fN_kBVukw4&5S_BQO z{8O3;GxZgpQUL;6jO)E|-BQ@at2CT_e1p!1T$b?}ePC#UcWv;N)xsYA3`0)PpZ|=W zUdxVqxHz`f!=)h1PIx%Hqr2I6i?}*)_ip2&Sz8--0_N%uv~y`>h-CbF@q#6`QefMc zkx+eOCx_+WW;eRHhhQ!ny_g%1IL%qiT^)jS>}oDB5NYy8ZWAij2N*ZP=qc|#i3V^y zeGAu*^x!rwDu@)=SigrG&C>cfE3Pf;<8I)`m|pi1SKvct+1Qc2+y#TK+{aB~QGMJ! zZ0>$8mfhHgx@0|fKPLexwXsbva~u3*D}vbE&Gzo&!q}!0+%Q(CagCs`evOM6;j{|z zHb-5(5}WI)k>hy>`$prYV-yn)a1+4A$`5eK;9-9{z%9pH&4^dHC~)c%Uf~)b%w^?0 z)^f9Kd`sO$L?zpOklO@}28m~~&ZYXhWlU9KqMP;U+{)oNQg9S^cQ*YHw-u^c%SkSn zCBMW4u%8ZbNl?kg9_EtAG+NCTZ2u=@U{+75Fu`o!a0DL}oBT=nZM0z{-sq;0~dx z_XoI4R26fE6WG-OE}Xf~pwj3ww;R+X6!_!}cQgvJ*fXDSA0b)#DR<1rn+}$81otTa rZR*FGk8wV1)OHl_xWPS4Dy*#REd>8Z*Kqpg4K52R^z_fTL%#nDy89IP diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index c2e1c5b4f3d06367327002e48c1995cdd2b4b000..b95e6ba02e8321339453db43dad5d8056abef060 100644 GIT binary patch delta 260 zcmX^AM`+tWp@tU57N#xCtB%&^C7L?vL^?X=I_tPPI#xRAI6FEUy6Ct&I{Jc$P>`sv zj=!UGi4%zL3E~IpggQD#>I8$Nfyy(S;mU)7(#|0uH6WIwzfPi~W1>#7qqDgagbx<< zc62Uq(s2Ztrt1k(>I*UxXnL@Yr=v5_JwT>6&`z)+&M6?B-a6rq-Vxi^9c3K9sVl_gyR>lK%0RmG + * Save & Continue + * + * ``` + * + * @param event Click event. + */ +function handleSubmitWithReturnUrl(event: MouseEvent): void { + const element = event.target as HTMLElement; + if (element.tagName === 'BUTTON') { + const button = element as HTMLButtonElement; + const action = button.getAttribute('return-url'); + const form = button.form; + if (form !== null && isTruthy(action)) { + form.action = action; + form.submit(); + } + } +} function handleFormSubmit(event: Event, form: HTMLFormElement): void { // Track the names of each invalid field. @@ -38,6 +66,15 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void { } } +/** + * Attach event listeners to form buttons with the `return-url` attribute present. + */ +function initReturnUrlSubmitButtons(): void { + for (const button of getElements('button[return-url]')) { + button.addEventListener('click', handleSubmitWithReturnUrl); + } +} + /** * Attach an event listener to each form's submitter (button[type=submit]). When called, the * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class @@ -54,4 +91,5 @@ export function initFormElements(): void { submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form)); } } + initReturnUrlSubmitButtons(); } diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 38b22fe5e..317b49a79 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -51,7 +51,7 @@ {% block buttons %} Cancel {% if obj.pk %} - + {% else %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index b4d097513..7e5b8599c 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -46,7 +46,7 @@ {% block buttons %} Cancel {% if obj.pk %} - + {% else %} From 40c6b172f747b1f9cd983a637a96908cb530bd11 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 11:33:00 -0500 Subject: [PATCH 282/289] Fixes #7981: Fix Markdown sanitization regex --- docs/release-notes/version-3.0.md | 6 +++++- netbox/utilities/templatetags/helpers.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 9be270999..41b366bf2 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -5,12 +5,16 @@ ### Enhancements * [#7751](https://github.com/netbox-community/netbox/issues/7751) - Get API user from LDAP only when `FIND_GROUP_PERMS` is enabled -* [#7823](https://github.com/netbox-community/netbox/issues/7823) - Fix issue where `return_url` is not honored when 'Save & Continue' button is present * [#7885](https://github.com/netbox-community/netbox/issues/7885) - Linkify VLAN name in VLANs table * [#7892](https://github.com/netbox-community/netbox/issues/7892) - Add L22-30 power port & outlet types * [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function * [#7941](https://github.com/netbox-community/netbox/issues/7941) - Add multi-standard ITA power outlet type +### Bug Fixes + +* [#7823](https://github.com/netbox-community/netbox/issues/7823) - Fix issue where `return_url` is not honored when 'Save & Continue' button is present +* [#7981](https://github.com/netbox-community/netbox/issues/7981) - Fix Markdown sanitization regex + --- ## v3.0.11 (2021-11-24) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 0f3f75bc2..3064cdf38 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -51,7 +51,7 @@ def render_markdown(value): value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) # Sanitize Markdown reference links - pattern = fr'\[(.+)\]:\w?(?!({schemes})).*:(.+)' + pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)' value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown From dc6decd404e2d128eeb70161e9b2b34461db9bee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 11:54:50 -0500 Subject: [PATCH 283/289] Release v3.0.12 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.0.md | 2 +- netbox/netbox/settings.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 4a6dba734..b041c7ff4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.11 + placeholder: v3.0.12 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 4c3ab0277..0852b4f9b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.11 + placeholder: v3.0.12 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 41b366bf2..51ad02395 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,6 +1,6 @@ # NetBox v3.0 -## v3.0.12 (FUTURE) +## v3.0.12 (2021-12-06) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7c205cca4..64869fab0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -17,7 +17,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.12-dev' +VERSION = '3.0.12' # Hostname HOSTNAME = platform.node() From 414810bdf5b12f0d0bd1405d6747ead9cda3ec4e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 13:15:17 -0500 Subject: [PATCH 284/289] Update required dependencies --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 385370884..a4a16aa2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ Django==3.2.9 -django-cors-headers==3.10.0 +django-cors-headers==3.10.1 django-debug-toolbar==3.2.2 django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.1.0 -django-redis==5.0.0 +django-redis==5.1.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==1.5.1 @@ -18,7 +18,7 @@ gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==7.3.6 +mkdocs-material==8.0.4 netaddr==0.8.0 Pillow==8.4.0 psycopg2-binary==2.9.2 From 0cd173f9dfaa1a31b152d1dda1f54c16f0111c66 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 13:25:09 -0500 Subject: [PATCH 285/289] Update django-taggit to 2.0 --- netbox/extras/tests/test_changelog.py | 6 +++--- netbox/extras/tests/test_filtersets.py | 4 ++-- netbox/extras/tests/test_webhooks.py | 8 ++++---- netbox/netbox/api/serializers.py | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 33251473f..e0be8c3bd 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -73,7 +73,7 @@ class ChangeLogViewTest(ModelViewTestCase): site = Site(name='Site 1', slug='site-1') site.save() tags = create_tags('Tag 1', 'Tag 2', 'Tag 3') - site.tags.set('Tag 1', 'Tag 2') + site.tags.set(['Tag 1', 'Tag 2']) form_data = { 'name': 'Site X', @@ -117,7 +117,7 @@ class ChangeLogViewTest(ModelViewTestCase): ) site.save() create_tags('Tag 1', 'Tag 2') - site.tags.set('Tag 1', 'Tag 2') + site.tags.set(['Tag 1', 'Tag 2']) request = { 'path': self._get_url('delete', instance=site), @@ -310,7 +310,7 @@ class ChangeLogAPITest(APITestCase): } ) site.save() - site.tags.set(*Tag.objects.all()[:2]) + site.tags.set(Tag.objects.all()[:2]) self.assertEqual(ObjectChange.objects.count(), 0) self.add_permissions('dcim.delete_site') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 656c3efdc..0f4b35cf6 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -542,8 +542,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): site = Site.objects.create(name='Site 1', slug='site-1') provider = Provider.objects.create(name='Provider 1', slug='provider-1') - site.tags.set(tags[0]) - provider.tags.set(tags[1]) + site.tags.set([tags[0]]) + provider.tags.set([tags[1]]) def test_name(self): params = {'name': ['Tag 1', 'Tag 2']} diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 811260f92..111ec6353 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -123,7 +123,7 @@ class WebhookTest(APITestCase): def test_enqueue_webhook_update(self): site = Site.objects.create(name='Site 1', slug='site-1') - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Update an object via the REST API data = { @@ -159,7 +159,7 @@ class WebhookTest(APITestCase): ) Site.objects.bulk_create(sites) for site in sites: - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Update three objects via the REST API data = [ @@ -205,7 +205,7 @@ class WebhookTest(APITestCase): def test_enqueue_webhook_delete(self): site = Site.objects.create(name='Site 1', slug='site-1') - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Delete an object via the REST API url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) @@ -231,7 +231,7 @@ class WebhookTest(APITestCase): ) Site.objects.bulk_create(sites) for site in sites: - site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar'])) + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) # Delete three objects via the REST API data = [ diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index 9f51d475d..4c26dbada 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -175,7 +175,7 @@ class PrimaryModelSerializer(CustomFieldModelSerializer): def _save_tags(self, instance, tags): if tags: - instance.tags.set(*[t.name for t in tags]) + instance.tags.set([t.name for t in tags]) else: instance.tags.clear() From 029605f926e4fab5cfb2260c8d6d03c7fe7161ac Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 13:43:02 -0500 Subject: [PATCH 286/289] Clean up site view --- netbox/dcim/views.py | 1 + netbox/templates/dcim/site.html | 524 +++++++++++++++++--------------- 2 files changed, 273 insertions(+), 252 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e353f24d0..b1a53e93c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -312,6 +312,7 @@ class SiteView(generic.ObjectView): def get_extra_context(self, request, instance): stats = { + 'location_count': Location.objects.restrict(request.user, 'view').filter(site=instance).count(), 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index b895d3cec..2ad970301 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -20,266 +20,286 @@ {% block content %}

-
-
-
- Site -
-
-
Physical Address + {% if object.physical_address %} + + {{ object.physical_address|linebreaksbr }} + {% else %} + + {% endif %} +
Shipping Address{{ object.shipping_address|linebreaksbr|placeholder }}
GPS Coordinates + {% if object.latitude and object.longitude %} + + {{ object.latitude }}, {{ object.longitude }} + {% else %} + + {% endif %} +
Contact Name @@ -180,8 +179,8 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
From c2f85a287780eb7b9711524eac1ea074f34bb9fc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 16:16:59 -0400 Subject: [PATCH 168/289] #6732: Show sites table under ASN view --- netbox/ipam/views.py | 7 +- netbox/templates/ipam/asn.html | 123 +++++++++++++++++---------------- 2 files changed, 67 insertions(+), 63 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c9d126f45..407f6d29e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -216,10 +216,13 @@ class ASNView(generic.ObjectView): queryset = ASN.objects.all() def get_extra_context(self, request, instance): - sites = instance.sites.restrict(request.user, 'view').all() + sites = instance.sites.restrict(request.user, 'view') + sites_table = SiteTable(sites) + paginate_table(sites_table, request) return { - 'sites': sites, + 'sites_table': sites_table, + 'sites_count': sites.count() } diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 8eafe7633..ad828c32e 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -6,72 +6,73 @@ {% block breadcrumbs %} {{ block.super }} -{% endblock %} +{% endblock breadcrumbs %} {% block content %} -
-
-
-
- ASN -
-
- - - - - - - - - - - - - - - - - -
AS Number{{ object.asn }}
RIR - {{ object.rir }} -
Tenant - {% if object.tenant %} - {% if prefix.object.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None - {% endif %} -
Description{{ object.description|placeholder }}
-
+
+
+
+
ASN
+
+ + + + + + + + + + + + + + + + + + + + + +
AS Number{{ object.asn }}
RIR + {{ object.rir }} +
Tenant + {% if object.tenant %} + {% if prefix.object.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
Sites + {% if sites_count %} + {{ sites_count }} + {% else %} + {{ sites_count }} + {% endif %} +
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} - {% plugin_left_page object %} +
+ {% plugin_left_page object %}
-
-
- Sites -
-
- {% if sites %} - {% for site in sites %} - {{ site }} - {% endfor %} - {% else %} - None - {% endif %} -
-
- {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% plugin_right_page object %}
-
-
+
+
- {% plugin_full_width_page object %} +
+
Sites
+
+ {% include 'inc/table.html' with table=sites_table %} +
+
+ {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %}
-
-{% endblock %} +
+{% endblock content %} From bc92f9221a8afc48cf97300fc05a711eb1dcb134 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 16:22:55 -0400 Subject: [PATCH 169/289] Tweak site ASN filters & tests --- netbox/dcim/filtersets.py | 8 +------ netbox/dcim/tests/test_filtersets.py | 33 ++++++++++++---------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 06b697502..f166a23de 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -131,17 +131,11 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Group (slug)', ) - asns_id = django_filters.ModelMultipleChoiceFilter( + asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), label='AS (ID)', ) - asns = django_filters.ModelMultipleChoiceFilter( - field_name='asns__asn', - queryset=ASN.objects.all(), - to_field_name='asn', - label='AS (Number)', - ) tag = TagFilter() class Meta: diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 0cbd892f5..0f3b994a2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -142,29 +142,23 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): ) Tenant.objects.bulk_create(tenants) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = ( + ASN(asn=64512, rir=rir, tenant=tenants[0]), + ASN(asn=64513, rir=rir, tenant=tenants[0]), + ASN(asn=64514, rir=rir, tenant=tenants[0]), + ) + ASN.objects.bulk_create(asns) + sites = ( Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), ) Site.objects.bulk_create(sites) - - rir = RIR.objects.create(name='RFC 6996', is_private=True) - - asns = ( - ASN(asn=64512, rir=rir, tenant=tenants[0]), - ASN(asn=64513, rir=rir, tenant=tenants[0]), - ASN(asn=64514, rir=rir, tenant=tenants[0]), - ASN(asn=65001, rir=rir, tenant=tenants[0]), - ASN(asn=65002, rir=rir, tenant=tenants[0]) - ) - ASN.objects.bulk_create(asns) - - asns[0].sites.set([sites[0]]) - asns[1].sites.set([sites[1]]) - asns[2].sites.set([sites[2]]) - asns[3].sites.set([sites[2]]) - asns[4].sites.set([sites[1]]) + sites[0].asns.set([asns[0]]) + sites[1].asns.set([asns[1]]) + sites[2].asns.set([asns[2]]) def test_name(self): params = {'name': ['Site 1', 'Site 2']} @@ -182,8 +176,9 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'asn': [65001, 65002]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asns(self): - params = {'asns': [64512, 65002]} + def test_asn_id(self): + asns = ASN.objects.all()[:2] + params = {'asn_id': [asns[0].pk, asns[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_latitude(self): From 83ac8696936a5a9a68c5c05542533a2b7a991032 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 16:29:43 -0400 Subject: [PATCH 170/289] Fix ASNs column in SiteTable --- netbox/dcim/tables/sites.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 5a5975e4a..8ef17c6f2 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -82,6 +82,7 @@ class SiteTable(BaseTable): linkify=True ) asn_count = LinkedCountColumn( + accessor=tables.A('asns.count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name='ASNs' @@ -99,7 +100,7 @@ class SiteTable(BaseTable): 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'description') + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') # From 2826f2715303d329278b33c6954a716af6430882 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Nov 2021 16:36:49 -0400 Subject: [PATCH 171/289] Split interface edit forms into subsections --- netbox/templates/dcim/interface_edit.html | 34 ++++++++++++++----- .../virtualization/vminterface_edit.html | 14 +++++--- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 2abe723f6..ace19c521 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -15,22 +15,40 @@
{% endif %} {% render_field form.name %} - {% render_field form.label %} {% render_field form.type %} - {% render_field form.parent %} - {% render_field form.bridge %} - {% render_field form.lag %} - {% render_field form.mac_address %} - {% render_field form.wwn %} - {% render_field form.mtu %} - {% render_field form.tx_power %} + {% render_field form.label %} {% render_field form.description %} {% render_field form.tags %} +
+ +
+
+
Addressing
+
+ {% render_field form.mac_address %} + {% render_field form.wwn %} +
+ +
+
+
Operation
+
+ {% render_field form.mtu %} + {% render_field form.tx_power %} {% render_field form.enabled %} {% render_field form.mgmt_only %} {% render_field form.mark_connected %}
+
+
+
Related Interfaces
+
+ {% render_field form.parent %} + {% render_field form.bridge %} + {% render_field form.lag %} +
+ {% if form.instance.is_wireless %}
diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index 824f2bf24..2e54d7e2b 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -15,13 +15,19 @@
{% endif %} {% render_field form.name %} - {% render_field form.enabled %} - {% render_field form.parent %} - {% render_field form.bridge %} + {% render_field form.description %} {% render_field form.mac_address %} {% render_field form.mtu %} - {% render_field form.description %} {% render_field form.tags %} + {% render_field form.enabled %} +
+ +
+
+
Related Interfaces
+
+ {% render_field form.parent %} + {% render_field form.bridge %}
From 830cf4b31f6036c4ca17422d9bace348f034c41e Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 28 Oct 2021 21:29:08 +0200 Subject: [PATCH 172/289] Fix #7399 - LDAP using excessive CPU when AUTH_LDAP_FIND_GROUP_PERMS is enabled --- netbox/netbox/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 653fad3b0..a67ec451d 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -34,7 +34,7 @@ class ObjectPermissionMixin(): object_permissions = ObjectPermission.objects.filter( self.get_permission_filter(user_obj), enabled=True - ).prefetch_related('object_types') + ).order_by('id').distinct('id').prefetch_related('object_types') # Create a dictionary mapping permissions to their constraints perms = defaultdict(list) From 2fd526b35940d558b41ca2bc1cc3af8d3c474096 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 08:33:16 -0400 Subject: [PATCH 173/289] Fix up contact template --- netbox/templates/tenancy/contact.html | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index af466cb30..79878b1ac 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -14,7 +14,7 @@
-
Tenant
+
Contact
@@ -49,9 +49,7 @@ - +
Assignments - {{ assignment_count }} - {{ assignment_count }}
From 773fd47ca6f7a33238d63816e4a589c2f2f5687c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 08:45:57 -0400 Subject: [PATCH 174/289] Fixes #7752: Fix minimum version check under Python v3.10 --- docs/release-notes/version-3.0.md | 4 ++++ netbox/netbox/settings.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 528b90846..eaac82444 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,10 @@ ## v3.0.10 (FUTURE) +### Bug Fixes + +* [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10 + --- ## v3.0.9 (2021-11-03) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ccf3b3752..b0c996141 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -4,6 +4,7 @@ import os import platform import re import socket +import sys import warnings from urllib.parse import urlsplit @@ -25,7 +26,7 @@ HOSTNAME = platform.node() BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Validate Python version -if platform.python_version_tuple() < ('3', '7'): +if sys.version_info < (3, 7): raise RuntimeError( f"NetBox requires Python 3.7 or higher (current: Python {platform.python_version()})" ) From cf80c1a5066eeff81e3e84df3da3ec91133161f0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 09:03:27 -0400 Subject: [PATCH 175/289] Release v3.1-beta1 --- docs/release-notes/version-3.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 616313ef2..f33ee605b 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,4 +1,4 @@ -## v3.1-beta1 (FUTURE) +## v3.1-beta1 (2021-11-05) !!! warning "PostgreSQL 10 Required" NetBox v3.1 requires PostgreSQL 10 or later. From e5d757866364a55795ca060f2fa4ecb5aed3cf2b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 11:10:17 -0400 Subject: [PATCH 176/289] Fixes #7750: Fix cable trace image link --- docs/core-functionality/devices.md | 10 ++++++++++ docs/models/dcim/cable.md | 10 ---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 67e3612b9..982ee3071 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -27,3 +27,13 @@ Device components represent discrete objects within a device which are used to t --- {!models/dcim/cable.md!} + +In the example below, three individual cables comprise a path between devices A and D: + +![Cable path](../media/models/dcim_cable_trace.png) + +Traced from Interface 1 on Device A, NetBox will show the following path: + +* Cable 1: Interface 1 to Front Port 1 +* Cable 2: Rear Port 1 to Rear Port 2 +* Cable 3: Front Port 2 to Interface 2 diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md index 87ec68e03..43c0abfab 100644 --- a/docs/models/dcim/cable.md +++ b/docs/models/dcim/cable.md @@ -22,13 +22,3 @@ Each cable may be assigned a type, label, length, and color. Each cable is also ## Tracing Cables A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user. - -In the example below, three individual cables comprise a path between devices A and D: - -![Cable path](../media/models/dcim_cable_trace.png) - -Traced from Interface 1 on Device A, NetBox will show the following path: - -* Cable 1: Interface 1 to Front Port 1 -* Cable 2: Rear Port 1 to Rear Port 2 -* Cable 3: Front Port 2 to Interface 2 From fab1d3651bcdb2728f9a7d79048d3834c4b055fd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 13:10:27 -0400 Subject: [PATCH 177/289] Add new models to developer docs --- docs/development/models.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/development/models.md b/docs/development/models.md index 59e795cf7..62dd016f3 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -41,15 +41,20 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [dcim.Site](../models/dcim/site.md) * [dcim.VirtualChassis](../models/dcim/virtualchassis.md) * [ipam.Aggregate](../models/ipam/aggregate.md) +* [ipam.ASN](../models/ipam/asn.md) +* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) * [ipam.IPAddress](../models/ipam/ipaddress.md) * [ipam.Prefix](../models/ipam/prefix.md) * [ipam.RouteTarget](../models/ipam/routetarget.md) * [ipam.Service](../models/ipam/service.md) * [ipam.VLAN](../models/ipam/vlan.md) * [ipam.VRF](../models/ipam/vrf.md) +* [tenancy.Contact](../models/tenancy/contact.md) * [tenancy.Tenant](../models/tenancy/tenant.md) * [virtualization.Cluster](../models/virtualization/cluster.md) * [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md) +* [wireless.WirelessLAN](../models/wireless/wirelesslan.md) +* [wireless.WirelessLink](../models/wireless/wirelesslink.md) ### Organizational Models @@ -61,6 +66,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [ipam.RIR](../models/ipam/rir.md) * [ipam.Role](../models/ipam/role.md) * [ipam.VLANGroup](../models/ipam/vlangroup.md) +* [tenancy.ContactRole](../models/tenancy/contactrole.md) * [virtualization.ClusterGroup](../models/virtualization/clustergroup.md) * [virtualization.ClusterType](../models/virtualization/clustertype.md) @@ -69,7 +75,9 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [dcim.Location](../models/dcim/location.md) (formerly RackGroup) * [dcim.Region](../models/dcim/region.md) * [dcim.SiteGroup](../models/dcim/sitegroup.md) +* [tenancy.ContactGroup](../models/tenancy/contactgroup.md) * [tenancy.TenantGroup](../models/tenancy/tenantgroup.md) +* [wireless.WirelessLANGroup](../models/wireless/wirelesslangroup.md) ### Component Models From f7d0db9cd25468fbf2a97f05761f670d5974f854 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 13:16:43 -0400 Subject: [PATCH 178/289] Fixes #7756: Fix AttributeError exception when editing an IP address assigned to a FHRPGroup --- docs/release-notes/version-3.1.md | 8 ++++++++ netbox/ipam/forms/models.py | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index f33ee605b..df7ad3a4a 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,3 +1,11 @@ +## v3.1-beta2 (FUTURE) + +### Bug Fixes + +* [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup + +--- + ## v3.1-beta1 (2021-11-05) !!! warning "PostgreSQL 10 Required" diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index afbe33282..7eda3c38d 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -444,8 +444,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Initialize primary_for_parent if IP address is already assigned if self.instance.pk and self.instance.assigned_object: - parent = self.instance.assigned_object.parent_object - if ( + parent = getattr(self.instance.assigned_object, 'parent_object', None) + if parent and ( self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk ): From 79f2f03fb241f35ff74bbd034df7ff26aa0be025 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 13:26:18 -0400 Subject: [PATCH 179/289] Issues policy tweaks --- .github/ISSUE_TEMPLATE/bug_report.yaml | 5 +---- CONTRIBUTING.md | 12 ++++-------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 6c13631d9..fa773eb13 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -13,10 +13,7 @@ body: - type: input attributes: label: NetBox version - description: > - What version of NetBox are you currently running? (If you don't have access to the most - recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) - before opening a bug report to see if your issue has already been addressed.) + description: What version of NetBox are you currently running? placeholder: v3.0.9 validations: required: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a3b1f002..a3627a2b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,14 +76,10 @@ free to add a comment with any additional justification for the feature. (However, note that comments with no substance other than a "+1" will be deleted. Please use GitHub's reactions feature to indicate your support.) -* Due to a large backlog of feature requests, we are not currently accepting -any proposals which substantially extend NetBox's functionality beyond its -current feature set. This includes the introduction of any new views or models -which have not already been proposed in an existing feature request. - -* Before filing a new feature request, consider raising your idea on the -mailing list first. Feedback you receive there will help validate and shape the -proposed feature before filing a formal issue. +* Before filing a new feature request, consider raising your idea in a +[GitHub discussion](https://github.com/netbox-community/netbox/discussions) +first. Feedback you receive there will help validate and shape the proposed +feature before filing a formal issue. * Good feature requests are very narrowly defined. Be sure to thoroughly describe the functionality and data model(s) being proposed. The more effort From 3eda8d84821cafcf8170f52be8300bdabdedb3ee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 13:31:36 -0400 Subject: [PATCH 180/289] Closes #7760: Add vid filter field to VLANs list --- docs/release-notes/version-3.0.md | 4 ++++ netbox/ipam/forms/filtersets.py | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index eaac82444..52ca0e5cf 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,10 @@ ## v3.0.10 (FUTURE) +### Enhancements + +* [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list + ### Bug Fixes * [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10 diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 8bc0f10fb..02fac75b3 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,3 +1,4 @@ +import django_filters from django import forms from django.utils.translation import gettext as _ @@ -409,7 +410,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo field_groups = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id'], - ['group_id', 'status', 'role_id'], + ['group_id', 'status', 'role_id', 'vid'], ['tenant_group_id', 'tenant_id'], ] q = forms.CharField( @@ -461,6 +462,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo label=_('Role'), fetch_trigger='open' ) + vid = forms.IntegerField( + required=False, + label='VLAN ID' + ) tag = TagFilterField(model) From e04402ed570ad702f8f7f32ba5e2ecc3d51829f1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 5 Nov 2021 13:40:38 -0400 Subject: [PATCH 181/289] Allow bypassing the pre-commit script with NOVALIDATE=1 --- scripts/git-hooks/pre-commit | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 71b8cca6e..7a3d680a4 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -11,6 +11,7 @@ exec 1>&2 EXIT=0 RED='\033[0;31m' +YELLOW='\033[0;33m' NOCOLOR='\033[0m' if [ -d ./venv/ ]; then @@ -22,6 +23,11 @@ if [ -d ./venv/ ]; then fi fi +if [ ${NOVALIDATE} ]; then + echo "${YELLOW}Skipping validation checks${NOCOLOR}" + exit $EXIT +fi + echo "Validating PEP8 compliance..." pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/ if [ $? != 0 ]; then From 9c2514fce4d8d6c46fddae8e79afe66631b468ae Mon Sep 17 00:00:00 2001 From: Nico Domino Date: Mon, 8 Nov 2021 14:15:26 +0100 Subject: [PATCH 182/289] feat: add outer_width to RackTable (#7766) * feat: add outer_width to RackTable * fix: add outer_units to column display * feat: add outer_depth to available columns --- netbox/dcim/tables/racks.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index f3d1cb7f8..982e2a47f 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -72,12 +72,20 @@ class RackTable(BaseTable): tags = TagColumn( url_name='dcim:rack_list' ) + outer_width = tables.TemplateColumn( + template_code="{{ record.outer_width }} {{ record.outer_unit }}", + verbose_name='Outer Width' + ) + outer_depth = tables.TemplateColumn( + template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + verbose_name='Outer Depth' + ) class Meta(BaseTable.Meta): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', - 'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', + 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', From 7b7afd3e7b75b810a48c1fe84f601f98ed2fef14 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 08:24:14 -0500 Subject: [PATCH 183/289] Changelog for #7765 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 52ca0e5cf..ddf32c680 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -9,6 +9,7 @@ ### Bug Fixes * [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10 +* [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table --- From 2ce8ef57046ed1e8ef4f71e0a0dc02ffdd8fdfac Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 08:34:10 -0500 Subject: [PATCH 184/289] Fixes #7771: Group assignment should be optional when creating contacts via REST API --- docs/release-notes/version-3.1.md | 1 + netbox/tenancy/api/serializers.py | 2 +- netbox/tenancy/tests/test_api.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index df7ad3a4a..1a0daf93f 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,6 +3,7 @@ ### Bug Fixes * [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup +* [#7771](https://github.com/netbox-community/netbox/issues/7771) - Group assignment should be optional when creating contacts via REST API --- diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index b97c3dac4..f60c8f258 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -79,7 +79,7 @@ class ContactRoleSerializer(PrimaryModelSerializer): class ContactSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail') - group = NestedContactGroupSerializer(required=False, allow_null=True) + group = NestedContactGroupSerializer(required=False, allow_null=True, default=None) class Meta: model = Contact diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 467352588..a4469e0f2 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -200,7 +200,6 @@ class ContactTest(APIViewTestCases.APIViewTestCase): }, { 'name': 'Contact 6', - 'group': contact_groups[1].pk, }, ] From 17fd6e692ed1118626306180bd7520d283d1ee17 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 08:40:24 -0500 Subject: [PATCH 185/289] Fixes #7768: Validate IP address status when creating a new FHRP group --- docs/release-notes/version-3.1.md | 1 + netbox/ipam/forms/models.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 1a0daf93f..650770b37 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,6 +3,7 @@ ### Bug Fixes * [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup +* [#7768](https://github.com/netbox-community/netbox/issues/7768) - Validate IP address status when creating a new FHRP group * [#7771](https://github.com/netbox-community/netbox/issues/7771) - Group assignment should be optional when creating contacts via REST API --- diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 7eda3c38d..2875c5182 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -576,6 +576,15 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm): return instance + def clean(self): + ip_address = self.cleaned_data['ip_address'] + ip_status = self.cleaned_data['ip_status'] + + if ip_address and not ip_status: + raise forms.ValidationError({ + 'ip_status': "Status must be set when creating a new IP address." + }) + class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): group = DynamicModelChoiceField( From b7aa44837fe5e152becda03ee1401c04766bc3dc Mon Sep 17 00:00:00 2001 From: Flo <52775027+FloEisen@users.noreply.github.com> Date: Mon, 8 Nov 2021 17:50:13 +0100 Subject: [PATCH 186/289] Add Mini-DIN 8 Console-Port-Type --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2f6228751..5a732fa8d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -185,6 +185,7 @@ class ConsolePortTypeChoices(ChoiceSet): TYPE_RJ11 = 'rj-11' TYPE_RJ12 = 'rj-12' TYPE_RJ45 = 'rj-45' + TYPE_MINI_DIN_8 = 'mini-din-8' TYPE_USB_A = 'usb-a' TYPE_USB_B = 'usb-b' TYPE_USB_C = 'usb-c' @@ -202,6 +203,7 @@ class ConsolePortTypeChoices(ChoiceSet): (TYPE_RJ11, 'RJ-11'), (TYPE_RJ12, 'RJ-12'), (TYPE_RJ45, 'RJ-45'), + (TYPE_MINI_DIN_8, 'Mini-DIN 8'), )), ('USB', ( (TYPE_USB_A, 'USB Type A'), From 3292a2aecc4dbefa80e5970dece66a69be4a8b28 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 14:52:56 -0500 Subject: [PATCH 187/289] Closes #7619: Permit custom validation rules to be defined as plain data or dotted path to class --- docs/customization/custom-validation.md | 100 +++++++++++++------- docs/release-notes/version-3.1.md | 4 + netbox/extras/signals.py | 13 +++ netbox/extras/tests/test_customvalidator.py | 35 +++++++ 4 files changed, 118 insertions(+), 34 deletions(-) diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index 720e8e487..bfa1fc1b1 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -1,22 +1,18 @@ # Custom Validation -NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using NetBox's `CustomValidator` class. +NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using custom validation rules. -## CustomValidator +## Custom Validation Rules -### Validation Rules +Custom validation rules are expressed as a mapping of model attributes to a set of rules to which that attribute must conform. For example: -A custom validator can be instantiated by passing a mapping of attributes to a set of rules to which that attribute must conform. For example: - -```python -from extras.validators import CustomValidator - -CustomValidator({ - 'name': { - 'min_length': 5, - 'max_length': 30, - } -}) +```json +{ + "name": { + "min_length": 5, + "max_length": 30 + } +} ``` This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation. @@ -38,12 +34,13 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len ### Custom Validation Logic -There may be instances where the provided validation types are insufficient. The `CustomValidator` class can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected. +There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected. ```python from extras.validators import CustomValidator class MyValidator(CustomValidator): + def validate(self, instance): if instance.status == 'active' and not instance.description: self.fail("Active sites must have a description set!", field='status') @@ -53,34 +50,69 @@ The `fail()` method may optionally specify a field with which to associate the s ## Assigning Custom Validators -Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter, as such: +Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined: + +1. Plain JSON mapping (no custom logic) +2. Dotted path to a custom validator class +3. Direct reference to a custom validator class + +### Plain Data + +For cases where custom logic is not needed, it is sufficient to pass validation rules as plain JSON-compatible objects. This approach typically affords the most portability for your configuration. For instance: + +```python +CUSTOM_VALIDATORS = { + "dcim.site": [ + { + "name": { + "min_length": 5, + "max_length": 30, + } + } + ], + "dcim.device": [ + { + "platform": { + "required": True, + } + } + ] +} +``` + +### Dotted Path + +In instances where a custom validator class is needed, it can be referenced by its Python path (relative to NetBox's working directory): ```python +CUSTOM_VALIDATORS = { + 'dcim.site': ( + 'my_validators.Validator1', + 'my_validators.Validator2', + ), + 'dcim.device': ( + 'my_validators.Validator3', + ) +} +``` + +### Direct Class Reference + +This approach requires each class being instantiated to be imported directly within the Python configuration file. + +```python +from my_validators import Validator1, Validator2, Validator3 + CUSTOM_VALIDATORS = { 'dcim.site': ( Validator1, Validator2, - Validator3 + ), + 'dcim.device': ( + Validator3, ) } ``` !!! note Even if defining only a single validator, it must be passed as an iterable. - -When it is not necessary to define a custom `validate()` method, you may opt to pass a `CustomValidator` instance directly: - -```python -from extras.validators import CustomValidator - -CUSTOM_VALIDATORS = { - 'dcim.site': ( - CustomValidator({ - 'name': { - 'min_length': 5, - 'max_length': 30, - } - }), - ) -} -``` diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 650770b37..746a64e62 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,5 +1,9 @@ ## v3.1-beta2 (FUTURE) +### Enhancements + +* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class + ### Bug Fixes * [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 9b37dd763..99bc91236 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -1,3 +1,4 @@ +import importlib import logging from django.conf import settings @@ -6,6 +7,7 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django_prometheus.models import model_deletes, model_inserts, model_updates +from extras.validators import CustomValidator from netbox.signals import post_clean from .choices import ObjectChangeActionChoices from .models import ConfigRevision, CustomField, ObjectChange @@ -159,7 +161,18 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type def run_custom_validators(sender, instance, **kwargs): model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' validators = settings.CUSTOM_VALIDATORS.get(model_name, []) + for validator in validators: + + # Loading a validator class by dotted path + if type(validator) is str: + module, cls = validator.rsplit('.', 1) + validator = getattr(importlib.import_module(module), cls)() + + # Constructing a new instance on the fly from a ruleset + elif type(validator) is dict: + validator = CustomValidator(validator) + validator(instance) diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidator.py index 373303fb1..89857b615 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidator.py @@ -119,3 +119,38 @@ class CustomValidatorTest(TestCase): @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]}) def test_custom_valid(self): Site(name='foo', slug='foo').clean() + + +class CustomValidatorConfigTest(TestCase): + + @override_settings( + CUSTOM_VALIDATORS={ + 'dcim.site': [ + {'name': {'min_length': 5}} + ] + } + ) + def test_plain_data(self): + """ + Test custom validator configuration using plain data (as opposed to a CustomValidator + class) + """ + with self.assertRaises(ValidationError): + Site(name='abcd', slug='abcd').clean() + Site(name='abcde', slug='abcde').clean() + + @override_settings( + CUSTOM_VALIDATORS={ + 'dcim.site': ( + 'extras.tests.test_customvalidator.MyValidator', + ) + } + ) + def test_dotted_path(self): + """ + Test custom validator configuration using a dotted path (string) reference to a + CustomValidator class. + """ + Site(name='foo', slug='foo').clean() + with self.assertRaises(ValidationError): + Site(name='bar', slug='bar').clean() From 2a00519b93afeffe638f0a27b1e204adbf66d59f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 15:07:58 -0500 Subject: [PATCH 188/289] Move CHANGELOG_RETENTION to dyanmic configuration --- docs/configuration/dynamic-settings.md | 12 ++++++++++++ docs/configuration/optional-settings.md | 12 ------------ netbox/extras/admin.py | 2 +- netbox/extras/management/commands/housekeeping.py | 10 ++++++---- netbox/netbox/config/parameters.py | 7 +++++++ netbox/netbox/configuration.example.py | 3 --- netbox/netbox/settings.py | 1 - 7 files changed, 26 insertions(+), 21 deletions(-) diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index 765e1d0f1..bb8fb33d7 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -31,6 +31,18 @@ This defines custom content to be displayed on the login page above the login fo --- +## CHANGELOG_RETENTION + +Default: 90 + +The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain +changes in the database indefinitely. + +!!! warning + If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + ## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index d3b82e995..49a4a776b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -25,18 +25,6 @@ BASE_PATH = 'netbox/' --- -## CHANGELOG_RETENTION - -Default: 90 - -The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain -changes in the database indefinitely. - -!!! warning - If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. - ---- - ## CORS_ORIGIN_ALLOW_ALL Default: False diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 752c8c83d..a905367c5 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -31,7 +31,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin): 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), }), ('Miscellaneous', { - 'fields': ('MAINTENANCE_MODE', 'MAPS_URL'), + 'fields': ('MAINTENANCE_MODE', 'CHANGELOG_RETENTION', 'MAPS_URL'), }), ('Config Revision', { 'fields': ('comment',), diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index a4d617c9a..0607a16c2 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -10,12 +10,14 @@ from django.utils import timezone from packaging import version from extras.models import ObjectChange +from netbox.config import Config class Command(BaseCommand): help = "Perform nightly housekeeping tasks. (This command can be run at any time.)" def handle(self, *args, **options): + config = Config() # Clear expired authentication sessions (essentially replicating the `clearsessions` command) if options['verbosity']: @@ -37,10 +39,10 @@ class Command(BaseCommand): # Delete expired ObjectRecords if options['verbosity']: self.stdout.write("[*] Checking for expired changelog records") - if settings.CHANGELOG_RETENTION: - cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) + if config.CHANGELOG_RETENTION: + cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION) if options['verbosity'] >= 2: - self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days") + self.stdout.write(f"\tRetention period: {config.CHANGELOG_RETENTION} days") self.stdout.write(f"\tCut-off time: {cutoff}") expired_records = ObjectChange.objects.filter(time__lt=cutoff).count() if expired_records: @@ -58,7 +60,7 @@ class Command(BaseCommand): self.stdout.write("\tNo expired records found.", self.style.SUCCESS) elif options['verbosity']: self.stdout.write( - f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})" + f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})" ) # Check for new releases (if enabled) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 8bf1d6dc5..7b9f6a1f7 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -130,6 +130,13 @@ PARAMS = ( description="Enable maintenance mode", field=forms.BooleanField ), + ConfigParam( + name='CHANGELOG_RETENTION', + label='Changelog retention', + default=90, + description="Days to retain changelog history (set to zero for unlimited)", + field=forms.IntegerField + ), ConfigParam( name='MAPS_URL', label='Maps URL', diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 189e98d11..947ed6d53 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -76,9 +76,6 @@ ADMINS = [ # BASE_PATH = 'netbox/' BASE_PATH = '' -# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) -CHANGELOG_RETENTION = 90 - # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f4f6c2f32..c91a5e75b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -80,7 +80,6 @@ ADMINS = getattr(configuration, 'ADMINS', []) BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only -CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) From f8e44c09ebd54704594eb1c544a98a7e3e991fa9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 15:22:29 -0500 Subject: [PATCH 189/289] Move CUSTOM_VALIDATORS to dynamic configuration --- docs/configuration/dynamic-settings.md | 23 +++++++++++++++++++++++ docs/configuration/optional-settings.md | 16 ---------------- netbox/extras/admin.py | 3 +++ netbox/extras/signals.py | 5 +++-- netbox/netbox/config/parameters.py | 9 +++++++++ netbox/netbox/configuration.example.py | 14 -------------- netbox/netbox/settings.py | 1 - 7 files changed, 38 insertions(+), 33 deletions(-) diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index bb8fb33d7..a98143045 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -43,6 +43,29 @@ changes in the database indefinitely. --- +## CUSTOM_VALIDATORS + +This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: + +```python +CUSTOM_VALIDATORS = { + "dcim.site": [ + { + "name": { + "min_length": 5, + "max_length": 30 + } + }, + "my_plugin.validators.Validator1" + ], + "dim.device": [ + "my_plugin.validators.Validator1" + ] +} +``` + +--- + ## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 49a4a776b..e49968130 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -49,22 +49,6 @@ CORS_ORIGIN_WHITELIST = [ --- -## CUSTOM_VALIDATORS - -This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: - -```python -CUSTOM_VALIDATORS = { - 'dcim.site': ( - Validator1, - Validator2, - Validator3 - ) -} -``` - ---- - ## DEBUG Default: False diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index a905367c5..73ffb40fc 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -27,6 +27,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('Pagination', { 'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'), }), + ('Validation', { + 'fields': ('CUSTOM_VALIDATORS',), + }), ('NAPALM', { 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), }), diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 99bc91236..77931f268 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -1,13 +1,13 @@ import importlib import logging -from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django_prometheus.models import model_deletes, model_inserts, model_updates from extras.validators import CustomValidator +from netbox.config import get_config from netbox.signals import post_clean from .choices import ObjectChangeActionChoices from .models import ConfigRevision, CustomField, ObjectChange @@ -159,8 +159,9 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type @receiver(post_clean) def run_custom_validators(sender, instance, **kwargs): + config = get_config() model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' - validators = settings.CUSTOM_VALIDATORS.get(model_name, []) + validators = config.CUSTOM_VALIDATORS.get(model_name, []) for validator in validators: diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 7b9f6a1f7..1be664b28 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -94,6 +94,15 @@ PARAMS = ( field=forms.IntegerField ), + # Validation + ConfigParam( + name='CUSTOM_VALIDATORS', + label='Custom validators', + default={}, + description="Custom validation rules (JSON)", + field=forms.JSONField + ), + # NAPALM ConfigParam( name='NAPALM_USERNAME', diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 947ed6d53..48885f844 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -87,20 +87,6 @@ CORS_ORIGIN_REGEX_WHITELIST = [ # r'^(https?://)?(\w+\.)?example\.com$', ] -# Specify any custom validators here, as a mapping of model to a list of validators classes. Validators should be -# instances of or inherit from CustomValidator. -# from extras.validators import CustomValidator -CUSTOM_VALIDATORS = { - # 'dcim.site': [ - # CustomValidator({ - # 'name': { - # 'min_length': 10, - # 'regex': r'\d{3}$', - # } - # }) - # ], -} - # Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal # sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging # on a production system. diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c91a5e75b..529da77dc 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -83,7 +83,6 @@ if BASE_PATH: CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) -CUSTOM_VALIDATORS = getattr(configuration, 'CUSTOM_VALIDATORS', {}) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) From d2391b9c63ecdac04121e18f2f072f7c62193d2a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 15:31:09 -0500 Subject: [PATCH 190/289] Move GRAPHQL_ENABLED to dynamic configuration --- docs/configuration/dynamic-settings.md | 8 ++++++++ docs/configuration/optional-settings.md | 8 -------- netbox/extras/admin.py | 2 +- netbox/netbox/config/parameters.py | 7 +++++++ netbox/netbox/configuration.example.py | 3 --- netbox/netbox/graphql/views.py | 4 +++- netbox/netbox/settings.py | 1 - netbox/templates/base/layout.html | 2 +- 8 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index a98143045..a222272c2 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -74,6 +74,14 @@ By default, NetBox will permit users to create duplicate prefixes and IP address --- +## GRAPHQL_ENABLED + +Default: True + +Setting this to False will disable the GraphQL API. + +--- + ## MAINTENANCE_MODE Default: False diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index e49968130..d8d79b6ec 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -140,14 +140,6 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- -## GRAPHQL_ENABLED - -Default: True - -Setting this to False will disable the GraphQL API. - ---- - ## HTTP_PROXIES Default: None diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 73ffb40fc..b6ee01db9 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -34,7 +34,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin): 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), }), ('Miscellaneous', { - 'fields': ('MAINTENANCE_MODE', 'CHANGELOG_RETENTION', 'MAPS_URL'), + 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), }), ('Config Revision', { 'fields': ('comment',), diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 1be664b28..b4f16bf28 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -139,6 +139,13 @@ PARAMS = ( description="Enable maintenance mode", field=forms.BooleanField ), + ConfigParam( + name='GRAPHQL_ENABLED', + label='GraphQL enabled', + default=True, + description="Enable the GraphQL API", + field=forms.BooleanField + ), ConfigParam( name='CHANGELOG_RETENTION', label='Changelog retention', diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 48885f844..8130acb2e 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -112,9 +112,6 @@ EXEMPT_VIEW_PERMISSIONS = [ # 'ipam.prefix', ] -# Enable the GraphQL API -GRAPHQL_ENABLED = True - # HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks). # HTTP_PROXIES = { # 'http': 'http://10.10.1.10:3128', diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py index c2c0269fa..e1573dba6 100644 --- a/netbox/netbox/graphql/views.py +++ b/netbox/netbox/graphql/views.py @@ -6,6 +6,7 @@ from graphene_django.views import GraphQLView as GraphQLView_ from rest_framework.exceptions import AuthenticationFailed from netbox.api.authentication import TokenAuthentication +from netbox.config import get_config class GraphQLView(GraphQLView_): @@ -15,9 +16,10 @@ class GraphQLView(GraphQLView_): graphiql_template = 'graphiql.html' def dispatch(self, request, *args, **kwargs): + config = get_config() # Enforce GRAPHQL_ENABLED - if not settings.GRAPHQL_ENABLED: + if not config.GRAPHQL_ENABLED: return HttpResponseNotFound("The GraphQL API is not enabled.") # Attempt to authenticate the user using a DRF token, if provided diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 529da77dc..5e5718559 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -90,7 +90,6 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) -GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 2770a6dc6..d45dc62f6 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -127,7 +127,7 @@ {# GraphQL API #} - {% if settings.GRAPHQL_ENABLED %} + {% if config.GRAPHQL_ENABLED %} From f3d8f1b1fb06be80ef7afc2fc6c145f2fe83922a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 8 Nov 2021 15:38:55 -0500 Subject: [PATCH 191/289] Changelog for #7775 --- docs/release-notes/version-3.1.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 746a64e62..b19e7979c 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,6 +3,7 @@ ### Enhancements * [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class +* [#7775](https://github.com/netbox-community/netbox/issues/7775) - Enable dynamic config for `CHANGELOG_RETENTION`, `CUSTOM_VALIDATORS`, and `GRAPHQL_ENABLED` ### Bug Fixes From 3f72492a59550469bc621df8071948f26c3fbacb Mon Sep 17 00:00:00 2001 From: Jason Yates Date: Tue, 9 Nov 2021 15:18:46 +0000 Subject: [PATCH 192/289] Fixed #7783 - Site location visual changes Updating site location list to visually match the /dcim/locations list where child locations are "indtended" with mdi-circle-small. Also removes the padding-left attribute on each row as it is no longer functional. --- netbox/templates/dcim/site.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 1ee8cfce0..2afa5376c 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -219,8 +219,8 @@
- + + {% for i in location.level|as_range %}{% endfor %} {{ location }} From 82210cc116bb54d63a80cf54ec431d14b32da7c8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Nov 2021 15:15:34 -0500 Subject: [PATCH 193/289] Changelog for #7783 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index ddf32c680..8c2d3cbad 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -10,6 +10,7 @@ * [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10 * [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table +* [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view --- From d08ed9fe5f69d736d566f8f5f6ebba7c3b25951a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Nov 2021 15:24:21 -0500 Subject: [PATCH 194/289] Fixes #7780: Preserve mutli-line values during CSV file import --- docs/release-notes/version-3.0.md | 1 + netbox/utilities/forms/fields.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 8c2d3cbad..6fe9948a0 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -10,6 +10,7 @@ * [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10 * [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table +* [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve mutli-line values during CSV file import * [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view --- diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 2561c2e22..d9f1719ec 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -224,7 +224,7 @@ class CSVFileField(forms.FileField): return None csv_str = file.read().decode('utf-8').strip() - reader = csv.reader(csv_str.splitlines()) + reader = csv.reader(StringIO(csv_str)) headers, records = parse_csv(reader) return headers, records From 9dad7e4daf22bce29ce9318f849a81dac2647c33 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Nov 2021 16:04:16 -0500 Subject: [PATCH 195/289] Fixes #7701: Fix conflation of assigned IP status & role in interface tables --- docs/release-notes/version-3.0.md | 1 + netbox/dcim/tables/template_code.py | 18 +++++++----------- netbox/project-static/dist/netbox-dark.css | Bin 788776 -> 788777 bytes netbox/project-static/dist/netbox-light.css | Bin 493600 -> 493601 bytes netbox/project-static/dist/netbox-print.css | Bin 1623462 -> 1623463 bytes netbox/project-static/styles/netbox.scss | 2 +- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 6fe9948a0..1f3716383 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -8,6 +8,7 @@ ### Bug Fixes +* [#7701](https://github.com/netbox-community/netbox/issues/7701) - Fix conflation of assigned IP status & role in interface tables * [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10 * [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table * [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve mutli-line values during CSV file import diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2f359e1b9..092fe3b95 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -40,17 +40,13 @@ DEVICEBAY_STATUS = """ INTERFACE_IPADDRESSES = """
- {% for ip in record.ip_addresses.all %} - - {{ ip }} - - {% endfor %} + {% for ip in record.ip_addresses.all %} + {% if ip.status != 'active' %} + {{ ip }} + {% else %} + {{ ip }} + {% endif %} + {% endfor %}
""" diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index b06cca0a11d640e5d728a81d25e0745825aa204a..bac0e589ac527f59e5f9cb369769f0e2c93501e1 100644 GIT binary patch delta 52 zcmV-40L%ZVk}#=~Fo1*sgaU*Egam{Iga(8Mgb0KQgbK6^(fgO5rU(U>r=ANVm(KkQ K47UXQ3nUE$+7q7u delta 46 zcmV+}0MY-ck}#-}Fo1*sgaU*Egam{Iga(8Mgb0KQgbK6^(fgC2s3(`t{R<4Y1^f#n E4VeHDumAu6 diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index cf06883a981bf8a07b403e0a852b2a0a12b620af..3ae6b577918c8c0e2ee575ae28d6e8002fd11ebb 100644 GIT binary patch delta 31 ncmZ25L2ls$xrP?T7N!>F7M2#)7Pc+y&+8bCwm+|9x8wu>wF7M2#)7Pc+y&+Df1+q1K7e_O|H!U+J_zYD?u diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 7e565c3d5be6b900a59495cd008ed04c0bad79ad..7974e338d08659ae8a191e608fd831cf2d961d6a 100644 GIT binary patch delta 87 zcmV-d0I2__i({vYV}OJKgaU*Egam{Iga(8Mgb0KQgbIWUgbaiYgbsucgb;)ggc5`k tgcO7ogcgJsv=|Pnm#~5n1(!(g7ZjJxs~8WLXR8<;lQ0qnw^*wfP8tO?9UuSz delta 81 zcmZ49oxH3&xuJ!zg{g(Pg{6hHg{_6Xg` Date: Tue, 9 Nov 2021 16:08:11 -0500 Subject: [PATCH 196/289] Changelog for #7740 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 1f3716383..ef99c4a0e 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -4,6 +4,7 @@ ### Enhancements +* [#7740](https://github.com/netbox-community/netbox/issues/7740) - Add mini-DIN 8 console port type * [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list ### Bug Fixes From 3ad773beb3bfadf2b6ecaa5dd843acba3a2fb49e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Nov 2021 16:46:58 -0500 Subject: [PATCH 197/289] Fixes #7741: Fix 404 when attaching multiple images in succession --- docs/release-notes/version-3.0.md | 1 + netbox/extras/models/models.py | 2 ++ netbox/extras/views.py | 6 +----- .../inc/image_attachments_panel.html | 2 +- netbox/utilities/templatetags/helpers.py | 20 +++++++++++++++++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index ef99c4a0e..7a3005421 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -10,6 +10,7 @@ ### Bug Fixes * [#7701](https://github.com/netbox-community/netbox/issues/7701) - Fix conflation of assigned IP status & role in interface tables +* [#7741](https://github.com/netbox-community/netbox/issues/7741) - Fix 404 when attaching multiple images in succession * [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10 * [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table * [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve mutli-line values during CSV file import diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 75f5242d3..2c56f2f0f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -357,6 +357,8 @@ class ImageAttachment(BigIDModel): objects = RestrictedQuerySet.as_manager() + clone_fields = ('content_type', 'object_id') + class Meta: ordering = ('name', 'pk') # name may be non-unique diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d39f50c79..b0387c73d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -475,11 +475,7 @@ class ImageAttachmentEditView(generic.ObjectEditView): def alter_obj(self, instance, request, args, kwargs): if not instance.pk: # Assign the parent object based on URL kwargs - try: - app_label, model = request.GET.get('content_type').split('.') - except (AttributeError, ValueError): - raise Http404("Content type not specified") - content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) return instance diff --git a/netbox/templates/inc/image_attachments_panel.html b/netbox/templates/inc/image_attachments_panel.html index ca7312901..9706a7ffe 100644 --- a/netbox/templates/inc/image_attachments_panel.html +++ b/netbox/templates/inc/image_attachments_panel.html @@ -44,7 +44,7 @@ {% if perms.extras.add_imageattachment %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 1695c8257..5b5534321 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -6,6 +6,7 @@ from typing import Dict, Any import yaml from django import template from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse from django.utils import timezone @@ -78,6 +79,25 @@ def meta(obj, attr): return getattr(obj._meta, attr, '') +@register.filter() +def content_type(obj): + """ + Return the ContentType for the given object. + """ + return ContentType.objects.get_for_model(obj) + + +@register.filter() +def content_type_id(obj): + """ + Return the ContentType ID for the given object. + """ + content_type = ContentType.objects.get_for_model(obj) + if content_type: + return content_type.pk + return None + + @register.filter() def viewname(model, action): """ From 34f24de3e48482486255c6c60983b81f2247d425 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Nov 2021 17:08:28 -0500 Subject: [PATCH 198/289] Fixes #7757: Fix 404 when assigning multiple contacts/FHRP groups in succession --- docs/release-notes/version-3.1.md | 1 + netbox/ipam/models/fhrp.py | 6 +-- netbox/ipam/views.py | 6 +-- netbox/templates/inc/panels/contacts.html | 2 +- netbox/templates/inc/panels/nhrp_groups.html | 49 ------------------- .../ipam/inc/panels/fhrp_groups.html | 6 +-- netbox/tenancy/models/contacts.py | 2 + netbox/tenancy/views.py | 6 +-- 8 files changed, 12 insertions(+), 66 deletions(-) delete mode 100644 netbox/templates/inc/panels/nhrp_groups.html diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index b19e7979c..912a68357 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -8,6 +8,7 @@ ### Bug Fixes * [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup +* [#7757](https://github.com/netbox-community/netbox/issues/7757) - Fix 404 when assigning multiple contacts/FHRP groups in succession * [#7768](https://github.com/netbox-community/netbox/issues/7768) - Validate IP address status when creating a new FHRP group * [#7771](https://github.com/netbox-community/netbox/issues/7771) - Group assignment should be optional when creating contacts via REST API diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 5a12a54bb..e6624b27b 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -52,9 +52,7 @@ class FHRPGroup(PrimaryModel): objects = RestrictedQuerySet.as_manager() - clone_fields = [ - 'protocol', 'auth_type', 'auth_key' - ] + clone_fields = ('protocol', 'auth_type', 'auth_key') class Meta: ordering = ['protocol', 'group_id', 'pk'] @@ -91,6 +89,8 @@ class FHRPGroupAssignment(ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() + clone_fields = ('interface_type', 'interface_id') + class Meta: ordering = ('-priority', 'pk') unique_together = ('interface_type', 'interface_id', 'group') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 407f6d29e..2267a27d3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -976,11 +976,7 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView): def alter_obj(self, instance, request, args, kwargs): if not instance.pk: # Assign the interface based on URL kwargs - try: - app_label, model = request.GET.get('interface_type').split('.') - except (AttributeError, ValueError): - raise Http404("Content type not specified") - content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + content_type = get_object_or_404(ContentType, pk=request.GET.get('interface_type')) instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id')) return instance diff --git a/netbox/templates/inc/panels/contacts.html b/netbox/templates/inc/panels/contacts.html index 872c11153..e3e5cf483 100644 --- a/netbox/templates/inc/panels/contacts.html +++ b/netbox/templates/inc/panels/contacts.html @@ -41,7 +41,7 @@ {% if perms.tenancy.add_contactassignment %} diff --git a/netbox/templates/inc/panels/nhrp_groups.html b/netbox/templates/inc/panels/nhrp_groups.html deleted file mode 100644 index 223354441..000000000 --- a/netbox/templates/inc/panels/nhrp_groups.html +++ /dev/null @@ -1,49 +0,0 @@ -{% load helpers %} - -
-
Contacts
-
- {% with fhrp_groups=object.fhrp_group_assignments.all %} - {% if contacts.exists %} - - - - - - - - {% for contact in contacts %} - - - - - - - {% endfor %} -
ProtocolGroup IDPriority
- {{ contact.contact }} - {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} - {% if perms.tenancy.change_contactassignment %} - - - - {% endif %} - {% if perms.tenancy.delete_contactassignment %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} - {% endwith %} -
- {% if perms.tenancy.add_contactassignment %} - - {% endif %} -
diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html index 9692927a3..6583694ef 100644 --- a/netbox/templates/ipam/inc/panels/fhrp_groups.html +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -46,7 +46,7 @@
NoneNone
- {% badge vc_member.vc_position %} + {% badge vc_member.vc_position show_empty=True %} {% if object.master == vc_member %} From 2f064cdfd1429ee835062cdb9d88ffc64f3ddf9a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 11 Nov 2021 12:30:28 -0500 Subject: [PATCH 205/289] Changelog for #7767 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index fdbf58908..366f0bca0 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -6,6 +6,7 @@ * [#7740](https://github.com/netbox-community/netbox/issues/7740) - Add mini-DIN 8 console port type * [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list +* [#7767](https://github.com/netbox-community/netbox/issues/7767) - Add visual aids to interfaces table for type, enabled status ### Bug Fixes From 83b21027055ec9e619b33e30423da49fe0d5f326 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 11 Nov 2021 14:05:35 -0500 Subject: [PATCH 206/289] Closes #7769: Enable assignment of IP addresses to an existing FHRP group --- docs/release-notes/version-3.1.md | 1 + netbox/ipam/forms/models.py | 20 ++++-- netbox/ipam/models/fhrp.py | 2 +- netbox/ipam/views.py | 6 ++ netbox/templates/ipam/fhrpgroup.html | 7 +++ netbox/templates/ipam/ipaddress_edit.html | 76 ++++++++++------------- 6 files changed, 63 insertions(+), 49 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 912a68357..2c09082af 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,6 +3,7 @@ ### Enhancements * [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class +* [#7769](https://github.com/netbox-community/netbox/issues/7769) - Enable assignment of IP addresses to an existing FHRP group * [#7775](https://github.com/netbox-community/netbox/issues/7775) - Enable dynamic config for `CHANGELOG_RETENTION`, `CUSTOM_VALIDATORS`, and `GRAPHQL_ENABLED` ### Bug Fixes diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 2875c5182..eea3bb216 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -321,6 +321,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'virtual_machine_id': '$virtual_machine' } ) + fhrpgroup = DynamicModelChoiceField( + queryset=FHRPGroup.objects.all(), + required=False, + label='FHRP Group' + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -428,6 +433,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): initial['interface'] = instance.assigned_object elif type(instance.assigned_object) is VMInterface: initial['vminterface'] = instance.assigned_object + elif type(instance.assigned_object) is FHRPGroup: + initial['fhrpgroup'] = instance.assigned_object if instance.nat_inside: nat_inside_parent = instance.nat_inside.assigned_object if type(nat_inside_parent) is Interface: @@ -454,10 +461,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def clean(self): super().clean() - # Cannot select both a device interface and a VM interface - if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): - raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") - self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + # Handle object assignment + if self.cleaned_data['interface']: + self.instance.assigned_object = self.cleaned_data['interface'] + elif self.cleaned_data['vminterface']: + self.instance.assigned_object = self.cleaned_data['vminterface'] + elif self.cleaned_data['fhrpgroup']: + self.instance.assigned_object = self.cleaned_data['fhrpgroup'] # Primary IP assignment is only available if an interface has been assigned. interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') @@ -471,7 +481,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. interface = self.instance.assigned_object - if interface: + if type(interface) in (Interface, VMInterface): parent = interface.parent_object if self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index e6624b27b..42ba8d84d 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -47,7 +47,7 @@ class FHRPGroup(PrimaryModel): to='ipam.IPAddress', content_type_field='assigned_object_type', object_id_field='assigned_object_id', - related_query_name='fhrp_group' + related_query_name='fhrpgroup' ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 2267a27d3..21f0857b3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -739,6 +739,12 @@ class IPAddressEditView(generic.ObjectEditView): except (ValueError, VMInterface.DoesNotExist): pass + elif 'fhrpgroup' in request.GET: + try: + obj.assigned_object = FHRPGroup.objects.get(pk=request.GET['fhrpgroup']) + except (ValueError, FHRPGroup.DoesNotExist): + pass + return obj diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index a7fa1a248..0ee94ab90 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -68,6 +68,13 @@
None
{% endif %} + {% if perms.ipam.add_ipaddress %} + + {% endif %}
Members
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index c23fcd63c..e611aefbb 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -33,51 +33,41 @@
Interface Assignment
- {% with vm_tab_active=form.initial.vminterface %} -
-
- -
+
+
+
-
-
- {% render_field form.device %} - {% render_field form.interface %} -
-
- {% render_field form.virtual_machine %} - {% render_field form.vminterface %} -
- {% render_field form.primary_for_parent %} +
+
+
+ {% render_field form.device %} + {% render_field form.interface %}
- {% endwith %} +
+ {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
+
+ {% render_field form.fhrpgroup %} +
+ {% render_field form.primary_for_parent %} +
From 834f68e6e43e7bdded9a5df62e9aa31101c433d3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 11 Nov 2021 14:45:10 -0500 Subject: [PATCH 207/289] Fixes #7761: Extend cable tracing across bridged interfaces --- docs/release-notes/version-3.1.md | 1 + netbox/dcim/models/device_components.py | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 2c09082af..a8b1711c9 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,6 +3,7 @@ ### Enhancements * [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class +* [#7761](https://github.com/netbox-community/netbox/issues/7761) - Extend cable tracing across bridged interfaces * [#7769](https://github.com/netbox-community/netbox/issues/7769) - Enable assignment of IP addresses to an existing FHRP group * [#7775](https://github.com/netbox-community/netbox/issues/7775) - Enable dynamic config for `CHANGELOG_RETENTION`, `CUSTOM_VALIDATORS`, and `GRAPHQL_ENABLED` diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3896e5e83..ff10bc853 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -189,15 +189,23 @@ class PathEndpoint(models.Model): abstract = True def trace(self): - if self._path is None: - return [] + origin = self + path = [] # Construct the complete path - path = [self, *self._path.get_path()] - while (len(path) + 1) % 3: - # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort) - path.append(None) - path.append(self._path.destination) + while origin is not None: + + if origin._path is None: + return path + + path.extend([origin, *origin._path.get_path()]) + while (len(path) + 1) % 3: + # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort) + path.append(None) + path.append(origin._path.destination) + + # Check for bridge interface to continue the trace + origin = getattr(origin._path.destination, 'bridge', None) # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) From d529c1b5b33c1578691aec36765e01339e362d84 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 11 Nov 2021 15:04:22 -0500 Subject: [PATCH 208/289] Housekeeping: Use RestrictedQuerySet for default manager on base models --- netbox/circuits/models/circuits.py | 7 ------- netbox/circuits/models/providers.py | 4 ---- netbox/dcim/models/cables.py | 3 --- netbox/dcim/models/device_component_templates.py | 3 --- netbox/dcim/models/device_components.py | 3 --- netbox/dcim/models/devices.py | 11 ----------- netbox/dcim/models/power.py | 5 ----- netbox/dcim/models/racks.py | 7 ------- netbox/dcim/models/sites.py | 3 --- netbox/extras/models/models.py | 8 -------- netbox/extras/models/tags.py | 3 --- netbox/ipam/models/fhrp.py | 5 ----- netbox/ipam/models/ip.py | 11 ----------- netbox/ipam/models/services.py | 3 --- netbox/ipam/models/vlans.py | 3 --- netbox/ipam/models/vrfs.py | 5 ----- netbox/netbox/models.py | 7 +++++++ netbox/tenancy/models/contacts.py | 7 ------- netbox/tenancy/models/tenants.py | 3 --- netbox/virtualization/models.py | 9 --------- netbox/wireless/models.py | 5 ----- 21 files changed, 7 insertions(+), 108 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 3af3c6bc0..602c0f403 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -7,7 +7,6 @@ from circuits.choices import * from dcim.models import LinkTermination from extras.utils import extras_features from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel -from utilities.querysets import RestrictedQuerySet __all__ = ( 'Circuit', @@ -35,8 +34,6 @@ class CircuitType(OrganizationalModel): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -123,8 +120,6 @@ class Circuit(PrimaryModel): null=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', ] @@ -195,8 +190,6 @@ class CircuitTermination(ChangeLoggedModel, LinkTermination): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 1449e9520..b3a6902f9 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -59,8 +59,6 @@ class Provider(PrimaryModel): to='tenancy.ContactAssignment' ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', ] @@ -97,8 +95,6 @@ class ProviderNetwork(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('provider', 'name') constraints = ( diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 54012f0e9..333972b21 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -14,7 +14,6 @@ from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_ob from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField -from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters from .devices import Device from .device_components import FrontPort, RearPort @@ -116,8 +115,6 @@ class Cable(PrimaryModel): null=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['pk'] unique_together = ( diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index e704f74a7..42e453669 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -7,7 +7,6 @@ from dcim.constants import * from extras.utils import extras_features from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField -from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, @@ -50,8 +49,6 @@ class ComponentTemplateModel(ChangeLoggedModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: abstract = True diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index ff10bc853..75363b4f0 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -16,7 +16,6 @@ from netbox.models import PrimaryModel from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface -from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from wireless.choices import * from wireless.utils import get_channel_attr @@ -65,8 +64,6 @@ class ComponentModel(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: abstract = True diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 418944a4a..7039f6cdd 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -18,7 +18,6 @@ from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.querysets import RestrictedQuerySet from .device_components import * @@ -59,8 +58,6 @@ class Manufacturer(OrganizationalModel): to='tenancy.ContactAssignment' ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -137,8 +134,6 @@ class DeviceType(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', ] @@ -379,8 +374,6 @@ class DeviceRole(OrganizationalModel): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -431,8 +424,6 @@ class Platform(OrganizationalModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -896,8 +887,6 @@ class VirtualChassis(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] verbose_name_plural = 'virtual chassis' diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 30e11b342..b5d8d4c83 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -8,7 +8,6 @@ from dcim.choices import * from dcim.constants import * from extras.utils import extras_features from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -49,8 +48,6 @@ class PowerPanel(PrimaryModel): to='extras.ImageAttachment' ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['site', 'name'] unique_together = ['site', 'name'] @@ -131,8 +128,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'available_power', diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 0bc28acaa..082ecfe57 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -18,7 +18,6 @@ from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.querysets import RestrictedQuerySet from utilities.utils import array_to_string from .device_components import PowerOutlet, PowerPort from .devices import Device @@ -56,8 +55,6 @@ class RackRole(OrganizationalModel): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -190,8 +187,6 @@ class Rack(PrimaryModel): to='extras.ImageAttachment' ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', @@ -471,8 +466,6 @@ class RackReservation(PrimaryModel): max_length=200 ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['created', 'pk'] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 79f8921d5..fd40b30c4 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -11,7 +11,6 @@ from dcim.fields import ASNField from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField -from utilities.querysets import RestrictedQuerySet __all__ = ( 'Location', @@ -259,8 +258,6 @@ class Site(PrimaryModel): to='extras.ImageAttachment' ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index fc2662826..5d4379d6a 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -125,8 +125,6 @@ class Webhook(ChangeLoggedModel): 'Leave blank to use the system defaults.' ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('name',) unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) @@ -222,8 +220,6 @@ class CustomLink(ChangeLoggedModel): help_text="Force link to open in a new window" ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['group_name', 'weight', 'name'] @@ -268,8 +264,6 @@ class ExportTemplate(ChangeLoggedModel): help_text="Download file as attachment" ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['content_type', 'name'] unique_together = [ @@ -429,8 +423,6 @@ class JournalEntry(ChangeLoggedModel): ) comments = models.TextField() - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('-created',) verbose_name_plural = 'journal entries' diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index afeeee53d..e5e740eb5 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -7,7 +7,6 @@ from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel from utilities.choices import ColorChoices from utilities.fields import ColorField -from utilities.querysets import RestrictedQuerySet # @@ -24,8 +23,6 @@ class Tag(ChangeLoggedModel, TagBase): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 42ba8d84d..0176b2f71 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -8,7 +8,6 @@ from extras.utils import extras_features from netbox.models import ChangeLoggedModel, PrimaryModel from ipam.choices import * from ipam.constants import * -from utilities.querysets import RestrictedQuerySet __all__ = ( 'FHRPGroup', @@ -50,8 +49,6 @@ class FHRPGroup(PrimaryModel): related_query_name='fhrpgroup' ) - objects = RestrictedQuerySet.as_manager() - clone_fields = ('protocol', 'auth_type', 'auth_key') class Meta: @@ -87,8 +84,6 @@ class FHRPGroupAssignment(ChangeLoggedModel): ) ) - objects = RestrictedQuerySet.as_manager() - clone_fields = ('interface_type', 'interface_id') class Meta: diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b6c0a1b6b..c361acd01 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -18,7 +18,6 @@ from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator from netbox.config import get_config -from utilities.querysets import RestrictedQuerySet from virtualization.models import VirtualMachine @@ -57,8 +56,6 @@ class RIR(OrganizationalModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] verbose_name = 'RIR' @@ -100,8 +97,6 @@ class ASN(PrimaryModel): null=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['asn'] verbose_name = 'ASN' @@ -143,8 +138,6 @@ class Aggregate(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'rir', 'tenant', 'date_added', 'description', ] @@ -235,8 +228,6 @@ class Role(OrganizationalModel): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['weight', 'name'] @@ -592,8 +583,6 @@ class IPRange(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'vrf', 'tenant', 'status', 'role', 'description', ] diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 9efe7fed7..5c1ebb9dd 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -8,7 +8,6 @@ from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet from utilities.utils import array_to_string @@ -65,8 +64,6 @@ class Service(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 14eaa7ccc..1c1691a62 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -11,7 +11,6 @@ from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet from netbox.models import OrganizationalModel, PrimaryModel -from utilities.querysets import RestrictedQuerySet from virtualization.models import VMInterface @@ -52,8 +51,6 @@ class VLANGroup(OrganizationalModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('name', 'pk') # Name may be non-unique unique_together = [ diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index c8e703520..11fab9c44 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -4,7 +4,6 @@ from django.urls import reverse from extras.utils import extras_features from ipam.constants import * from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -58,8 +57,6 @@ class VRF(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'tenant', 'enforce_unique', 'description', ] @@ -100,8 +97,6 @@ class RouteTarget(PrimaryModel): null=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 9f3e00b66..091bae7bd 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -11,6 +11,7 @@ from taggit.managers import TaggableManager from extras.choices import ObjectChangeActionChoices from netbox.signals import post_clean from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object __all__ = ( @@ -169,6 +170,8 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Base model for all objects which support change logging. """ + objects = RestrictedQuerySet.as_manager() + class Meta: abstract = True @@ -183,6 +186,8 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, content_type_field='assigned_object_type' ) + objects = RestrictedQuerySet.as_manager() + class Meta: abstract = True @@ -251,6 +256,8 @@ class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidatio blank=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: abstract = True ordering = ('name',) diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index efc50c978..42a7ffe7d 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -7,7 +7,6 @@ from mptt.models import TreeForeignKey from extras.utils import extras_features from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from tenancy.choices import * -from utilities.querysets import RestrictedQuerySet __all__ = ( 'ContactAssignment', @@ -69,8 +68,6 @@ class ContactRole(OrganizationalModel): blank=True, ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -115,8 +112,6 @@ class Contact(PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'group', ] @@ -161,8 +156,6 @@ class ContactAssignment(ChangeLoggedModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = ('content_type', 'object_id') class Meta: diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index 7dae2c093..d480f9112 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -5,7 +5,6 @@ from mptt.models import TreeForeignKey from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel -from utilities.querysets import RestrictedQuerySet __all__ = ( 'Tenant', @@ -80,8 +79,6 @@ class Tenant(PrimaryModel): to='tenancy.ContactAssignment' ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'group', 'description', ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 08df36d4d..5a1bcd42f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -13,7 +13,6 @@ from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar -from utilities.querysets import RestrictedQuerySet from .choices import * @@ -48,8 +47,6 @@ class ClusterType(OrganizationalModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -93,8 +90,6 @@ class ClusterGroup(OrganizationalModel): to='tenancy.ContactAssignment' ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ['name'] @@ -158,8 +153,6 @@ class Cluster(PrimaryModel): to='tenancy.ContactAssignment' ) - objects = RestrictedQuerySet.as_manager() - clone_fields = [ 'type', 'group', 'tenant', 'site', ] @@ -405,8 +398,6 @@ class VMInterface(PrimaryModel, BaseInterface): related_query_name='+' ) - objects = RestrictedQuerySet.as_manager() - class Meta: verbose_name = 'interface' ordering = ('virtual_machine', CollateAsChar('_name')) diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 45a7881b7..4d6d26a92 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -7,7 +7,6 @@ from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel -from utilities.querysets import RestrictedQuerySet from .choices import * from .constants import * @@ -109,8 +108,6 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): blank=True ) - objects = RestrictedQuerySet.as_manager() - class Meta: ordering = ('ssid', 'pk') verbose_name = 'Wireless LAN' @@ -171,8 +168,6 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): null=True ) - objects = RestrictedQuerySet.as_manager() - clone_fields = ('ssid', 'status') class Meta: From a7990942276f125694e7fa33f6d5c58925574aee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 11 Nov 2021 15:38:34 -0500 Subject: [PATCH 209/289] Fixes #7788: Improve XSS mitigation in Markdown renderer --- docs/release-notes/version-3.0.md | 1 + netbox/utilities/templatetags/helpers.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 366f0bca0..5ba2ac35f 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -17,6 +17,7 @@ * [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table * [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve multi-line values during CSV file import * [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view +* [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer * [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status * [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 5b5534321..b047bb698 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -40,14 +40,19 @@ def render_markdown(value): """ Render text as Markdown """ + schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) + # Strip HTML tags value = strip_tags(value) # Sanitize Markdown links - schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) - pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)' + pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) + # Sanitize Markdown reference links + pattern = fr'\[(.+)\]:\w?(?!({schemes})).*:(.+)' + value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) + # Render Markdown html = markdown(value, extensions=['fenced_code', 'tables']) From 0b705553a565edbd03c808fc8376f109b2c653fb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 11 Nov 2021 16:16:54 -0500 Subject: [PATCH 210/289] Fixes #7809: Add missing export template support for various models --- docs/release-notes/version-3.0.md | 1 + netbox/extras/forms/models.py | 2 +- netbox/extras/models/customfields.py | 2 +- netbox/extras/models/models.py | 8 ++++---- netbox/extras/models/tags.py | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 5ba2ac35f..27c8fffad 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -20,6 +20,7 @@ * [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer * [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status * [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table +* [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models --- diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 7e462e62b..61c341334 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -70,7 +70,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, forms.ModelForm): content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') + limit_choices_to=FeatureQuery('export_templates') ) class Meta: diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c74bb0cde..245079863 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -31,7 +31,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class CustomField(ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 2c56f2f0f..1b20cc79c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -9,7 +9,7 @@ from django.db import models from django.http import HttpResponse from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format, time_format +from django.utils.formats import date_format from rest_framework.utils.encoders import JSONEncoder from extras.choices import * @@ -36,7 +36,7 @@ __all__ = ( # Webhooks # -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class Webhook(ChangeLoggedModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or @@ -175,7 +175,7 @@ class Webhook(ChangeLoggedModel): # Custom links # -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class CustomLink(ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template @@ -234,7 +234,7 @@ class CustomLink(ChangeLoggedModel): # Export templates # -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class ExportTemplate(ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index afeeee53d..da2016875 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -14,7 +14,7 @@ from utilities.querysets import RestrictedQuerySet # Tags # -@extras_features('webhooks') +@extras_features('webhooks', 'export_templates') class Tag(ChangeLoggedModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY From 9f8068e8d12da7ac9399e622fa2c4ec5d74fe452 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 11 Nov 2021 16:21:27 -0500 Subject: [PATCH 211/289] Fixes #7808: Fix reference values for content type under custom field import form --- docs/release-notes/version-3.0.md | 1 + netbox/utilities/forms/fields.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 27c8fffad..03a4d3a59 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -20,6 +20,7 @@ * [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer * [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status * [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table +* [#7808](https://github.com/netbox-community/netbox/issues/7808) - Fix reference values for content type under custom field import form * [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models --- diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index d9f1719ec..bca293b0b 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -304,7 +304,7 @@ class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField): app_label, model = name.split('.') ct_filter |= Q(app_label=app_label, model=model) return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True)) - return super().prepare_value(value) + return f'{value.app_label}.{value.model}' # From daf6c8e327ff3128449a3add684a76bd7fef4268 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 08:23:58 -0500 Subject: [PATCH 212/289] Fixes #7814: Fix restriction of user & group objects in GraphQL API queries --- docs/release-notes/version-3.0.md | 1 + netbox/users/graphql/types.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 03a4d3a59..324cda952 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -22,6 +22,7 @@ * [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table * [#7808](https://github.com/netbox-community/netbox/issues/7808) - Fix reference values for content type under custom field import form * [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models +* [#7814](https://github.com/netbox-community/netbox/issues/7814) - Fix restriction of user & group objects in GraphQL API queries --- diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 3315744b9..d948686c6 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -19,7 +19,7 @@ class GroupType(DjangoObjectType): @classmethod def get_queryset(cls, queryset, info): - return RestrictedQuerySet(model=Group) + return RestrictedQuerySet(model=Group).restrict(info.context.user, 'view') class UserType(DjangoObjectType): @@ -34,4 +34,4 @@ class UserType(DjangoObjectType): @classmethod def get_queryset(cls, queryset, info): - return RestrictedQuerySet(model=User) + return RestrictedQuerySet(model=User).restrict(info.context.user, 'view') From 49e77841e06f47f29294c9b69dca97c87285a2d7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 08:36:33 -0500 Subject: [PATCH 213/289] Release v3.0.10 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.0.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index fa773eb13..79fb0e334 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.9 + placeholder: v3.0.10 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index a6fc342be..76944eecb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.9 + placeholder: v3.0.10 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 324cda952..4c263e78f 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,6 +1,6 @@ # NetBox v3.0 -## v3.0.10 (FUTURE) +## v3.0.10 (2021-11-12) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b0c996141..83655d0c5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -17,7 +17,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.10-dev' +VERSION = '3.0.10' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index c537a39c3..84ad0c398 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,13 +15,13 @@ djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 graphene_django==2.15.0 gunicorn==20.1.0 -Jinja2==3.0.2 +Jinja2==3.0.3 Markdown==3.3.4 markdown-include==0.6.0 mkdocs-material==7.3.6 netaddr==0.8.0 Pillow==8.4.0 -psycopg2-binary==2.9.1 +psycopg2-binary==2.9.2 PyYAML==6.0 svgwrite==1.4.1 tablib==3.1.0 From c0ca1eaf9044ace9d4b7933d6ff75a9d53e55bb1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 08:54:08 -0500 Subject: [PATCH 214/289] PRVB --- docs/release-notes/version-3.0.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 4c263e78f..4e7ead79c 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,5 +1,9 @@ # NetBox v3.0 +## v3.0.11 (FUTURE) + +--- + ## v3.0.10 (2021-11-12) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 83655d0c5..b7886b23c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -17,7 +17,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.10' +VERSION = '3.0.11-dev' # Hostname HOSTNAME = platform.node() From 500f213c6b8b85d6a994fa5f396712edf9c458f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 09:29:55 -0500 Subject: [PATCH 215/289] Fix erroneous merge resolution --- netbox/utilities/templatetags/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index cc60b0ec0..9499845b6 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -15,6 +15,7 @@ from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown +from netbox.config import get_config from utilities.forms import get_selected_values, TableConfigForm from utilities.utils import foreground_color @@ -41,7 +42,7 @@ def render_markdown(value): """ Render text as Markdown """ - schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) + schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) # Strip HTML tags value = strip_tags(value) From 8aa73c59009a1936f40123dd46696d3776a6048c Mon Sep 17 00:00:00 2001 From: Flo <52775027+FloEisen@users.noreply.github.com> Date: Fri, 12 Nov 2021 16:05:42 +0100 Subject: [PATCH 216/289] Add IEEE 802.15.1 Interface Type --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 5a732fa8d..d3cdbdf8f 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -739,6 +739,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_80211AC = 'ieee802.11ac' TYPE_80211AD = 'ieee802.11ad' TYPE_80211AX = 'ieee802.11ax' + TYPE_802151 = 'ieee802.15.1' # Cellular TYPE_GSM = 'gsm' @@ -850,6 +851,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_80211AC, 'IEEE 802.11ac'), (TYPE_80211AD, 'IEEE 802.11ad'), (TYPE_80211AX, 'IEEE 802.11ax'), + (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), ) ), ( From 17e01644f5ff6d55a18f5f308058cd3258b7f6c0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 13:32:52 -0500 Subject: [PATCH 217/289] Fixes #7813: Fix handling of errors during export template rendering --- docs/release-notes/version-3.0.md | 4 ++ netbox/netbox/views/generic.py | 66 ++++++++++++++++++------------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 4e7ead79c..e6217f967 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,10 @@ ## v3.0.11 (FUTURE) +### Bug Fixes + +* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering + --- ## v3.0.10 (2021-11-12) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 44e83f5ec..1c2ff9917 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') + def get_table(self, request, permissions): + table = self.table(self.queryset, user=request.user) + if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): + table.columns.show('pk') + + return table + def export_yaml(self): """ Export the queryset of objects as concatenated YAML documents. @@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' ) - def get(self, request): + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + :param template: ExportTemplate instance + :param request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + def get(self, request): model = self.queryset.model content_type = ContentType.objects.get_for_model(model) @@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): perm_name = get_permission_for_model(model, action) permissions[action] = request.user.has_perm(perm_name) - # Export template/YAML rendering - if 'export' in request.GET and request.GET['export'] != 'table': + if 'export' in request.GET: - # An export template has been specified - if request.GET['export']: - et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) - try: - return et.render_to_response(self.queryset) - except Exception as e: - messages.error( - request, - "There was an error rendering the selected export template ({}): {}".format( - et.name, e - ) - ) + # Export the current table view + if request.GET['export'] == 'table': + table = self.get_table(request, permissions) + columns = [name for name, _ in table.selected_columns] + return self.export_table(table, columns) - # Check for YAML export support + # Render an ExportTemplate + elif request.GET['export']: + template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + return self.export_template(template, request) + + # Check for YAML export support on the model elif hasattr(model, 'to_yaml'): response = HttpResponse(self.export_yaml(), content_type='text/yaml') filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) return response - # Construct the objects table - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') + # Fall back to default table/YAML export + else: + table = self.get_table(request, permissions) + return self.export_table(table) - # Handle table-based exports (current view or static CSV-based) - if request.GET.get('export') == 'table': - columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) - elif 'export' in request.GET: - return self.export_table(table) - - # Paginate the objects table + # Render the objects table + table = self.get_table(request, permissions) paginate_table(table, request) context = { From bb99c3e6f9247d85f9f81e43e9d5505c6ddee1ee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 13:46:06 -0500 Subject: [PATCH 218/289] Changelog for #7803, #7810 --- docs/release-notes/version-3.0.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index e6217f967..de2ae3f74 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,11 @@ ## v3.0.11 (FUTURE) +### Enhancements + +* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts +* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type + ### Bug Fixes * [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering From 1fed564c477e220e2b5acc19e30becf850e74e03 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 14:44:14 -0500 Subject: [PATCH 219/289] Clean up script & report lists --- netbox/templates/extras/report_list.html | 190 +++++++++++------------ netbox/templates/extras/script_list.html | 130 ++++++++-------- 2 files changed, 149 insertions(+), 171 deletions(-) diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 498e56b8d..99d6da730 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -3,108 +3,94 @@ {% block title %}Reports{% endblock %} -{% block content %} -
-
- {% if reports %} - {% for module, module_reports in reports %} -
-
{{ module|bettertitle }}
-
- - - - - - - - - - - - {% for report in module_reports %} - - - - - - - - {% for method, stats in report.result.data.items %} - - - - - {% endfor %} - {% endfor %} - -
NameStatusDescriptionLast Run
- {{ report.name }} - - {% include 'extras/inc/job_label.html' with result=report.result %} - {{ report.description|render_markdown|placeholder }} - {% if report.result %} - {{ report.result.created|annotated_date }} - {% else %} - Never - {% endif %} - - {% if perms.extras.run_report %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
+{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
+ {% if reports %} + {% for module, module_reports in reports %} +
+
+ + {{ module|bettertitle }} +
+
+ + + + + + + + + + + + {% for report in module_reports %} + + + + + + + + {% for method, stats in report.result.data.items %} + + + + + {% endfor %} {% endfor %} - {% else %} - - {% endif %} + +
NameStatusDescriptionLast Run
+ {{ report.name }} + + {% include 'extras/inc/job_label.html' with result=report.result %} + {{ report.description|render_markdown|placeholder }} + {% if report.result %} + {{ report.result.created|annotated_date }} + {% else %} + Never + {% endif %} + + {% if perms.extras.run_report %} +
+
+ {% csrf_token %} + +
- + {% endif %} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
+
-
- {% if reports %} -
-
- {% for module, module_reports in reports %} -
{{ module|bettertitle }}
- - {% endfor %} -
-
- {% endif %} -
-
-{% endblock %} + {% endfor %} + {% else %} + + {% endif %} +
+{% endblock content-wrapper %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 1cc35d36c..ccbdca705 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -3,74 +3,66 @@ {% block title %}Scripts{% endblock %} -{% block content %} -
-
- {% if scripts %} - {% for module, module_scripts in scripts.items %} -

{{ module|bettertitle }}

- - - - - - - - - - - {% for class_name, script in module_scripts.items %} - - - - - {% if script.result %} - - {% else %} - - {% endif %} - - {% endfor %} - -
NameStatusDescriptionLast Run
- {{ script }} - - {% include 'extras/inc/job_label.html' with result=script.result %} - {{ script.Meta.description|render_markdown }} - {{ script.result.created|annotated_date }} - Never
+{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
+ {% if scripts %} + {% for module, module_scripts in scripts.items %} +
+
+ + {{ module|bettertitle }} +
+
+ + + + + + + + + + + {% for class_name, script in module_scripts.items %} + + + + + {% if script.result %} + + {% else %} + + {% endif %} + {% endfor %} - {% else %} -
-

No Scripts Found

- Scripts should be saved to {{ settings.SCRIPTS_ROOT }}. -
- This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration. -
- {% endif %} + +
NameStatusDescriptionLast Run
+ {{ script }} + + {% include 'extras/inc/job_label.html' with result=script.result %} + + {{ script.Meta.description|render_markdown|placeholder }} + + {{ script.result.created|annotated_date }} + Never
+
-
- {% if scripts %} -
-
- {% for module, module_scripts in scripts.items %} -
{{ module|bettertitle }}
-
- -
- {% endfor %} -
-
- {% endif %} -
-
-{% endblock %} + {% endfor %} + {% else %} +
+

No Scripts Found

+ Scripts should be saved to {{ settings.SCRIPTS_ROOT }}. +
+ This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration. +
+ {% endif %} +
+{% endblock content-wrapper %} From f49d7008a0d7074e0211a753eeed81fd81b1a949 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 15:05:33 -0500 Subject: [PATCH 220/289] Add q filters for connection lists --- netbox/dcim/filtersets.py | 13 +++++++++++++ netbox/dcim/forms/filtersets.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index df7f415e2..ba7ede783 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1394,6 +1394,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE # class ConnectionFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) site_id = MultiValueNumberFilter( method='filter_connections', field_name='device__site_id' @@ -1416,6 +1420,15 @@ class ConnectionFilterSet(BaseFilterSet): return queryset return queryset.filter(**{f'{name}__in': value}) + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(device__name__icontains=value) | + Q(cable__label__icontains=value) + ) + return queryset.filter(qs_filter) + class ConsoleConnectionFilterSet(ConnectionFilterSet): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4ef53c469..93299a17e 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1068,6 +1068,11 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1095,6 +1100,11 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1122,6 +1132,11 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, From 50f283cf28ae607731b314646707991d11014ae9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 15:17:44 -0500 Subject: [PATCH 221/289] Add q filter for extras models --- netbox/extras/filtersets.py | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index af8d904f4..1ed25cdac 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -35,6 +35,10 @@ EXACT_FILTER_TYPES = ( class WebhookFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) content_types = ContentTypeFilter() http_method = django_filters.MultipleChoiceFilter( choices=WebhookHttpMethodChoices @@ -47,30 +51,81 @@ class WebhookFilterSet(BaseFilterSet): 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', ] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(payload_url__icontains=value) + ) + class CustomFieldFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) content_types = ContentTypeFilter() class Meta: model = CustomField fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(label__icontains=value) | + Q(description__icontains=value) + ) + class CustomLinkFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = CustomLink fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(link_text__icontains=value) | + Q(link_url__icontains=value) | + Q(group_name__icontains=value) + ) + class ExportTemplateFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = ExportTemplate fields = ['id', 'content_type', 'name'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + class ImageAttachmentFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) created = django_filters.DateTimeFilter() content_type = ContentTypeFilter() @@ -78,6 +133,11 @@ class ImageAttachmentFilterSet(BaseFilterSet): model = ImageAttachment fields = ['id', 'content_type_id', 'object_id', 'name'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(name__icontains=value) + class JournalEntryFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( From 467fa5a84757bb011515356ca498f7c9037dff55 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 15:30:16 -0500 Subject: [PATCH 222/289] Add q filters for Token and ObjectPermission filter sets --- netbox/users/filtersets.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 80643c1e5..ad296ea25 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -99,8 +99,20 @@ class TokenFilterSet(BaseFilterSet): model = Token fields = ['id', 'key', 'write_enabled'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(user__username__icontains=value) | + Q(description__icontains=value) + ) + class ObjectPermissionFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', queryset=User.objects.all(), @@ -127,3 +139,11 @@ class ObjectPermissionFilterSet(BaseFilterSet): class Meta: model = ObjectPermission fields = ['id', 'name', 'enabled', 'object_types'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) From f3beabba69c369fb4f53ac832150ce87c83e25c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 12 Nov 2021 15:33:49 -0500 Subject: [PATCH 223/289] Changelog for #2101 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index de2ae3f74..bb9a18c75 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -4,6 +4,7 @@ ### Enhancements +* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models * [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts * [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type From fa8a8abc984faa62073fd06996c2dce436751bd2 Mon Sep 17 00:00:00 2001 From: Rhys Barrie Date: Sat, 13 Nov 2021 21:30:38 -0500 Subject: [PATCH 224/289] netbox-community/netbox#7424: Add virtual_chassis and virtual_chassis_id filter to device components --- netbox/dcim/filtersets.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index ba7ede783..f7cf011ce 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -861,6 +861,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__virtual_chassis', + queryset=VirtualChassis.objects.all(), + label='Virtual Chassis (ID)' + ) + virtual_chassis = django_filters.ModelMultipleChoiceFilter( + field_name='device__virtual_chassis__name', + queryset=VirtualChassis.objects.all(), + to_field_name='name', + label='Virtual Chassis', + ) tag = TagFilter() def search(self, queryset, name, value): From 6b21c8453fc68cb6bb999f69fd52342759aed22c Mon Sep 17 00:00:00 2001 From: Rhys Barrie Date: Sat, 13 Nov 2021 21:33:52 -0500 Subject: [PATCH 225/289] netbox-community/netbox#7424: Add virtual_chassis field to device component filter form --- netbox/dcim/api/nested_serializers.py | 2 +- netbox/dcim/forms/filtersets.py | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 67ae9b046..1fdde78d7 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): class Meta: model = models.VirtualChassis - fields = ['id', 'name', 'url', 'master', 'member_count'] + fields = ['id', 'url', 'display', 'name', 'master', 'member_count'] # diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 93299a17e..6d76c4003 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -92,6 +92,12 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Location'), fetch_trigger='open' ) + virtual_chassis_id = DynamicModelMultipleChoiceField( + queryset=VirtualChassis.objects.all(), + required=False, + label=_('Virtual Chassis'), + fetch_trigger='open' + ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, @@ -888,7 +894,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, @@ -908,7 +914,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, @@ -928,7 +934,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=PowerPortTypeChoices, @@ -943,7 +949,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=PowerOutletTypeChoices, @@ -958,7 +964,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] kind = forms.MultipleChoiceField( choices=InterfaceKindChoices, @@ -993,7 +999,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] model = FrontPort type = forms.MultipleChoiceField( @@ -1012,7 +1018,7 @@ class RearPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=PortTypeChoices, @@ -1030,7 +1036,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] tag = TagFilterField(model) @@ -1040,7 +1046,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), From f77f7ca0ec7887ad1f075654f87658908d83346b Mon Sep 17 00:00:00 2001 From: Rhys Barrie Date: Sat, 13 Nov 2021 21:35:13 -0500 Subject: [PATCH 226/289] netbox-community/netbox#7424:make device component device field filter from selected virtual chassis --- netbox/dcim/forms/filtersets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 6d76c4003..70a20d8a5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -104,6 +104,7 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): query_params={ 'site_id': '$site_id', 'location_id': '$location_id', + 'virtual_chassis_id': '$virtual_chassis_id' }, label=_('Device'), fetch_trigger='open' From a8c958ece2038be0ac849312663785f4e4791d51 Mon Sep 17 00:00:00 2001 From: Rhys Barrie Date: Sat, 13 Nov 2021 22:01:15 -0500 Subject: [PATCH 227/289] netbox-community/netbox#7424: fix test failure from adding virtual chassis filter field --- netbox/dcim/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index e5977b760..3fc48beed 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1524,7 +1524,7 @@ class ConnectedDeviceTest(APITestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase): model = VirtualChassis - brief_fields = ['id', 'master', 'member_count', 'name', 'url'] + brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url'] @classmethod def setUpTestData(cls): From 68b544c676fba9542d09cd7b5c71e0315c885b80 Mon Sep 17 00:00:00 2001 From: Rhys Barrie Date: Sat, 13 Nov 2021 22:16:18 -0500 Subject: [PATCH 228/289] netbox-community/netbox#7424: add filterset test for virtual_chassis_id --- netbox/dcim/tests/test_filtersets.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fb94bde08..2b5da8576 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2048,6 +2048,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + # VirtualChassis assignment for filtering + virtual_chassis = VirtualChassis.objects.create(master=devices[0]) + Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) + Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) + interfaces = ( Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), @@ -2157,6 +2162,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'location': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_virtual_chassis_id(self): + params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): devices = Device.objects.all()[:2] params = {'device_id': [devices[0].pk, devices[1].pk]} From a5a480133ff0c2c9764f2b72d10d0c50434fac08 Mon Sep 17 00:00:00 2001 From: Rhys Barrie Date: Sat, 13 Nov 2021 23:08:46 -0500 Subject: [PATCH 229/289] netbox-community/netbox#7229: Fix context of VLAN table in VLAN Group view --- netbox/templates/ipam/vlangroup.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index a46bef3b0..daa2c8e8c 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block breadcrumbs %} {{ block.super }} @@ -68,7 +69,7 @@ VLANs
- {% include 'inc/table.html' with table=vlans_table %} + {% render_table vlans_table 'inc/table.html' %}
{% if perms.ipam.add_vlan %} """ +INTERFACE_FHRPGROUPS = """ +
+ {% for assignment in value.all %} + {{ assignment.group.group_id }} ({{ assignment.group.get_protocol_display }}) + {% endfor %} +
+""" + INTERFACE_TAGGED_VLANS = """ {% if record.mode == 'tagged' %} {% for vlan in record.tagged_vlans.all %} diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 28a57c7ef..501b5c0c7 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -171,7 +171,7 @@ class VMInterfaceTable(BaseInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') @@ -193,7 +193,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', From b6fe613329a2a356e0ec3e34a3327244764c1980 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 19 Nov 2021 10:42:13 -0500 Subject: [PATCH 258/289] Fix redirection after creating FHRP group assignment --- netbox/ipam/models/fhrp.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 0176b2f71..fe8c80b6b 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -93,3 +93,9 @@ class FHRPGroupAssignment(ChangeLoggedModel): def __str__(self): return f'{self.interface}: {self.group} ({self.priority})' + + def get_absolute_url(self): + # Used primarily for redirection after creating a new assignment + if self.interface: + return self.interface.get_absolute_url() + return None From 5f59f458f449cd625964128349a028367231e9c3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 19 Nov 2021 11:34:59 -0500 Subject: [PATCH 259/289] Fixes #7880: Include assigned IP addresses in FHRP group object representation --- docs/release-notes/version-3.1.md | 1 + netbox/dcim/tables/template_code.py | 2 +- netbox/ipam/api/views.py | 1 + netbox/ipam/models/fhrp.py | 12 +++++++++++- netbox/templates/ipam/fhrpgroup.html | 3 +++ 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index dba962b9f..61618b0f1 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -24,6 +24,7 @@ * [#7768](https://github.com/netbox-community/netbox/issues/7768) - Validate IP address status when creating a new FHRP group * [#7771](https://github.com/netbox-community/netbox/issues/7771) - Group assignment should be optional when creating contacts via REST API * [#7849](https://github.com/netbox-community/netbox/issues/7849) - Fix exception when creating an FHRPGroup with an invalid IP address +* [#7880](https://github.com/netbox-community/netbox/issues/7880) - Include assigned IP addresses in FHRP group object representation ### REST API Changes diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 12b0fe4a2..ccca32be8 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -53,7 +53,7 @@ INTERFACE_IPADDRESSES = """ INTERFACE_FHRPGROUPS = """ """ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 5e40a2081..cdb40333d 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -138,6 +138,7 @@ class FHRPGroupViewSet(CustomFieldModelViewSet): queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') serializer_class = serializers.FHRPGroupSerializer filterset_class = filtersets.FHRPGroupFilterSet + brief_prefetch_fields = ('ip_addresses',) class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index fe8c80b6b..0a099499f 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -56,7 +56,17 @@ class FHRPGroup(PrimaryModel): verbose_name = 'FHRP group' def __str__(self): - return f'{self.get_protocol_display()} group {self.group_id}' + name = f'{self.get_protocol_display()}: {self.group_id}' + + # Append the list of assigned IP addresses to serve as an additional identifier + if self.pk: + ip_addresses = [ + str(ip.address) for ip in self.ip_addresses.all() + ] + if ip_addresses: + return f"{name} ({', '.join(ip_addresses)})" + + return name def get_absolute_url(self): return reverse('ipam:fhrpgroup', args=[self.pk]) diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index 0ee94ab90..32ed1fc1c 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -3,6 +3,9 @@ {% load plugins %} {% load render_table from django_tables2 %} +{# Omit assigned IP addresses from object representation #} +{% block title %}{{ object.get_protocol_display }}: {{ object.group_id }}{% endblock %} + {% block breadcrumbs %} {{ block.super }} From 7072f207c001dfb10383151919a993540e7bb1ce Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 19 Nov 2021 11:42:34 -0500 Subject: [PATCH 260/289] Call out all models with cable_peer name changes --- docs/release-notes/version-3.1.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 61618b0f1..cbcf5478b 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -160,12 +160,24 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * tenancy.TenantGroup * virtualization.ClusterGroup * virtualization.ClusterType +* circuits.CircuitTermination + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Cable * Added `tenant` field +* dcim.ConsolePort + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` +* dcim.ConsoleServerPort + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Device * Added `airflow` field * dcim.DeviceType * Added `airflow` field +* dcim.FrontPort + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Interface * `cable_peer` has been renamed to `link_peer` * `cable_peer_type` has been renamed to `link_peer_type` @@ -180,6 +192,18 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * Added `count_fhrp_groups` read-only field * dcim.Location * Added `tenant` field +* dcim.PowerFeed + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` +* dcim.PowerOutlet + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` +* dcim.PowerPort + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` +* dcim.RearPort + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Site * Added `asns` relationship to ipam.ASN * extras.Webhook From b7c9ca720a0d84cc506921fb83c2d618fbc0ae71 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 19 Nov 2021 15:12:45 -0500 Subject: [PATCH 261/289] Closes #7886: Introduce a base FilterForm class --- netbox/circuits/forms/filtersets.py | 20 ---- netbox/dcim/forms/filtersets.py | 108 +--------------------- netbox/extras/forms/customfields.py | 4 +- netbox/extras/forms/filtersets.py | 60 ++---------- netbox/ipam/forms/filtersets.py | 65 ------------- netbox/tenancy/forms/filtersets.py | 25 ----- netbox/utilities/forms/forms.py | 15 +++ netbox/virtualization/forms/filtersets.py | 30 +----- netbox/wireless/forms/filtersets.py | 15 --- 9 files changed, 33 insertions(+), 309 deletions(-) diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 5f7d5b612..0822ff206 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -23,11 +23,6 @@ class ProviderFilterForm(CustomFieldModelFilterForm): ['region_id', 'site_group_id', 'site_id'], ['asn'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -63,11 +58,6 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm): ('q', 'tag'), ('provider_id',), ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), required=False, @@ -79,11 +69,6 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm): class CircuitTypeFilterForm(CustomFieldModelFilterForm): model = CircuitType - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) @@ -96,11 +81,6 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['region_id', 'site_group_id', 'site_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), required=False, diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 72b7418c1..21e8c9c97 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -9,7 +9,7 @@ from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterFor from ipam.models import ASN from tenancy.forms import TenancyFilterForm from utilities.forms import ( - APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, + APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from wireless.choices import * @@ -51,11 +51,6 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): field_order = [ 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id', ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) name = forms.CharField( required=False ) @@ -114,11 +109,6 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): class RegionFilterForm(CustomFieldModelFilterForm): model = Region - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -130,11 +120,6 @@ class RegionFilterForm(CustomFieldModelFilterForm): class SiteGroupFilterForm(CustomFieldModelFilterForm): model = SiteGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -153,11 +138,6 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['tenant_group_id', 'tenant_id'], ['asn_id'] ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) status = forms.MultipleChoiceField( choices=SiteStatusChoices, required=False, @@ -191,11 +171,6 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['region_id', 'site_group_id', 'site_id', 'parent_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -233,11 +208,6 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RackRoleFilterForm(CustomFieldModelFilterForm): model = RackRole - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) @@ -251,11 +221,6 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['type', 'width', 'serial', 'asset_tag'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -338,11 +303,6 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['region_id', 'site_id', 'location_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -379,11 +339,6 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class ManufacturerFilterForm(CustomFieldModelFilterForm): model = Manufacturer - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) @@ -394,11 +349,6 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): ['manufacturer_id', 'subdevice_role', 'airflow'], ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -462,21 +412,11 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): class DeviceRoleFilterForm(CustomFieldModelFilterForm): model = DeviceRole - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) class PlatformFilterForm(CustomFieldModelFilterForm): model = Platform - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -503,11 +443,6 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi 'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data', ], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -666,11 +601,6 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['region_id', 'site_group_id', 'site_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -704,11 +634,6 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['type', 'status', 'color'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -767,11 +692,6 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm): ('q', 'tag'), ('region_id', 'site_group_id', 'site_id', 'location_id') ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -815,11 +735,6 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): ['power_panel_id', 'rack_id'], ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1112,12 +1027,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): # Connections # -class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) +class ConsoleConnectionFilterForm(FilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1144,12 +1054,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): ) -class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) +class PowerConnectionFilterForm(FilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1176,12 +1081,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): ) -class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) +class InterfaceConnectionFilterForm(FilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index e5c200b6a..d58e6ce65 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -4,7 +4,7 @@ from django.db.models import Q from extras.choices import * from extras.models import * -from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm +from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm __all__ = ( 'CustomFieldModelCSVForm', @@ -105,7 +105,7 @@ class CustomFieldModelBulkEditForm(BulkEditForm): self.custom_fields.append(cf.name) -class CustomFieldModelFilterForm(BootstrapMixin, forms.Form): +class CustomFieldModelFilterForm(FilterForm): def __init__(self, *args, **kwargs): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 6196ba8da..07375a203 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -9,9 +9,8 @@ from extras.models import * from extras.utils import FeatureQuery from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, APISelectMultiple, BootstrapMixin, ContentTypeChoiceField, - ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, StaticSelect, - StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, APISelectMultiple, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DateTimePicker, + DynamicModelMultipleChoiceField, FilterForm, StaticSelect, StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup @@ -28,17 +27,12 @@ __all__ = ( ) -class CustomFieldFilterForm(BootstrapMixin, forms.Form): +class CustomFieldFilterForm(FilterForm): field_groups = [ ['q'], ['type', 'content_types'], ['weight', 'required'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -61,16 +55,11 @@ class CustomFieldFilterForm(BootstrapMixin, forms.Form): ) -class CustomLinkFilterForm(BootstrapMixin, forms.Form): +class CustomLinkFilterForm(FilterForm): field_groups = [ ['q'], ['content_type', 'weight', 'new_window'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -87,16 +76,11 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form): ) -class ExportTemplateFilterForm(BootstrapMixin, forms.Form): +class ExportTemplateFilterForm(FilterForm): field_groups = [ ['q'], ['content_type', 'mime_type', 'file_extension', 'as_attachment'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -117,17 +101,12 @@ class ExportTemplateFilterForm(BootstrapMixin, forms.Form): ) -class WebhookFilterForm(BootstrapMixin, forms.Form): +class WebhookFilterForm(FilterForm): field_groups = [ ['q'], ['content_types', 'http_method', 'enabled'], ['type_create', 'type_update', 'type_delete'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -165,12 +144,8 @@ class WebhookFilterForm(BootstrapMixin, forms.Form): ) -class TagFilterForm(BootstrapMixin, forms.Form): +class TagFilterForm(FilterForm): model = Tag - q = forms.CharField( - required=False, - label=_('Search') - ) content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), required=False, @@ -178,7 +153,7 @@ class TagFilterForm(BootstrapMixin, forms.Form): ) -class ConfigContextFilterForm(BootstrapMixin, forms.Form): +class ConfigContextFilterForm(FilterForm): field_groups = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id'], @@ -186,11 +161,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): ['cluster_group_id', 'cluster_id'], ['tenant_group_id', 'tenant_id'] ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -270,18 +240,13 @@ class LocalConfigContextFilterForm(forms.Form): ) -class JournalEntryFilterForm(BootstrapMixin, forms.Form): +class JournalEntryFilterForm(FilterForm): model = JournalEntry field_groups = [ ['q'], ['created_before', 'created_after', 'created_by_id'], ['assigned_object_type_id', 'kind'] ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) created_after = forms.DateTimeField( required=False, label=_('After'), @@ -317,18 +282,13 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form): ) -class ObjectChangeFilterForm(BootstrapMixin, forms.Form): +class ObjectChangeFilterForm(FilterForm): model = ObjectChange field_groups = [ ['q'], ['time_before', 'time_after', 'action'], ['user_id', 'changed_object_type_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) time_after = forms.DateTimeField( required=False, label=_('After'), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 159170ab4..75953001b 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -45,11 +45,6 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['import_target_id', 'export_target_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), required=False, @@ -72,11 +67,6 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['importing_vrf_id', 'exporting_vrf_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) importing_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, @@ -94,11 +84,6 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RIRFilterForm(CustomFieldModelFilterForm): model = RIR - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) is_private = forms.NullBooleanField( required=False, label=_('Private'), @@ -116,11 +101,6 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['family', 'rir_id'], ['tenant_group_id', 'tenant_id'] ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), @@ -144,11 +124,6 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['tenant_group_id', 'tenant_id'], ['site_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), required=False, @@ -165,11 +140,6 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RoleFilterForm(CustomFieldModelFilterForm): model = Role - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) @@ -182,11 +152,6 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['region_id', 'site_group_id', 'site_id'], ['tenant_group_id', 'tenant_id'] ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) mask_length__lte = forms.IntegerField( widget=forms.HiddenInput() ) @@ -282,11 +247,6 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['family', 'vrf_id', 'status', 'role_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), @@ -327,11 +287,6 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['vrf_id', 'present_in_vrf_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent = forms.CharField( required=False, widget=forms.TextInput( @@ -393,11 +348,6 @@ class FHRPGroupFilterForm(CustomFieldModelFilterForm): ('protocol', 'group_id'), ('auth_type', 'auth_key'), ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) protocol = forms.MultipleChoiceField( choices=FHRPGroupProtocolChoices, required=False, @@ -427,11 +377,6 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm): ['region', 'sitegroup', 'site', 'location', 'rack'] ] model = VLANGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -473,11 +418,6 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['group_id', 'status', 'role_id', 'vid'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -535,11 +475,6 @@ class ServiceFilterForm(CustomFieldModelFilterForm): ('q', 'tag'), ('protocol', 'port'), ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) protocol = forms.ChoiceField( choices=add_blank_choice(ServiceProtocolChoices), required=False, diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 1e4f2d06e..957f0ab7b 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -20,11 +20,6 @@ __all__ = ( class TenantGroupFilterForm(CustomFieldModelFilterForm): model = TenantGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -40,11 +35,6 @@ class TenantFilterForm(CustomFieldModelFilterForm): ('q', 'tag'), ('group_id',), ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -61,11 +51,6 @@ class TenantFilterForm(CustomFieldModelFilterForm): class ContactGroupFilterForm(CustomFieldModelFilterForm): model = ContactGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, @@ -77,11 +62,6 @@ class ContactGroupFilterForm(CustomFieldModelFilterForm): class ContactRoleFilterForm(CustomFieldModelFilterForm): model = ContactRole - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) @@ -91,11 +71,6 @@ class ContactFilterForm(CustomFieldModelFilterForm): ('q', 'tag'), ('group_id',), ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) group_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 1cb5ae123..87fa4ae33 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -3,6 +3,7 @@ import re import yaml from django import forms +from django.utils.translation import gettext as _ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect @@ -13,6 +14,7 @@ __all__ = ( 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', + 'FilterForm', 'ImportForm', 'ReturnURLForm', 'TableConfigForm', @@ -177,6 +179,19 @@ class ImportForm(BootstrapMixin, forms.Form): }) +class FilterForm(BootstrapMixin, forms.Form): + """ + Base Form class for FilterSet forms. + """ + q = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={'placeholder': _('All fields')} + ), + label=_('Search') + ) + + class TableConfigForm(BootstrapMixin, forms.Form): """ Form for configuring user's table preferences. diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index cea980f5f..7132ba316 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -5,8 +5,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( - BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, + DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.choices import * from virtualization.models import * @@ -22,21 +21,11 @@ __all__ = ( class ClusterTypeFilterForm(CustomFieldModelFilterForm): model = ClusterType - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) class ClusterGroupFilterForm(CustomFieldModelFilterForm): model = ClusterGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) tag = TagFilterField(model) @@ -51,11 +40,6 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ['region_id', 'site_group_id', 'site_id'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), required=False, @@ -104,11 +88,6 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'], ['tenant_group_id', 'tenant_id'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -188,18 +167,13 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, tag = TagFilterField(model) -class VMInterfaceFilterForm(BootstrapMixin, forms.Form): +class VMInterfaceFilterForm(CustomFieldModelFilterForm): model = VMInterface field_groups = [ ['q', 'tag'], ['cluster_id', 'virtual_machine_id'], ['enabled', 'mac_address'], ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index a8787c78e..b94332bd7 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -16,11 +16,6 @@ __all__ = ( class WirelessLANGroupFilterForm(CustomFieldModelFilterForm): model = WirelessLANGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) parent_id = DynamicModelMultipleChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -36,11 +31,6 @@ class WirelessLANFilterForm(CustomFieldModelFilterForm): ('q', 'tag'), ('group_id',), ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) ssid = forms.CharField( required=False, label='SSID' @@ -70,11 +60,6 @@ class WirelessLANFilterForm(CustomFieldModelFilterForm): class WirelessLinkFilterForm(CustomFieldModelFilterForm): model = WirelessLink - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) ssid = forms.CharField( required=False, label='SSID' From eded00cbb3444a0e5de0bd08acb02c96875c7ed0 Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Sun, 21 Nov 2021 22:23:29 +0100 Subject: [PATCH 262/289] chore: Always use "CEE 7" (with the space) consistently --- netbox/dcim/choices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d3cdbdf8f..40c61e899 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -640,8 +640,8 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_CS8464C, 'CS8464C'), )), ('ITA/International', ( - (TYPE_ITA_E, 'ITA Type E (CEE7/5)'), - (TYPE_ITA_F, 'ITA Type F (CEE7/3)'), + (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'), + (TYPE_ITA_F, 'ITA Type F (CEE 7/3)'), (TYPE_ITA_G, 'ITA Type G (BS 1363)'), (TYPE_ITA_H, 'ITA Type H'), (TYPE_ITA_I, 'ITA Type I'), From 175498940e77291768137432c9ea12b0620b22f9 Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Sun, 21 Nov 2021 23:41:36 +0100 Subject: [PATCH 263/289] Fixes #7897: CEE 7/5 is only a power outlet, no power port Ref: * https://en.wikipedia.org/wiki/CEE_7_standard_AC_plugs_and_sockets#CEE_7/5_socket_and_CEE_7/6_plug_(French;_Type_E) * https://blog.packetsar.com/wp-content/uploads/Power_and_Cooling_Cheat_Sheet.pdf --- netbox/dcim/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 40c61e899..36eb24c96 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -428,7 +428,7 @@ class PowerPortTypeChoices(ChoiceSet): )), ('International/ITA', ( (TYPE_ITA_C, 'ITA Type C (CEE 7/16)'), - (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'), + (TYPE_ITA_E, 'ITA Type E (CEE 7/6)'), (TYPE_ITA_F, 'ITA Type F (CEE 7/4)'), (TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'), (TYPE_ITA_G, 'ITA Type G (BS 1363)'), From f90c591c785df5493e1fb8f1ee0e193bbe63e15a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 22 Nov 2021 13:36:51 -0500 Subject: [PATCH 264/289] Fixes #7890: Correct typo --- docs/release-notes/version-3.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 184b5debb..92a3dd39e 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -448,7 +448,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul * [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths * [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally -### Bug Fixes (from v3.2-beta2) +### Bug Fixes (from v3.0-beta2) * [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens * [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations From 1e42fecf6645bfdf4b42a00b94e02c136a2c6f9e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 24 Nov 2021 09:15:30 -0500 Subject: [PATCH 265/289] Changelog for #7657 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 92a3dd39e..4bf7e54ce 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -14,6 +14,7 @@ ### Bug Fixes * [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled +* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe * [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values * [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table * [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination From 416caa8f50763d3ee6a8340f9e9fedbd53b5b4d0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 24 Nov 2021 13:17:59 -0500 Subject: [PATCH 266/289] Hide code blocks when not needed --- netbox/templates/extras/webhook.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html index c92ec4c99..266fa9263 100644 --- a/netbox/templates/extras/webhook.html +++ b/netbox/templates/extras/webhook.html @@ -137,7 +137,11 @@ Additional Headers
-
{{ object.additional_headers }}
+ {% if object.additional_headers %} +
{{ object.additional_headers }}
+ {% else %} + None + {% endif %}
@@ -145,7 +149,11 @@ Body Template
-
{{ object.body_template }}
+ {% if object.body_template %} +
{{ object.body_template }}
+ {% else %} + None + {% endif %}
{% plugin_right_page object %} From 57ccbf44b8854311880499f99a9dc6b013b390be Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 24 Nov 2021 13:25:57 -0500 Subject: [PATCH 267/289] Release v3.0.11 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.0.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 79fb0e334..4a6dba734 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.10 + placeholder: v3.0.11 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 76944eecb..4c3ab0277 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.10 + placeholder: v3.0.11 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 4bf7e54ce..f76869f6e 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,6 +1,6 @@ # NetBox v3.0 -## v3.0.11 (FUTURE) +## v3.0.11 (2021-11-24) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b7886b23c..8538c1d36 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -17,7 +17,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.11-dev' +VERSION = '3.0.11' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 84ad0c398..0f089a87c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.1.0 django-redis==5.0.0 -django-rq==2.4.1 +django-rq==2.5.1 django-tables2==2.4.1 django-taggit==1.5.1 django-timezone-field==4.2.1 @@ -16,7 +16,7 @@ drf-yasg[validation]==1.20.0 graphene_django==2.15.0 gunicorn==20.1.0 Jinja2==3.0.3 -Markdown==3.3.4 +Markdown==3.3.6 markdown-include==0.6.0 mkdocs-material==7.3.6 netaddr==0.8.0 From 86ada33577786140899c28792c8c26fc68d599c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 24 Nov 2021 13:58:57 -0500 Subject: [PATCH 268/289] PRVB --- docs/release-notes/version-3.0.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index f76869f6e..f7fbe06ab 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,5 +1,9 @@ # NetBox v3.0 +## v3.0.12 (FUTURE) + +--- + ## v3.0.11 (2021-11-24) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8538c1d36..7c205cca4 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -17,7 +17,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.11' +VERSION = '3.0.12-dev' # Hostname HOSTNAME = platform.node() From 8bb0cba949ca6fd4fe3d48b1cec0153ab1ae4817 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Thu, 25 Nov 2021 08:09:50 +0100 Subject: [PATCH 269/289] Fix #7751 - LDAP: Only get API user from ldap when FIND_GROUP_PERMS is enabled --- netbox/netbox/api/authentication.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 7f8bee318..5e177bfcb 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -29,10 +29,13 @@ class TokenAuthentication(authentication.TokenAuthentication): if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() - user = ldap_backend.populate_user(token.user.username) - # If the user is found in the LDAP directory use it, if not fallback to the local user - if user: - return user, token + + # Load from LDAP if FIND_GROUP_PERMS is active + if ldap_backend.settings.FIND_GROUP_PERMS: + user = ldap_backend.populate_user(token.user.username) + # If the user is found in the LDAP directory use it, if not fallback to the local user + if user: + return user, token return token.user, token From a0b9ac7bcc195a5cd8e5577a0dff16cb51d9775f Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Thu, 25 Nov 2021 12:14:07 +0100 Subject: [PATCH 270/289] UI: Improve performance of the quick filter --- netbox/project-static/dist/netbox.js | Bin 322534 -> 322575 bytes netbox/project-static/dist/netbox.js.map | Bin 310793 -> 310863 bytes netbox/project-static/src/search.ts | 22 ++++++++++++++++------ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 6a60ff56def395459f1244adc618e97ed9a518a2..b8567f0606eb5fb62dc8f125a33623f5466bf28c 100644 GIT binary patch delta 204 zcmaEMUAX^-a6=1Y3)2>6=WDV$DYntE)@k`g8ab&Y3Z)A9X$qN|)gX3`ZRvEGE6gVK zIf}Nnd8IiyYHAueddV57$=RtXwu*+Da9z0|U8(6BrJ9 f7hk~0JKbO}3nydgwCl{0?NZm7w@Y1TIU@o9Lia`2 delta 92 zcmV-i0Hgnp*c0a06M%#PgaWh!L)MoB)B|Ogt=0nw4QwE9W*}u}C}b%qX=az7)B_g; yDJgqoml2==6PF;@0|*Q|Vsd3+Ykg&Gb7dfx{L}*(mlo9nu!r&21Gn+l1RfMlUn58W diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index ba7d8cd2f7dca3b93a9f961f1393011110461af4..c2e1c5b4f3d06367327002e48c1995cdd2b4b000 100644 GIT binary patch delta 169 zcmeDDBXs_cP(ur23)2?n=Xdzr^__KG9UTpvb=)0YGp2LhW)7NeeV5rV$F%?~5vk+t z=;#fUbk1_p@ptt10}_tmAVGhSV1|>9qoboI$cRXtKu5ga6g1QPVr33YS~ s1?lkzvRsRh1%WC(b;2D@opYwoyUQHOXf*xDUFKj$ = []; + for (const row of rows) { // Find the row's checkbox and deselect it, so that it is not accidentally included in form // submissions. @@ -114,19 +117,26 @@ function initTableFilter(): void { if (checkBox !== null) { checkBox.checked = false; } + // Iterate through each row's cell values for (const value of getRowValues(row)) { if (filter.test(value.toLowerCase())) { - // If this row matches the search pattern, but is already hidden, unhide it and stop - // iterating through the rest of the cells. - row.classList.remove('d-none'); + // If this row matches the search pattern, add it to the list. + matchedRows.push(row); break; - } else { - // If none of the cells in this row match the search pattern, hide the row. - row.classList.add('d-none'); } } } + + // Iterate the rows again to set visibility. + // This results in a single reflow instead of one for each row. + for (const row of rows) { + if (matchedRows.indexOf(row) >= 0) { + row.classList.remove('d-none'); + } else { + row.classList.add('d-none'); + } + } } input.addEventListener('keyup', debounce(handleInput, 300)); } From bbdd3804c71bf9f6226c1cee03091559b220d604 Mon Sep 17 00:00:00 2001 From: bluikko <14869000+bluikko@users.noreply.github.com> Date: Fri, 26 Nov 2021 10:06:52 +0700 Subject: [PATCH 271/289] Add multistandard ITA power outlet type --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 36eb24c96..3616a8e68 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -552,6 +552,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_ITA_M = 'ita-m' TYPE_ITA_N = 'ita-n' TYPE_ITA_O = 'ita-o' + TYPE_ITA_MULTISTANDARD = 'ita-multistandard' # USB TYPE_USB_A = 'usb-a' TYPE_USB_MICROB = 'usb-micro-b' @@ -651,6 +652,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_ITA_M, 'ITA Type M (BS 546)'), (TYPE_ITA_N, 'ITA Type N'), (TYPE_ITA_O, 'ITA Type O'), + (TYPE_ITA_MULTISTANDARD, 'ITA Multistandard'), )), ('USB', ( (TYPE_USB_A, 'USB Type A'), From 3b25db919a0586a25b100c8736e57f7c41db8796 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 30 Nov 2021 09:43:14 -0600 Subject: [PATCH 272/289] Update changelog for #7940 --- docs/release-notes/version-3.1.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index cbcf5478b..a8422c208 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -15,6 +15,7 @@ * [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments * [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions * [#7884](https://github.com/netbox-community/netbox/issues/7884) - Add FHRP groups column to interface tables +* [#7940](https://github.com/netbox-community/netbox/issues/7940) - Add ITA multistandard outlet ### Bug Fixes From 83010e278c3046a48368f2c5d7ca6bd3ee38e8ff Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 1 Dec 2021 09:18:31 -0500 Subject: [PATCH 273/289] Add changelog for #7932, #7941 --- docs/release-notes/version-3.0.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index f7fbe06ab..ab32ea434 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,11 @@ ## v3.0.12 (FUTURE) +### Enhancements + +* [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function +* [#7941](https://github.com/netbox-community/netbox/issues/7941) - Add multi-standard ITA power outlet type + --- ## v3.0.11 (2021-11-24) From f1466d6da36a97c37a2df6f3615b8e17fa8e9245 Mon Sep 17 00:00:00 2001 From: Rhys Barrie Date: Thu, 2 Dec 2021 12:27:30 -0500 Subject: [PATCH 274/289] netbox-community/netbox#7885: Linkify VLAN name in VLAN tables --- netbox/ipam/tables/vlans.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index ffa6c5f40..a9e3dd48b 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -95,6 +95,9 @@ class VLANTable(BaseTable): template_code=VLAN_LINK, verbose_name='VID' ) + name = tables.Column( + linkify=True + ) site = tables.Column( linkify=True ) From 1377eda0bafa0d3f3ddb81010edb90480c71bad1 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Fri, 3 Dec 2021 09:43:42 -0500 Subject: [PATCH 275/289] Add support for L22-30P power port type (#7915) * Add support for L22-30P power port type Fixes #7892 * Add support for L22-30R power outlet type --- netbox/dcim/choices.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 3616a8e68..d77a51c05 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -312,6 +312,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_L1560P = 'nema-l15-60p' TYPE_NEMA_L2120P = 'nema-l21-20p' TYPE_NEMA_L2130P = 'nema-l21-30p' + TYPE_NEMA_L2230P = 'nema-l22-30p' # California style TYPE_CS6361C = 'cs6361c' TYPE_CS6365C = 'cs6365c' @@ -417,6 +418,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_L1560P, 'NEMA L15-60P'), (TYPE_NEMA_L2120P, 'NEMA L21-20P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'), + (TYPE_NEMA_L2230P, 'NEMA L22-30P'), )), ('California Style', ( (TYPE_CS6361C, 'CS6361C'), @@ -533,6 +535,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_L1560R = 'nema-l15-60r' TYPE_NEMA_L2120R = 'nema-l21-20r' TYPE_NEMA_L2130R = 'nema-l21-30r' + TYPE_NEMA_L2230R = 'nema-l22-30r' # California style TYPE_CS6360C = 'CS6360C' TYPE_CS6364C = 'CS6364C' @@ -631,6 +634,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_L1560R, 'NEMA L15-60R'), (TYPE_NEMA_L2120R, 'NEMA L21-20R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'), + (TYPE_NEMA_L2230R, 'NEMA L22-30R'), )), ('California Style', ( (TYPE_CS6360C, 'CS6360C'), From 97f0414ff301ce24bfbca33f9043e0dbcdba2a10 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 3 Dec 2021 09:51:05 -0500 Subject: [PATCH 276/289] Changelog for #7751, #7885, #7892 --- docs/release-notes/version-3.0.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index ab32ea434..b84f2a3c0 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -4,6 +4,9 @@ ### Enhancements +* [#7751](https://github.com/netbox-community/netbox/issues/7751) - Get API user from LDAP only when `FIND_GROUP_PERMS` is enabled +* [#7885](https://github.com/netbox-community/netbox/issues/7885) - Linkify VLAN name in VLANs table +* [#7892](https://github.com/netbox-community/netbox/issues/7892) - Add L22-30 power port & outlet types * [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function * [#7941](https://github.com/netbox-community/netbox/issues/7941) - Add multi-standard ITA power outlet type From 68f322a03bfd9151a135222b0600111fcc134c47 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 3 Dec 2021 10:51:24 -0500 Subject: [PATCH 277/289] Closes #7925: Linkify contact phone and email attributes --- docs/release-notes/version-3.1.md | 2 +- netbox/templates/tenancy/contact.html | 16 ++++++++++++++-- netbox/tenancy/tables.py | 6 +++++- netbox/utilities/tables.py | 16 ++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index a8422c208..11e034fca 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -15,7 +15,7 @@ * [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments * [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions * [#7884](https://github.com/netbox-community/netbox/issues/7884) - Add FHRP groups column to interface tables -* [#7940](https://github.com/netbox-community/netbox/issues/7940) - Add ITA multistandard outlet +* [#7925](https://github.com/netbox-community/netbox/issues/7925) - Linkify contact phone and email attributes ### Bug Fixes diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 79878b1ac..2c7cef040 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -37,11 +37,23 @@
Phone{{ object.phone|placeholder }} + {% if object.phone %} + {{ object.phone }} + {% else %} + None + {% endif %} +
Email{{ object.email|placeholder }} + {% if object.phone %} + {{ object.email }} + {% else %} + None + {% endif %} +
Address
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Region - {% if object.region %} - {% for region in object.region.get_ancestors %} - {{ region }} / - {% endfor %} - {{ object.region }} - {% else %} - None - {% endif %} -
Group - {% if object.group %} - {% for group in object.group.get_ancestors %} - {{ group }} / - {% endfor %} - {{ object.group }} - {% else %} - None - {% endif %} -
Status - {{ object.get_status_display }} -
Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None - {% endif %} -
Facility{{ object.facility|placeholder }}
Description{{ object.description|placeholder }}
AS Number{{ object.asn|placeholder }}
Time Zone - {% if object.time_zone %} - {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
- Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} - {% else %} - - {% endif %} -
-
-
-
-
Contact Info
-
- {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %} - - - - - - - - - - - - - - - - - - - - - - - - - -
Physical Address - {% if object.physical_address %} - - {{ object.physical_address|linebreaksbr }} - {% else %} - - {% endif %} -
Shipping Address{{ object.shipping_address|linebreaksbr|placeholder }}
GPS Coordinates - {% if object.latitude and object.longitude %} - - {{ object.latitude }}, {{ object.longitude }} - {% else %} - - {% endif %} -
Contact Name - {% if object.contact_name %} -
- -
- {% endif %} - {{ object.contact_name|placeholder }} -
Contact Phone - {% if object.contact_phone %} -
- -
- {{ object.contact_phone }} - {% else %} - - {% endif %} -
Contact E-Mail - {% if object.contact_email %} -
- -
- {{ object.contact_email }} - {% else %} - - {% endif %} -
- {% endwith %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/contacts.html' %} - {% plugin_left_page object %} - -
-
-
- Stats -
-
-
- -
-

{{ stats.device_count }}

-

Devices

-
-
-

{{ stats.prefix_count }}

-

Prefixes

-
- -
-

{{ stats.circuit_count }}

-

Circuits

-
-
-

{{ stats.vm_count }}

-

Virtual Machines

-
- -
-
-
-
-
- Locations -
-
- {% if locations %} - - - - - - - - {% for location in locations %} - - - - - - - {% endfor %} -
LocationRacksDevices
- {% for i in location.level|as_range %}{% endfor %} - {{ location }} - - {{ location.rack_count }} - - {{ location.device_count }} - - - - -
- {% else %} - None - {% endif %} -
-
-
-
- ASNs -
-
- {% if asns %} - {% for asn in asns %} - {{ asn }} +
+
+
Site
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {# Legacy contact fields #} + {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %} + {% if object.contact_name %} + + + + + {% endif %} + {% if object.contact_phone %} + + + + + {% endif %} + {% if object.contact_email %} + + + + + {% endif %} + {% endwith %} +
Region + {% if object.region %} + {% for region in object.region.get_ancestors %} + {{ region }} / {% endfor %} + {{ object.region }} {% else %} None {% endif %} +
Group + {% if object.group %} + {% for group in object.group.get_ancestors %} + {{ group }} / + {% endfor %} + {{ object.group }} + {% else %} + None + {% endif %} +
Status + {{ object.get_status_display }} +
Tenant + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Facility{{ object.facility|placeholder }}
Description{{ object.description|placeholder }}
AS Number{{ object.asn|placeholder }}
Time Zone + {% if object.time_zone %} + {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
+ Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} + {% else %} + + {% endif %} +
Physical Address + {% if object.physical_address %} + + {{ object.physical_address|linebreaksbr }} + {% else %} + + {% endif %} +
Shipping Address{{ object.shipping_address|linebreaksbr|placeholder }}
GPS Coordinates + {% if object.latitude and object.longitude %} + + {{ object.latitude }}, {{ object.longitude }} + {% else %} + + {% endif %} +
Contact Name + {% if object.contact_name %} +
+ +
+ {% endif %} + {{ object.contact_name|placeholder }} +
Contact Phone + {% if object.contact_phone %} +
+ +
+ {{ object.contact_phone }} + {% else %} + + {% endif %} +
Contact E-Mail + {% if object.contact_email %} +
+ +
+ {{ object.contact_email }} + {% else %} + + {% endif %} +
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+
Stats
+
+
+ + +
+

{{ stats.device_count }}

+

Devices

+
+
+

{{ stats.prefix_count }}

+

Prefixes

+
+ +
+

{{ stats.circuit_count }}

+

Circuits

+
+
+

{{ stats.vm_count }}

+

Virtual Machines

+
+ +
- {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} +
+ {% include 'inc/panels/contacts.html' %} +
+
Locations
+
+ {% if locations %} + + + + + + + + {% for location in locations %} + + + + + + + {% endfor %} +
LocationRacksDevices
+ {% for i in location.level|as_range %}{% endfor %} + {{ location }} + + {{ location.rack_count }} + + {{ location.device_count }} + + + + +
+ {% else %} + None + {% endif %} +
+ {% if perms.dcim.add_location %} + + {% endif %} +
+
+
ASNs
+
+ {% if asns %} + + + + + + {% for asn in asns %} + + + + + {% endfor %} +
ASNDescription
{{ asn }}{{ asn.description|placeholder }}
+ {% else %} + None + {% endif %} +
+ {% if perms.ipam.add_asn %} + + {% endif %} +
+ {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %}
-
- {% plugin_full_width_page object %} -
+
+ {% plugin_full_width_page object %} +
{% endblock %} From 26b2431cbf07afd9889e67e6e669d2674ee452ce Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 14:38:13 -0500 Subject: [PATCH 287/289] Bump django-taggit to 2.0.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a4a16aa2f..d728493dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ django-prometheus==2.1.0 django-redis==5.1.0 django-rq==2.5.1 django-tables2==2.4.1 -django-taggit==1.5.1 +django-taggit==2.0.0 django-timezone-field==4.2.1 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 From 5561b46a59616714ff2578f0eeaeb371c5597521 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 14:58:49 -0500 Subject: [PATCH 288/289] Finalize release notes --- docs/release-notes/version-3.1.md | 56 +++++++++---------------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 0ab4a611d..b117107b6 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,44 +1,6 @@ -## v3.1-beta2 (FUTURE) +# NetBox v3.1 -### Breaking Changes - -* Exported webhooks and custom fields now reference associated content types by raw string value (e.g. "dcim.site") rather than by human-friendly name. -* The 128GFC interface type has been corrected from `128gfc-sfp28` to `128gfc-qsfp28`. - -### Enhancements - -* [#5143](https://github.com/netbox-community/netbox/issues/5143) - Include a device's asset tag in its display value -* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class -* [#7761](https://github.com/netbox-community/netbox/issues/7761) - Extend cable tracing across bridged interfaces -* [#7769](https://github.com/netbox-community/netbox/issues/7769) - Enable assignment of IP addresses to an existing FHRP group -* [#7775](https://github.com/netbox-community/netbox/issues/7775) - Enable dynamic config for `CHANGELOG_RETENTION`, `CUSTOM_VALIDATORS`, and `GRAPHQL_ENABLED` -* [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments -* [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions -* [#7884](https://github.com/netbox-community/netbox/issues/7884) - Add FHRP groups column to interface tables -* [#7924](https://github.com/netbox-community/netbox/issues/7924) - Include child groups on contact group view -* [#7925](https://github.com/netbox-community/netbox/issues/7925) - Linkify contact phone and email attributes - -### Bug Fixes - -* [#7589](https://github.com/netbox-community/netbox/issues/7589) - Correct 128GFC interface type identifier -* [#7756](https://github.com/netbox-community/netbox/issues/7756) - Fix AttributeError exception when editing an IP address assigned to a FHRPGroup -* [#7757](https://github.com/netbox-community/netbox/issues/7757) - Fix 404 when assigning multiple contacts/FHRP groups in succession -* [#7768](https://github.com/netbox-community/netbox/issues/7768) - Validate IP address status when creating a new FHRP group -* [#7771](https://github.com/netbox-community/netbox/issues/7771) - Group assignment should be optional when creating contacts via REST API -* [#7849](https://github.com/netbox-community/netbox/issues/7849) - Fix exception when creating an FHRPGroup with an invalid IP address -* [#7880](https://github.com/netbox-community/netbox/issues/7880) - Include assigned IP addresses in FHRP group object representation -* [#7960](https://github.com/netbox-community/netbox/issues/7960) - Prevent creation of regions/site groups/locations with duplicate names (see #7354) - -### REST API Changes - -* dcim.Device - * The `display` field now includes the device's asset tag, if set -* extras.ImageAttachment - * Added the `last_updated` field - ---- - -## v3.1-beta1 (2021-11-05) +## v3.1.0 (2021-12-06) !!! warning "PostgreSQL 10 Required" NetBox v3.1 requires PostgreSQL 10 or later. @@ -47,6 +9,8 @@ * The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. * The `cable_peer` and `cable_peer_type` attributes of cable termination models have been renamed to `link_peer` and `link_peer_type`, respectively, to accommodate wireless links between interfaces. +* Exported webhooks and custom fields now reference associated content types by raw string value (e.g. "dcim.site") rather than by human-friendly name. +* The 128GFC interface type has been corrected from `128gfc-sfp28` to `128gfc-qsfp28`. ### New Features @@ -116,6 +80,7 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces * [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices +* [#5143](https://github.com/netbox-community/netbox/issues/5143) - Include a device's asset tag in its display value * [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models * [#6615](https://github.com/netbox-community/netbox/issues/6615) - Add filter lookups for custom fields * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support @@ -125,6 +90,14 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * [#7452](https://github.com/netbox-community/netbox/issues/7452) - Add `json` custom field type * [#7530](https://github.com/netbox-community/netbox/issues/7530) - Move device type component lists to separate views * [#7606](https://github.com/netbox-community/netbox/issues/7606) - Model transmit power for interfaces +* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class +* [#7761](https://github.com/netbox-community/netbox/issues/7761) - Extend cable tracing across bridged interfaces +* [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments +* [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions + +### Bug Fixes + +* [#7589](https://github.com/netbox-community/netbox/issues/7589) - Correct 128GFC interface type identifier ### Other Changes @@ -175,6 +148,7 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * `cable_peer` has been renamed to `link_peer` * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Device + * The `display` field now includes the device's asset tag, if set * Added `airflow` field * dcim.DeviceType * Added `airflow` field @@ -209,6 +183,8 @@ Support for single sign-on (SSO) authentication has been added via the [python-s * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Site * Added `asns` relationship to ipam.ASN +* extras.ImageAttachment + * Added the `last_updated` field * extras.Webhook * Added the `conditions` field * virtualization.VMInterface From 5164b78da14c26f313fffb83da0c2263a672e340 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 6 Dec 2021 15:01:36 -0500 Subject: [PATCH 289/289] Release v3.1.0 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- netbox/netbox/settings.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b041c7ff4..66ead4f47 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.12 + placeholder: v3.1.0 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 0852b4f9b..dcc0b1a5f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.12 + placeholder: v3.1.0 validations: required: true - type: dropdown diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bcea11e30..20a6d5d02 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1-beta1' +VERSION = '3.1.0' # Hostname HOSTNAME = platform.node()