From 29a5fb041ff6b1b82d23f110b5008f84b3f954d2 Mon Sep 17 00:00:00 2001 From: Craig Pund Date: Mon, 13 Jun 2022 17:04:25 -0400 Subject: [PATCH 01/50] add fields for virtual chassis to device_edit form --- netbox/templates/dcim/device_edit.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 7cbb224c9..38125e83c 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -86,6 +86,15 @@ {% render_field form.tenant %} +
+
+
Virtual Chassis
+
+ {% render_field form.virtual_chassis %} + {% render_field form.vc_position %} + {% render_field form.vc_priority %} +
+ {% if form.custom_fields %}
From 6876c9878e1e0965b61a341fd63083fc514c4540 Mon Sep 17 00:00:00 2001 From: Craig Pund Date: Mon, 13 Jun 2022 17:06:08 -0400 Subject: [PATCH 02/50] added field definitions for device form --- netbox/dcim/forms/models.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 179893219..2c905cc5c 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -521,13 +521,26 @@ class DeviceForm(TenancyForm, NetBoxModelForm): required=False, label='' ) + virtual_chassis = DynamicModelChoiceField( + queryset=VirtualChassis.objects.all(), + required=False + ) + vc_position = forms.IntegerField( + required=False, + help_text="The position in the virtual chassis this device is identified by" + ) + vc_priority = forms.IntegerField( + required=False, + help_text="The priority of the device in the virtual chassis" + ) class Meta: model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', - 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' + 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', + 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", From 52178f78d1b22478aeee567dddeab511c8cc2e3a Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 21 Jun 2022 12:58:41 -0400 Subject: [PATCH 03/50] Closes #7702: Add Powerfeed Defaults to User Configurations --- netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 12 ++++++++++++ netbox/extras/admin.py | 3 +++ netbox/netbox/config/parameters.py | 25 +++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c5cd0fa65..82ea3fec0 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -489,7 +489,7 @@ urlpatterns = [ # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), - path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), + path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 35a1056b2..0716f595e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -16,6 +16,7 @@ from circuits.models import Circuit from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable +from netbox.config import ConfigItem from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -3253,6 +3254,17 @@ class PowerFeedView(generic.ObjectView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') +class PowerFeedCreateView(generic.ObjectEditView): + queryset = PowerFeed.objects.all() + form = forms.PowerFeedForm + + def alter_object(self, obj, request, args, kwargs): + obj.voltage = ConfigItem('POWERFEED_DEFAULT_VOLTAGE') + obj.amperage = ConfigItem('POWERFEED_DEFAULT_AMPERAGE') + obj.max_utilization = ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION') + return obj + + class PowerFeedEditView(generic.ObjectEditView): queryset = PowerFeed.objects.all() form = forms.PowerFeedForm diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 28902c323..01011b276 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -15,6 +15,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('Rack Elevations', { 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), }), + ('Power', { + 'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION') + }), ('IPAM', { 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), }), diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 68c96b38a..e2295888f 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -82,6 +82,31 @@ PARAMS = ( field=forms.IntegerField ), + # Power + ConfigParam( + name='POWERFEED_DEFAULT_VOLTAGE', + label='Powerfeed voltage', + default=120, + description="Default voltage for powerfeeds", + field=forms.IntegerField + ), + + ConfigParam( + name='POWERFEED_DEFAULT_AMPERAGE', + label='Powerfeed amperage', + default=15, + description="Default amperage for powerfeeds", + field=forms.IntegerField + ), + + ConfigParam( + name='POWERFEED_DEFAULT_MAX_UTILIZATION', + label='Powerfeed max utilization', + default=80, + description="Default max utilization for powerfeeds", + field=forms.IntegerField + ), + # Security ConfigParam( name='ALLOWED_URL_SCHEMES', From 6cb8b9110ebf2d6ff5a41485ad7a1c29cb89008c Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Thu, 23 Jun 2022 13:28:36 -0400 Subject: [PATCH 04/50] Closes #9396: Query modules by module bay & display installed_modules for module_bay REST API endpoint (#9574) * Closes #9396 - Added ability to query modules by module bay & installed_modules for module bay REST API endpoint * Closes #9396 - Added ability to query modules by module bay & installed_modules for module bay REST API endpoint * Closes #9396 - Added ability to query modules by module bay & installed_modules for module bay REST API endpoint --- netbox/dcim/api/nested_serializers.py | 9 +++++++++ netbox/dcim/api/serializers.py | 4 ++-- netbox/dcim/api/views.py | 2 +- netbox/dcim/filtersets.py | 6 ++++++ netbox/dcim/tests/test_filtersets.py | 5 +++++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 0ec0e07e0..1be8bb9dc 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ 'ComponentNestedModuleSerializer', + 'ModuleBayNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class ModuleBayNestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'serial'] + + class ComponentNestedModuleSerializer(WritableNestedSerializer): """ Used by device component serializers. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7fcab6ba3..c2cb846a9 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -886,12 +886,12 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): class ModuleBaySerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') device = NestedDeviceSerializer() - # installed_module = NestedModuleSerializer(required=False, allow_null=True) + installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True) class Meta: model = ModuleBay fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields', + 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c4c25f654..3fa652a9b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -611,7 +611,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class ModuleBayViewSet(NetBoxModelViewSet): - queryset = ModuleBay.objects.prefetch_related('tags') + queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module') serializer_class = serializers.ModuleBaySerializer filterset_class = filtersets.ModuleBayFilterSet brief_prefetch_fields = ['device'] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f052a8be9..4b4201578 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -992,6 +992,12 @@ class ModuleFilterSet(NetBoxModelFilterSet): to_field_name='model', label='Module type (model)', ) + module_bay_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay', + queryset=ModuleBay.objects.all(), + to_field_name='id', + label='Module Bay (ID)' + ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 273ee6570..47aa9368c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1849,6 +1849,11 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'module_type': [module_types[0].model, module_types[1].model]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_module_bay(self): + module_bays = ModuleBay.objects.all()[:2] + params = {'module_bay_id': [module_bays[0].pk, module_bays[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): device_types = Device.objects.all()[:2] params = {'device_id': [device_types[0].pk, device_types[1].pk]} From afec53cea3873dac9ea970bf70431bde0a399a66 Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Thu, 23 Jun 2022 14:11:59 -0400 Subject: [PATCH 05/50] Fixes #9575: Add exception handling to services (#9586) --- netbox/ipam/views.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a01f2d052..6bcdc4c64 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -680,13 +680,16 @@ class IPAddressView(generic.ObjectView): service_filter = Q(ipaddresses=instance) # Find services listening on all IPs on the assigned device/vm - if instance.assigned_object and instance.assigned_object.parent_object: - parent_object = instance.assigned_object.parent_object + try: + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object - if isinstance(parent_object, VirtualMachine): - service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) - elif isinstance(parent_object, Device): - service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + except AttributeError: + pass services = Service.objects.restrict(request.user, 'view').filter(service_filter) From d55e3c352a3e8c3ac7d2d3639b85a509272f9d0e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 14:14:02 -0400 Subject: [PATCH 06/50] Changelog for #9396, #9575, #9597 --- docs/release-notes/version-3.2.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 059fc8924..c87200147 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,15 @@ ## v3.2.6 (FUTURE) +### Enhancements + +* [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID + +### Bug Fixes + +* [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned +* [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer + --- ## v3.2.5 (2022-06-20) From db807ab4a6f5d0ff9ff84ae39b5f18688bb19a1a Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Thu, 23 Jun 2022 14:16:09 -0400 Subject: [PATCH 07/50] Closes #7702: Add power feed defaults to user configurations --- netbox/dcim/models/power.py | 7 ++++--- netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 12 ------------ 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 08f89e3b0..5316951c6 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,6 +6,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * +from netbox.config import ConfigItem from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): default=PowerFeedPhaseChoices.PHASE_SINGLE ) voltage = models.SmallIntegerField( - default=POWERFEED_VOLTAGE_DEFAULT, + default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE') validators=[ExclusionValidator([0])] ) amperage = models.PositiveSmallIntegerField( validators=[MinValueValidator(1)], - default=POWERFEED_AMPERAGE_DEFAULT + default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE') ) max_utilization = models.PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(100)], - default=POWERFEED_MAX_UTILIZATION_DEFAULT, + default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION') help_text="Maximum permissible draw (percentage)" ) available_power = models.PositiveIntegerField( diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 82ea3fec0..c5cd0fa65 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -489,7 +489,7 @@ urlpatterns = [ # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), - path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), + path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0716f595e..35a1056b2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -16,7 +16,6 @@ from circuits.models import Circuit from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable -from netbox.config import ConfigItem from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -3254,17 +3253,6 @@ class PowerFeedView(generic.ObjectView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') -class PowerFeedCreateView(generic.ObjectEditView): - queryset = PowerFeed.objects.all() - form = forms.PowerFeedForm - - def alter_object(self, obj, request, args, kwargs): - obj.voltage = ConfigItem('POWERFEED_DEFAULT_VOLTAGE') - obj.amperage = ConfigItem('POWERFEED_DEFAULT_AMPERAGE') - obj.max_utilization = ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION') - return obj - - class PowerFeedEditView(generic.ObjectEditView): queryset = PowerFeed.objects.all() form = forms.PowerFeedForm From c330282919f7903315abc0263c9d3f5d22321fa2 Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Thu, 23 Jun 2022 14:56:24 -0400 Subject: [PATCH 08/50] Fix syntax error --- netbox/dcim/models/power.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 5316951c6..5978d86bd 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -106,7 +106,7 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): default=PowerFeedPhaseChoices.PHASE_SINGLE ) voltage = models.SmallIntegerField( - default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE') + default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'), validators=[ExclusionValidator([0])] ) amperage = models.PositiveSmallIntegerField( @@ -115,7 +115,7 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): ) max_utilization = models.PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(100)], - default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION') + default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'), help_text="Maximum permissible draw (percentage)" ) available_power = models.PositiveIntegerField( From b77013c859d91f5bbd4da64eb28094886280d70f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 17:26:20 -0400 Subject: [PATCH 09/50] Docs & cleanup for #7702 --- docs/configuration/dynamic-settings.md | 48 +++++++++++++++++++------- docs/release-notes/version-3.2.md | 1 + netbox/dcim/constants.py | 9 ----- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index 2fa046fcf..d376dc5c4 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -43,18 +43,6 @@ changes in the database indefinitely. --- -## JOBRESULT_RETENTION - -Default: 90 - -The number of days to retain job results (scripts and reports). Set this to `0` to retain -job results in the database indefinitely. - -!!! warning - If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. - ---- - ## 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: @@ -110,6 +98,18 @@ Setting this to False will disable the GraphQL API. --- +## JOBRESULT_RETENTION + +Default: 90 + +The number of days to retain job results (scripts and reports). Set this to `0` to retain +job results in the database indefinitely. + +!!! warning + If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + ## MAINTENANCE_MODE Default: False @@ -185,6 +185,30 @@ The default maximum number of objects to display per page within each list of ob --- +## POWERFEED_DEFAULT_AMPERAGE + +Default: 15 + +The default value for the `amperage` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_MAX_UTILIZATION + +Default: 80 + +The default value (percentage) for the `max_utilization` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_VOLTAGE + +Default: 120 + +The default value for the `voltage` field when creating new power feeds. + +--- + ## PREFER_IPV4 Default: False diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index c87200147..719ceb7e1 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,6 +4,7 @@ ### Enhancements +* [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes * [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID ### Bug Fixes diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 38bf16f0b..155f19c88 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -49,15 +49,6 @@ WIRELESS_IFACE_TYPES = [ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES -# -# Power feeds -# - -POWERFEED_VOLTAGE_DEFAULT = 120 -POWERFEED_AMPERAGE_DEFAULT = 20 -POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage - - # # Device components # From 4315c4697cc3ade4901c0a9bb9bef6720027252e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 17:44:19 -0400 Subject: [PATCH 10/50] Ignore default field values which reference ConfigItems when calculating migrations --- netbox/dcim/migrations/0001_squashed.py | 6 +++--- netbox/utilities/management/commands/__init__.py | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index bb99d199f..374d3bf45 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -386,9 +386,9 @@ class Migration(migrations.Migration): ('type', models.CharField(default='primary', max_length=50)), ('supply', models.CharField(default='ac', max_length=50)), ('phase', models.CharField(default='single-phase', max_length=50)), - ('voltage', models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])])), - ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])), - ('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), + ('voltage', models.SmallIntegerField(validators=[utilities.validators.ExclusionValidator([0])])), + ('amperage', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ('max_utilization', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), ('available_power', models.PositiveIntegerField(default=0, editable=False)), ('comments', models.TextField(blank=True)), ], diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py index bdd4face6..2c261b0d3 100644 --- a/netbox/utilities/management/commands/__init__.py +++ b/netbox/utilities/management/commands/__init__.py @@ -1,6 +1,8 @@ from django.db import models from timezone_field import TimeZoneField +from netbox.config import ConfigItem + SKIP_FIELDS = ( TimeZoneField, @@ -26,4 +28,9 @@ def custom_deconstruct(field): for attr in EXEMPT_ATTRS: kwargs.pop(attr, None) + # Ignore any field defaults which reference a ConfigItem + kwargs = { + k: v for k, v in kwargs.items() if not isinstance(v, ConfigItem) + } + return name, path, args, kwargs From d8b40056b52d6c12c1e37160f0e86f0730658d89 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Thu, 23 Jun 2022 20:54:26 -0400 Subject: [PATCH 11/50] Fixes #8854: Remote auth default groups added to new remote auth users --- netbox/netbox/authentication.py | 25 +++++++++++++++++++++++++ netbox/netbox/settings.py | 13 +++++++++++++ 2 files changed, 38 insertions(+) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index a13e8d192..208378872 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -1,5 +1,7 @@ import logging from collections import defaultdict +import requests +from rich import print from django.conf import settings from django.contrib.auth import get_user_model @@ -348,3 +350,26 @@ class LDAPBackend: ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) return obj + + +# Custom Social Auth Pipeline Handlers +def user_default_groups_handler(backend, user, response, *args, **kwargs): + """ + Custom pipeline handler which adds remote auth users to the default group specified in the + configuration file. + """ + logger = logging.getLogger('netbox.auth.user_default_groups_handler') + if settings.REMOTE_AUTH_DEFAULT_GROUPS: + # Assign default groups to the user + group_list = [] + for name in settings.REMOTE_AUTH_DEFAULT_GROUPS: + try: + group_list.append(Group.objects.get(name=name)) + except Group.DoesNotExist: + logging.error( + f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") + if group_list: + user.groups.add(*group_list) + else: + user.groups.clear() + logger.debug(f"Stripping user {user} from Groups") diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b2e1eca6c..c0df42a2b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -483,6 +483,19 @@ for param in dir(configuration): SOCIAL_AUTH_JSONFIELD_ENABLED = True +SOCIAL_AUTH_PIPELINE = ( + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + 'social_core.pipeline.social_auth.social_user', + 'social_core.pipeline.user.get_username', + 'social_core.pipeline.social_auth.associate_by_email', + 'social_core.pipeline.user.create_user', + 'social_core.pipeline.social_auth.associate_user', + 'netbox.authentication.user_default_groups_handler', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', +) + # # Django Prometheus From 9b91c2a88665f3621c05ae81f52ab2a8fed60c7f Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Thu, 23 Jun 2022 23:29:14 -0400 Subject: [PATCH 12/50] syntax: Removed dev imports --- netbox/netbox/authentication.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 208378872..00fb3ee66 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -1,7 +1,5 @@ import logging from collections import defaultdict -import requests -from rich import print from django.conf import settings from django.contrib.auth import get_user_model From 2077378ae174013f43cfca75bf4fc082bbfd3ed1 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 25 Jun 2022 15:41:31 -0400 Subject: [PATCH 13/50] Closes #9540: Filter IP addresses by assigned Device/VM --- netbox/ipam/forms/filtersets.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index bbd6bb97b..3d67d4d37 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,7 +1,8 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import Location, Rack, Region, Site, SiteGroup +from dcim.models import Location, Rack, Region, Site, SiteGroup, Device +from virtualization.models import VirtualMachine from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -265,6 +266,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Device/VM', ('device_id', 'virtual_machine_id')), ) parent = forms.CharField( required=False, @@ -298,6 +300,16 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, label=_('Present in VRF') ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Assigned Device'), + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label=_('Assigned VM'), + ) status = MultipleChoiceField( choices=IPAddressStatusChoices, required=False From 03f1584d3aadf5bd37c31e439a75a1b2bd064db3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 27 Jun 2022 23:24:50 -0500 Subject: [PATCH 14/50] L2VPN Clean Tree --- netbox/dcim/models/device_components.py | 5 + netbox/ipam/api/nested_serializers.py | 27 ++++ netbox/ipam/api/serializers.py | 58 +++++++++ netbox/ipam/api/urls.py | 4 + netbox/ipam/api/views.py | 13 ++ netbox/ipam/choices.py | 50 ++++++++ netbox/ipam/constants.py | 6 + netbox/ipam/filtersets.py | 65 ++++++++++ netbox/ipam/forms/bulk_edit.py | 18 +++ netbox/ipam/forms/bulk_import.py | 74 +++++++++++ netbox/ipam/forms/filtersets.py | 29 +++++ netbox/ipam/forms/models.py | 96 +++++++++++++++ netbox/ipam/models/__init__.py | 3 + netbox/ipam/models/l2vpn.py | 116 ++++++++++++++++++ netbox/ipam/models/vlans.py | 9 +- netbox/ipam/tables/__init__.py | 1 + netbox/ipam/tables/l2vpn.py | 38 ++++++ netbox/ipam/tests/test_api.py | 93 ++++++++++++++ netbox/ipam/tests/test_filtersets.py | 101 +++++++++++++++ netbox/ipam/tests/test_models.py | 10 ++ netbox/ipam/tests/test_views.py | 10 ++ netbox/ipam/urls.py | 21 ++++ netbox/ipam/views.py | 96 +++++++++++++++ netbox/netbox/navigation_menu.py | 7 ++ netbox/templates/ipam/l2vpn.html | 111 +++++++++++++++++ netbox/templates/ipam/l2vpntermination.html | 31 +++++ .../templates/ipam/l2vpntermination_edit.html | 39 ++++++ 27 files changed, 1130 insertions(+), 1 deletion(-) create mode 100644 netbox/ipam/models/l2vpn.py create mode 100644 netbox/ipam/tables/l2vpn.py create mode 100644 netbox/templates/ipam/l2vpn.html create mode 100644 netbox/templates/ipam/l2vpntermination.html create mode 100644 netbox/templates/ipam/l2vpntermination_edit.html diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index f49db08ab..4d19a2d8d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -649,6 +649,11 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo object_id_field='interface_id', related_query_name='+' ) + l2vpn = GenericRelation( + to='ipam.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 5f9e09049..8316cb992 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from ipam import models +from ipam.models.l2vpn import L2VPNTermination, L2VPN from netbox.api import WritableNestedSerializer __all__ = [ @@ -190,3 +191,29 @@ class NestedServiceSerializer(WritableNestedSerializer): class Meta: model = models.Service fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] + +# +# Virtual Circuits +# + + +class NestedL2VPNSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') + + class Meta: + model = L2VPN + fields = [ + 'id', 'url', 'display', 'name', 'type' + ] + + +class NestedL2VPNTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn_termination-detail') + l2vpn = NestedL2VPNSerializer() + + class Meta: + model = L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn', 'assigned_object' + ] + diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ea5c37f91..a51043e27 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -19,6 +19,9 @@ from .nested_serializers import * # # ASNs # +from .nested_serializers import NestedL2VPNSerializer +from ..models.l2vpn import L2VPNTermination, L2VPN + class ASNSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') @@ -433,3 +436,58 @@ class ServiceSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] + +# +# Virtual Circuits +# + + +class L2VPNSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') + type = ChoiceField(choices=L2VPNTypeChoices, required=False) + import_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + export_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = L2VPN + fields = [ + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', + 'description', 'tenant', + # Extra Fields + 'tags', 'custom_fields', 'created', 'last_updated' + ] + + +class L2VPNTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') + l2vpn = NestedL2VPNSerializer() + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', + # Extra Fields + 'tags', 'custom_fields', 'created', 'last_updated' + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(instance.assigned_object, context=context).data diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 99e039eff..b588b6974 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -45,6 +45,10 @@ router.register('vlans', views.VLANViewSet) router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) +# L2VPN +router.register('l2vpn', views.L2VPNViewSet) +router.register('l2vpn-termination', views.L2VPNTerminationViewSet) + app_name = 'ipam-api' urlpatterns = [ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dcddec580..36a6f02b6 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -18,6 +18,7 @@ from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import serializers +from ..models.l2vpn import L2VPN, L2VPNTermination class IPAMRootView(APIRootView): @@ -157,6 +158,18 @@ class ServiceViewSet(NetBoxModelViewSet): filterset_class = filtersets.ServiceFilterSet +class L2VPNViewSet(NetBoxModelViewSet): + queryset = L2VPN.objects + serializer_class = serializers.L2VPNSerializer + filterset_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationViewSet(NetBoxModelViewSet): + queryset = L2VPNTermination.objects + serializer_class = serializers.L2VPNTerminationSerializer + filterset_class = filtersets.L2VPNTerminationFilterSet + + # # Views # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index a364d3c6a..a867b05bc 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -170,3 +170,53 @@ class ServiceProtocolChoices(ChoiceSet): (PROTOCOL_UDP, 'UDP'), (PROTOCOL_SCTP, 'SCTP'), ) + + +class L2VPNTypeChoices(ChoiceSet): + TYPE_VPLS = 'vpls' + TYPE_VPWS = 'vpws' + TYPE_EPL = 'epl' + TYPE_EVPL = 'evpl' + TYPE_EPLAN = 'ep-lan' + TYPE_EVPLAN = 'evp-lan' + TYPE_EPTREE = 'ep-tree' + TYPE_EVPTREE = 'evp-tree' + TYPE_VXLAN = 'vxlan' + TYPE_VXLAN_EVPN = 'vxlan-evpn' + TYPE_MPLS_EVPN = 'mpls-evpn' + TYPE_PBB_EVPN = 'pbb-evpn' + + CHOICES = ( + ('VPLS', ( + (TYPE_VPWS, 'VPWS'), + (TYPE_VPLS, 'VPLS'), + )), + ('E-Line', ( + (TYPE_EPL, 'EPL'), + (TYPE_EVPL, 'EVPL'), + )), + ('E-LAN', ( + (TYPE_EPLAN, 'Ethernet Private LAN'), + (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), + )), + ('E-Tree', ( + (TYPE_EPTREE, 'Ethernet Private Tree'), + (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), + )), + ('VXLAN', ( + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), + ('L2VPN E-VPN', ( + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )) + + ) + + P2P = ( + TYPE_VPWS, + TYPE_EPL, + TYPE_EPLAN, + TYPE_EPTREE + ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index ab88dfc1a..cb121515d 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -90,3 +90,9 @@ VLANGROUP_SCOPE_TYPES = ( # 16-bit port number SERVICE_PORT_MIN = 1 SERVICE_PORT_MAX = 65535 + +L2VPN_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='ipam', model='vlan') | + Q(app_label='virtualization', model='vminterface') +) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d9cf6eefc..03189a7cb 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -23,6 +23,8 @@ __all__ = ( 'FHRPGroupFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', + 'L2VPNFilterSet', + 'L2VPNTerminationFilterSet', 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', @@ -922,3 +924,66 @@ class ServiceFilterSet(NetBoxModelFilterSet): return queryset qs_filter = Q(name__icontains=value) | Q(description__icontains=value) return queryset.filter(qs_filter) + + +# +# L2VPN +# + + +class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + import_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets', + queryset=RouteTarget.objects.all(), + label='Import target', + ) + import_target = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Import target (name)', + ) + export_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets', + queryset=RouteTarget.objects.all(), + label='Export target', + ) + export_target = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Export target (name)', + ) + + class Meta: + model = L2VPN + fields = ['identifier', 'name', 'type', 'description'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(identifier=value) | Q(name__icontains=value) | Q(description__icontains=value) + return queryset.filter(qs_filter) + + +class L2VPNTerminationFilterSet(NetBoxModelFilterSet): + l2vpn_id = django_filters.ModelMultipleChoiceFilter( + queryset=L2VPN.objects.all(), + label='L2VPN (ID)', + ) + l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn__name', + queryset=L2VPN.objects.all(), + to_field_name='name', + label='L2VPN (name)', + ) + + class Meta: + model = L2VPNTermination + fields = ['l2vpn'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(l2vpn__name__icontains=value) + return queryset.filter(qs_filter) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 66b4ba0fc..bbfa5bf9f 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -18,6 +18,7 @@ __all__ = ( 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', + 'L2VPNBulkEditForm', 'PrefixBulkEditForm', 'RIRBulkEditForm', 'RoleBulkEditForm', @@ -440,3 +441,20 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): class ServiceBulkEditForm(ServiceTemplateBulkEditForm): model = Service + + +class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + model = L2VPN + fieldsets = ( + (None, ('tenant', 'description')), + ) + nullable_fields = ('tenant', 'description',) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 17da242a0..5b94f6c8e 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.core.exceptions import ValidationError from dcim.models import Device, Interface, Site from ipam.choices import * @@ -16,6 +17,8 @@ __all__ = ( 'FHRPGroupCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', + 'L2VPNCSVForm', + 'L2VPNTerminationCSVForm', 'PrefixCSVForm', 'RIRCSVForm', 'RoleCSVForm', @@ -425,3 +428,74 @@ class ServiceCSVForm(NetBoxModelCSVForm): class Meta: model = Service fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') + + +class L2VPNCSVForm(NetBoxModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + ) + type = CSVChoiceField( + choices=L2VPNTypeChoices, + help_text='IP protocol' + ) + + class Meta: + model = L2VPN + fields = ('identifier', 'name', 'slug', 'type', 'description') + + +class L2VPNTerminationCSVForm(NetBoxModelCSVForm): + l2vpn = CSVModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + to_field_name='name', + label='L2VPN', + ) + + device = CSVModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Required if assigned to a interface' + ) + + interface = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Required if not assigned to a vlan' + ) + + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + to_field_name='name', + help_text='Required if not assigned to a interface' + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', 'device', 'interface', 'vlan') + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + if data: + # Limit interface queryset by assigned device + if data.get('device'): + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + + def clean(self): + super().clean() + + if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + raise ValidationError('You must have either a interface or a VLAN') + + if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + raise ValidationError('Cannot assign both a interface and vlan') + + # Set Assigned Object + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index bbd6bb97b..1cb936ca3 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -19,6 +19,8 @@ __all__ = ( 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', + 'L2VPNFilterForm', + 'L2VPNTerminationFilterForm', 'PrefixFilterForm', 'RIRFilterForm', 'RoleFilterForm', @@ -463,3 +465,30 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + + +class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): + model = L2VPN + fieldsets = ( + (None, ('type', )), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) + type = forms.ChoiceField( + choices=add_blank_choice(L2VPNTypeChoices), + required=False, + widget=StaticSelect() + ) + + +class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): + model = L2VPNTermination + fieldsets = ( + (None, ('l2vpn', )), + ) + l2vpn = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + query_params={}, + label='L2VPN', + fetch_trigger='open' + ) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index e86abc672..7ef47ed2f 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.models import Tag @@ -8,8 +9,10 @@ from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * from ipam.models import ASN +from ipam.models.l2vpn import L2VPN, L2VPNTermination from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm +from tenancy.models import Tenant from utilities.exceptions import PermissionsViolation from utilities.forms import ( add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, @@ -26,6 +29,8 @@ __all__ = ( 'IPAddressBulkAddForm', 'IPAddressForm', 'IPRangeForm', + 'L2VPNForm', + 'L2VPNTerminationForm', 'PrefixForm', 'RIRForm', 'RoleForm', @@ -861,3 +866,94 @@ class ServiceCreateForm(ServiceForm): self.cleaned_data['description'] = service_template.description elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") + + +# +# L2VPN +# + + +class L2VPNForm(TenancyForm, NetBoxModelForm): + slug = SlugField() + import_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + export_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + + fieldsets = ( + ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')), + ('Route Targets', ('import_targets', 'export_targets')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + class Meta: + model = L2VPN + fields = ( + 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' + ) + + +class L2VPNTerminationForm(NetBoxModelForm): + l2vpn = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + query_params={}, + label='L2VPN', + fetch_trigger='open' + ) + + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={} + ) + + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + query_params={ + 'available_on_device': '$device' + } + ) + + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + + if instance: + if type(instance.assigned_object) is Interface: + initial['device'] = instance.assigned_object.parent + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VLAN: + initial['vlan'] = instance.assigned_object + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + raise ValidationError('You must have either a interface or a VLAN') + + if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + raise ValidationError('Cannot assign both a interface and vlan') + + obj = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') + self.instance.assigned_object = obj diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index ce09c482a..d13ee9076 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -2,6 +2,7 @@ from .fhrp import * from .vrfs import * from .ip import * +from .l2vpn import * from .services import * from .vlans import * @@ -12,6 +13,8 @@ __all__ = ( 'IPRange', 'FHRPGroup', 'FHRPGroupAssignment', + 'L2VPN', + 'L2VPNTermination', 'Prefix', 'RIR', 'Role', diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py new file mode 100644 index 000000000..b086fa109 --- /dev/null +++ b/netbox/ipam/models/l2vpn.py @@ -0,0 +1,116 @@ +from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse + +from ipam.choices import L2VPNTypeChoices +from ipam.constants import L2VPN_ASSIGNMENT_MODELS +from netbox.models import NetBoxModel + + +class L2VPN(NetBoxModel): + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField() + type = models.CharField(max_length=50, choices=L2VPNTypeChoices) + identifier = models.BigIntegerField( + null=True, + blank=True, + unique=True + ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_l2vpns', + blank=True, + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_l2vpns', + blank=True + ) + description = models.TextField(null=True, blank=True) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.SET_NULL, + related_name='l2vpns', + blank=True, + null=True + ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + class Meta: + ordering = ('identifier', 'name') + verbose_name = 'L2VPN' + + def __str__(self): + if self.identifier: + return f'{self.name} ({self.identifier})' + return f'{self.name}' + + def get_absolute_url(self): + return reverse('ipam:l2vpn', args=[self.pk]) + + +class L2VPNTermination(NetBoxModel): + l2vpn = models.ForeignKey( + to='ipam.L2VPN', + on_delete=models.CASCADE, + related_name='terminations', + blank=False, + null=False + ) + + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=L2VPN_ASSIGNMENT_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + assigned_object_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) + + class Meta: + ordering = ('l2vpn',) + verbose_name = 'L2VPN Termination' + + constraints = ( + models.UniqueConstraint( + fields=('assigned_object_type', 'assigned_object_id'), + name='ipam_l2vpntermination_assigned_object' + ), + ) + + def __str__(self): + if self.pk is not None: + return f'{self.assigned_object} <> {self.l2vpn}' + return '' + + def get_absolute_url(self): + return reverse('ipam:l2vpntermination', args=[self.pk]) + + def clean(self): + # Only check is assigned_object is set + if self.assigned_object: + obj_id = self.assigned_object.pk + obj_type = ContentType.objects.get_for_model(self.assigned_object) + if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\ + exclude(pk=self.pk).count() > 0: + raise ValidationError(f'L2VPN Termination already assigned ({self.assigned_object})') + + # Only check if L2VPN is set and is of type P2P + if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P: + if L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() >= 2: + raise ValidationError(f'P2P Type L2VPNs can only have 2 terminations; first delete a termination') diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7643a2617..3a7969405 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.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.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -8,6 +8,7 @@ from django.urls import reverse from dcim.models import Interface from ipam.choices import * from ipam.constants import * +from ipam.models import L2VPNTermination from ipam.querysets import VLANQuerySet from netbox.models import OrganizationalModel, NetBoxModel from virtualization.models import VMInterface @@ -173,6 +174,12 @@ class VLAN(NetBoxModel): blank=True ) + l2vpn = GenericRelation( + to='ipam.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + ) + objects = VLANQuerySet.as_manager() clone_fields = [ diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index 6f429e27d..3bde78af0 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,5 +1,6 @@ from .fhrp import * from .ip import * +from .l2vpn import * from .services import * from .vlans import * from .vrfs import * diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py new file mode 100644 index 000000000..551f692bb --- /dev/null +++ b/netbox/ipam/tables/l2vpn.py @@ -0,0 +1,38 @@ +import django_tables2 as tables + +from ipam.models import * +from ipam.models.l2vpn import L2VPN, L2VPNTermination +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'L2VPNTable', + 'L2VPNTerminationTable', +) + + +class L2VPNTable(NetBoxTable): + pk = columns.ToggleColumn() + name = tables.Column( + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = L2VPN + fields = ('pk', 'name', 'description', 'slug', 'type', 'tenant', 'actions') + default_columns = ('pk', 'name', 'description', 'actions') + + +class L2VPNTerminationTable(NetBoxTable): + pk = columns.ToggleColumn() + assigned_object_type = columns.ContentTypeColumn( + verbose_name='Object Type' + ) + assigned_object = tables.Column( + linkify=True, + orderable=False + ) + + class Meta(NetBoxTable.Meta): + model = L2VPNTermination + fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions') + default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index d99de6d20..0e93bd43e 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -914,3 +914,96 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): 'ports': [6], }, ] + + +class L2VPNTest(APIViewTestCases.APIViewTestCase): + model = L2VPN + brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url'] + create_data = [ + { + 'name': 'L2VPN 4', + 'slug': 'l2vpn-4', + 'type': 'vxlan', + 'identifier': 33343344 + }, + { + 'name': 'L2VPN 5', + 'slug': 'l2vpn-5', + 'type': 'vxlan', + 'identifier': 33343345 + }, + { + 'name': 'L2VPN 6', + 'slug': 'l2vpn-6', + 'type': 'vpws', + 'identifier': 33343346 + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + +class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): + model = L2VPNTermination + brief_fields = ['display', 'id', 'l2vpn', 'assigned_object', 'assigned_object_id', 'assigned_object_type', 'url'] + + @classmethod + def setUpTestData(cls): + + vlans = ( + VLAN(name='VLAN 1', vid=650001), + VLAN(name='VLAN 2', vid=650002), + VLAN(name='VLAN 3', vid=650003), + VLAN(name='VLAN 4', vid=650004), + VLAN(name='VLAN 5', vid=650005), + VLAN(name='VLAN 6', vid=650006), + VLAN(name='VLAN 7', vid=650007) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + cls.create_data = [ + { + 'l2vpn': l2vpns[0], + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[3], + }, + { + 'l2vpn': l2vpns[0], + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[4], + }, + { + 'l2vpn': l2vpns[0], + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[5], + }, + ] + + cls.bulk_update_data = { + 'l2vpn': l2vpns[2] + } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index d98fe889e..c5cffc7dc 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1463,3 +1463,104 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'virtual_machine': [vms[0].name, vms[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): + # TODO: L2VPN Tests + queryset = L2VPN.objects.all() + filterset = L2VPNFilterSet + + @classmethod + def setUpTestData(cls): + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + def test_created(self): + from datetime import date, date + pk_list = self.queryset.values_list('pk', flat=True)[:2] + print(pk_list) + self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) + params = {'created': '2021-01-01T00:00:00'} + fs = self.filterset({}, self.queryset).qs.all() + for res in fs: + print(f'{res.name}:{res.created}') + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): + # TODO: L2VPN Termination Tests + queryset = L2VPNTermination.objects.all() + filterset = L2VPNTerminationFilterSet + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + interfaces = Interface.objects.bulk_create( + Interface(name='GigabitEthernet1/0/1', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/2', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/3', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/4', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/5', device=device, type='1000baset'), + ) + + vlans = ( + VLAN(name='VLAN 1', vid=650001), + VLAN(name='VLAN 2', vid=650002), + VLAN(name='VLAN 3', vid=650003), + VLAN(name='VLAN 4', vid=650004), + VLAN(name='VLAN 5', vid=650005), + VLAN(name='VLAN 6', vid=650006), + VLAN(name='VLAN 7', vid=650007) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + def test_l2vpns(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_interfaces(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'interface': ['Interface 1', 'Interface 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlans(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) + params = {'vlan': ['VLAN 1', 'VLAN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 09bc95799..ce4643516 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -538,3 +538,13 @@ class TestVLANGroup(TestCase): VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup) self.assertEqual(vlangroup.get_next_available_vid(), 105) + + +class TestL2VPN(TestCase): + # TODO: L2VPN Tests + pass + + +class TestL2VPNTermination(TestCase): + # TODO: L2VPN Termination Tests + pass diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 672cfbe08..8d1b9bd1b 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -746,3 +746,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertEqual(instance.protocol, service_template.protocol) self.assertEqual(instance.ports, service_template.ports) self.assertEqual(instance.description, service_template.description) + + +class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): + # TODO: L2VPN Tests + pass + + +class L2VPNTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): + # TODO: L2VPN Termination Tests + pass diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 3c7ed2d1f..65a6b55ad 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -186,4 +186,25 @@ urlpatterns = [ path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), + # L2VPN + path('l2vpn/', views.L2VPNListView.as_view(), name='l2vpn_list'), + path('l2vpn/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), + path('l2vpn/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), + path('l2vpn/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), + path('l2vpn/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), + path('l2vpn//', views.L2VPNView.as_view(), name='l2vpn'), + path('l2vpn//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), + path('l2vpn//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), + path('l2vpn//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), + path('l2vpn//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + + path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), + path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), + path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), + path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), + path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), + path('l2vpn-termination//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), + path('l2vpn-termination//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), + path('l2vpn-termination//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6682fc920..77539434c 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -17,6 +17,7 @@ from . import filtersets, forms, tables from .constants import * from .models import * from .models import ASN +from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans @@ -1140,6 +1141,101 @@ class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable + + +# L2VPN + + +class L2VPNListView(generic.ObjectListView): + queryset = L2VPN.objects.all() + table = L2VPNTable + filterset = filtersets.L2VPNFilterSet + filterset_form = forms.L2VPNFilterForm + + +class L2VPNView(generic.ObjectView): + queryset = L2VPN.objects.all() + + def get_extra_context(self, request, instance): + terminations = L2VPNTermination.objects.restrict(request.user, 'view').filter(l2vpn=instance) + terminations_table = tables.L2VPNTerminationTable(terminations, user=request.user, exclude=('l2vpn', )) + terminations_table.configure(request) + + import_targets_table = tables.RouteTargetTable( + instance.import_targets.prefetch_related('tenant'), + orderable=False + ) + export_targets_table = tables.RouteTargetTable( + instance.export_targets.prefetch_related('tenant'), + orderable=False + ) + + return { + 'terminations_table': terminations_table, + 'import_targets_table': import_targets_table, + 'export_targets_table': export_targets_table, + } + + +class L2VPNEditView(generic.ObjectEditView): + queryset = L2VPN.objects.all() + form = forms.L2VPNForm + + +class L2VPNDeleteView(generic.ObjectDeleteView): + queryset = L2VPN.objects.all() + + +class L2VPNBulkImportView(generic.BulkImportView): + queryset = L2VPN.objects.all() + model_form = forms.L2VPNCSVForm + table = tables.L2VPNTable + + +class L2VPNBulkEditView(generic.BulkEditView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + form = forms.L2VPNBulkEditForm + + +class L2VPNBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + + +class L2VPNTerminationListView(generic.ObjectListView): + queryset = L2VPNTermination.objects.all() + table = L2VPNTerminationTable + filterset = filtersets.L2VPNTerminationFilterSet + filterset_form = forms.L2VPNTerminationFilterForm + + +class L2VPNTerminationView(generic.ObjectView): + queryset = L2VPNTermination.objects.all() + + +class L2VPNTerminationEditView(generic.ObjectEditView): + queryset = L2VPNTermination.objects.all() + form = forms.L2VPNTerminationForm + template_name = 'ipam/l2vpntermination_edit.html' + + +class L2VPNTerminationDeleteView(generic.ObjectDeleteView): + queryset = L2VPNTermination.objects.all() + + +class L2VPNTerminationBulkImportView(generic.BulkImportView): + queryset = L2VPNTermination.objects.all() + model_form = forms.L2VPNTerminationCSVForm + table = tables.L2VPNTerminationTable + + +class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable form = forms.ServiceBulkEditForm diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 9a55c263e..f2245f68b 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -260,6 +260,13 @@ IPAM_MENU = Menu( get_model_item('ipam', 'vlangroup', 'VLAN Groups'), ), ), + MenuGroup( + label='L2VPNs', + items=( + get_model_item('ipam', 'l2vpn', 'L2VPN'), + get_model_item('ipam', 'l2vpntermination', 'Terminations'), + ), + ), MenuGroup( label='Other', items=( diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html new file mode 100644 index 000000000..59cc6234b --- /dev/null +++ b/netbox/templates/ipam/l2vpn.html @@ -0,0 +1,111 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
+ L2VPN Attributes +
+
+ Name + + + + + + + + + + + + + + + + + + + + + + +
{{ object.name|placeholder }}
Slug{{ object.slug|placeholder }}
Identifier{{ object.identifier|placeholder }}
Type{{ object.get_type_display }}
Description{{ object.description|placeholder }}
Tenant{{ object.tenant|placeholder }}
+
+
+ {% include 'inc/panels/contacts.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %} +
+
+ {% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %} +
+
+
+
+
+
L2VPN Terminations
+
+ {% with terminations=object.terminations.all %} + {% if terminations.exists %} + + + + + + + {% for termination in terminations %} + + + + + + {% endfor %} +
Termination TypeTermination
{{ termination.assigned_object|meta:"verbose_name" }}{{ termination.assigned_object|linkify }} + {% if perms.ipam.change_l2vpntermination %} + + + + {% endif %} + {% if perms.ipam.delete_l2vpntermination %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.ipam.add_l2vpntermination %} + + {% endif %} +
+
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination.html b/netbox/templates/ipam/l2vpntermination.html new file mode 100644 index 000000000..22e0cc324 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination.html @@ -0,0 +1,31 @@ +{% extends 'generic/object.html' %} +{% load helpers %} + +{% block content %} +
+
+
+
+ L2VPN Attributes +
+
+ + + + + + + + + +
L2vPN{{ object.l2vpn.name|placeholder }}
Assigned Object{{ object.assigned_object.name|placeholder }}
+
+
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %} +
+
+ +{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html new file mode 100644 index 000000000..3fb0460b5 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -0,0 +1,39 @@ +{% extends 'generic/object_edit.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block form %} +
+
+
L2VPN Termination
+
+ {% render_field form.l2vpn %} +
+
+ +
+
+
+
+ {% render_field form.device %} +
+ {% render_field form.vlan %} +
+
+ {% render_field form.interface %} +
+
+
+
+{% endblock %} From ccb7e96d8a294c45bf8413c4bba167e5835d8a64 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Jun 2022 16:22:38 -0400 Subject: [PATCH 15/50] Changelog for #8854, #9403, #9540 --- docs/release-notes/version-3.2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 719ceb7e1..57d965538 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,9 +6,12 @@ * [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes * [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID +* [#9403](https://github.com/netbox-community/netbox/issues/9403) - Enable modifying virtual chassis properties when creating/editing a device +* [#9540](https://github.com/netbox-community/netbox/issues/9540) - Add filters for assigned device & VM to IP addresses list ### Bug Fixes +* [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends * [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned * [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer From 8e200a9cb485df698fe8ac30f13025fcab0f3281 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Jun 2022 16:24:56 -0400 Subject: [PATCH 16/50] #9403: Add labels to device VC fields --- netbox/dcim/forms/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2c905cc5c..043af751d 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -527,10 +527,12 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) vc_position = forms.IntegerField( required=False, + label='Position', help_text="The position in the virtual chassis this device is identified by" ) vc_priority = forms.IntegerField( required=False, + label='Priority', help_text="The priority of the device in the virtual chassis" ) From c5770392e32aeeaed9bd8dcf907a11c7df352b6c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Jun 2022 18:14:57 -0400 Subject: [PATCH 17/50] Refactor ObjectChildrenView --- netbox/netbox/views/generic/bulk_views.py | 26 ++++---------- netbox/netbox/views/generic/mixins.py | 22 ++++++++++++ netbox/netbox/views/generic/object_views.py | 39 +++++++++++++-------- 3 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 netbox/netbox/views/generic/mixins.py diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 96efc0de7..7267e73ed 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -24,6 +24,7 @@ from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.views import GetReturnURLMixin from .base import BaseMultiObjectView +from .mixins import TableMixin __all__ = ( 'BulkComponentCreateView', @@ -36,9 +37,9 @@ __all__ = ( ) -class ObjectListView(BaseMultiObjectView): +class ObjectListView(BaseMultiObjectView, TableMixin): """ - Display multiple objects, all of the same type, as a table. + Display multiple objects, all the same type, as a table. Attributes: filterset: A django-filter FilterSet that is applied to the queryset @@ -61,20 +62,6 @@ class ObjectListView(BaseMultiObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') - def get_table(self, request, bulk_actions=True): - """ - Return the django-tables2 Table instance to be used for rendering the objects list. - - Args: - request: The current request - bulk_actions: Show checkboxes for object selection - """ - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and bulk_actions: - table.columns.show('pk') - - return table - # # Export methods # @@ -159,7 +146,7 @@ class ObjectListView(BaseMultiObjectView): # Export the current table view if request.GET['export'] == 'table': - table = self.get_table(request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_bulk_actions) columns = [name for name, _ in table.selected_columns] return self.export_table(table, columns) @@ -177,12 +164,11 @@ class ObjectListView(BaseMultiObjectView): # Fall back to default table/YAML export else: - table = self.get_table(request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_bulk_actions) return self.export_table(table) # Render the objects table - table = self.get_table(request, has_bulk_actions) - table.configure(request) + table = self.get_table(self.queryset, request, has_bulk_actions) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py new file mode 100644 index 000000000..0adf3a4c4 --- /dev/null +++ b/netbox/netbox/views/generic/mixins.py @@ -0,0 +1,22 @@ +__all__ = ( + 'TableMixin', +) + + +class TableMixin: + + def get_table(self, data, request, bulk_actions=True): + """ + Return the django-tables2 Table instance to be used for rendering the objects list. + + Args: + data: Queryset or iterable containing table data + request: The current request + bulk_actions: Render checkboxes for object selection + """ + table = self.table(data, user=request.user) + if 'pk' in table.base_columns and bulk_actions: + table.columns.show('pk') + table.configure(request) + + return table diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 88abfa48f..f9d8b6ac9 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -1,4 +1,5 @@ import logging +from collections import defaultdict from copy import deepcopy from django.contrib import messages @@ -20,6 +21,7 @@ from utilities.permissions import get_permission_for_model from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin from .base import BaseObjectView +from .mixins import TableMixin __all__ = ( 'ComponentCreateView', @@ -69,23 +71,31 @@ class ObjectView(BaseObjectView): }) -class ObjectChildrenView(ObjectView): +class ObjectChildrenView(ObjectView, TableMixin): """ Display a table of child objects associated with the parent object. Attributes: - table: Table class used to render child objects list + child_model: The model class which represents the child objects + table: The django-tables2 Table class used to render the child objects list + filterset: A django-filter FilterSet that is applied to the queryset """ child_model = None table = None filterset = None + actions = ('bulk_edit', 'bulk_delete') + action_perms = defaultdict(set, **{ + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + }) def get_children(self, request, parent): """ Return a QuerySet of child objects. - request: The current request - parent: The parent object + Args: + request: The current request + parent: The parent object """ raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()') @@ -114,16 +124,16 @@ class ObjectChildrenView(ObjectView): if self.filterset: child_objects = self.filterset(request.GET, child_objects).qs - permissions = {} - for action in ('change', 'delete'): - perm_name = get_permission_for_model(self.child_model, action) - permissions[action] = request.user.has_perm(perm_name) + # Determine the available actions + actions = [] + for action in self.actions: + if request.user.has_perms([ + get_permission_for_model(self.child_model, name) for name in self.action_perms[action] + ]): + actions.append(action) - table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user) - # Determine whether to display bulk action checkboxes - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') - table.configure(request) + table_data = self.prep_table_data(request, child_objects, instance) + table = self.get_table(table_data, request, bool(actions)) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): @@ -134,8 +144,9 @@ class ObjectChildrenView(ObjectView): return render(request, self.get_template_name(), { 'object': instance, + 'child_model': self.child_model, 'table': table, - 'permissions': permissions, + 'actions': actions, **self.get_extra_context(request, instance), }) From cdcb77dea8b143c93369d5a34ded0fe128c01ea1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Jun 2022 14:06:01 -0400 Subject: [PATCH 18/50] Move actions determination to a mixin --- netbox/netbox/views/generic/bulk_views.py | 24 +++++--------------- netbox/netbox/views/generic/mixins.py | 25 +++++++++++++++++++++ netbox/netbox/views/generic/object_views.py | 20 +++++------------ 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 7267e73ed..bb1c2b8e3 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -24,7 +24,7 @@ from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.views import GetReturnURLMixin from .base import BaseMultiObjectView -from .mixins import TableMixin +from .mixins import ActionsMixin, TableMixin __all__ = ( 'BulkComponentCreateView', @@ -37,7 +37,7 @@ __all__ = ( ) -class ObjectListView(BaseMultiObjectView, TableMixin): +class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): """ Display multiple objects, all the same type, as a table. @@ -51,13 +51,6 @@ class ObjectListView(BaseMultiObjectView, TableMixin): template_name = 'generic/object_list.html' filterset = None filterset_form = None - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete') - action_perms = defaultdict(set, **{ - 'add': {'add'}, - 'import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - }) def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -134,12 +127,7 @@ class ObjectListView(BaseMultiObjectView, TableMixin): self.queryset = self.filterset(request.GET, self.queryset).qs # Determine the available actions - actions = [] - for action in self.actions: - if request.user.has_perms([ - get_permission_for_model(model, name) for name in self.action_perms[action] - ]): - actions.append(action) + actions = self.get_permitted_actions(request.user) has_bulk_actions = any([a.startswith('bulk_') for a in actions]) if 'export' in request.GET: @@ -176,15 +164,13 @@ class ObjectListView(BaseMultiObjectView, TableMixin): 'table': table, }) - context = { + return render(request, self.template_name, { 'model': model, 'table': table, 'actions': actions, 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, **self.get_extra_context(request), - } - - return render(request, self.template_name, context) + }) class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 0adf3a4c4..4b3fa0740 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -1,8 +1,33 @@ +from collections import defaultdict + +from utilities.permissions import get_permission_for_model + __all__ = ( 'TableMixin', ) +class ActionsMixin: + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + }) + + def get_permitted_actions(self, user, model=None): + """ + Return a tuple of actions for which the given user is permitted to do. + """ + model = model or self.queryset.model + return [ + action for action in self.actions if user.has_perms([ + get_permission_for_model(model, name) for name in self.action_perms[action] + ]) + ] + + class TableMixin: def get_table(self, data, request, bulk_actions=True): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index f9d8b6ac9..82867b429 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -1,5 +1,4 @@ import logging -from collections import defaultdict from copy import deepcopy from django.contrib import messages @@ -21,7 +20,7 @@ from utilities.permissions import get_permission_for_model from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin from .base import BaseObjectView -from .mixins import TableMixin +from .mixins import ActionsMixin, TableMixin __all__ = ( 'ComponentCreateView', @@ -71,7 +70,7 @@ class ObjectView(BaseObjectView): }) -class ObjectChildrenView(ObjectView, TableMixin): +class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): """ Display a table of child objects associated with the parent object. @@ -79,15 +78,13 @@ class ObjectChildrenView(ObjectView, TableMixin): child_model: The model class which represents the child objects table: The django-tables2 Table class used to render the child objects list filterset: A django-filter FilterSet that is applied to the queryset + actions: Supported actions for the model. When adding custom actions, bulk action names must + be prefixed with `bulk_`. Default actions: add, import, export, bulk_edit, bulk_delete + action_perms: A dictionary mapping supported actions to a set of permissions required for each """ child_model = None table = None filterset = None - actions = ('bulk_edit', 'bulk_delete') - action_perms = defaultdict(set, **{ - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - }) def get_children(self, request, parent): """ @@ -125,12 +122,7 @@ class ObjectChildrenView(ObjectView, TableMixin): child_objects = self.filterset(request.GET, child_objects).qs # Determine the available actions - actions = [] - for action in self.actions: - if request.user.has_perms([ - get_permission_for_model(self.child_model, name) for name in self.action_perms[action] - ]): - actions.append(action) + actions = self.get_permitted_actions(request.user, model=self.child_model) table_data = self.prep_table_data(request, child_objects, instance) table = self.get_table(table_data, request, bool(actions)) From 4649bc632cddb530ca91dbab763de96602a3829c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Jun 2022 14:21:57 -0400 Subject: [PATCH 19/50] Update templates for subclasses of ObjectChildrenView --- netbox/templates/dcim/device/consoleports.html | 4 ++-- netbox/templates/dcim/device/consoleserverports.html | 4 ++-- netbox/templates/dcim/device/devicebays.html | 4 ++-- netbox/templates/dcim/device/frontports.html | 4 ++-- netbox/templates/dcim/device/interfaces.html | 4 ++-- netbox/templates/dcim/device/inventory.html | 4 ++-- netbox/templates/dcim/device/modulebays.html | 4 ++-- netbox/templates/dcim/device/poweroutlets.html | 4 ++-- netbox/templates/dcim/device/powerports.html | 4 ++-- netbox/templates/dcim/device/rearports.html | 4 ++-- netbox/templates/ipam/aggregate/prefixes.html | 4 ++-- netbox/templates/ipam/iprange/ip_addresses.html | 4 ++-- netbox/templates/ipam/prefix/ip_addresses.html | 4 ++-- netbox/templates/ipam/prefix/ip_ranges.html | 4 ++-- netbox/templates/ipam/prefix/prefixes.html | 4 ++-- netbox/templates/virtualization/cluster/virtual_machines.html | 4 ++-- 16 files changed, 32 insertions(+), 32 deletions(-) diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index afc306bd4..04184be7c 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_consoleport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_consoleport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 5f244cdc7..ee1be91d7 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_consoleserverport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_consoleserverport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 5e33bdae0..7836935d9 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_devicebay %} + {% if 'bulk_edit' in actions %} @@ -25,7 +25,7 @@ Edit {% endif %} - {% if perms.dcim.delete_devicebay %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 0d0f9577c..8590fd50e 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_frontport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_frontport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 22f6d8be5..7db7ea0ae 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -53,7 +53,7 @@
- {% if perms.dcim.change_interface %} + {% if 'bulk_edit' in actions %} @@ -64,7 +64,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_interface %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 18a0712f3..de981c545 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_inventoryitem %} + {% if 'bulk_edit' in actions %} @@ -25,7 +25,7 @@ Edit {% endif %} - {% if perms.dcim.delete_inventoryitem %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html index fc1c9a60d..3e4dadb30 100644 --- a/netbox/templates/dcim/device/modulebays.html +++ b/netbox/templates/dcim/device/modulebays.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_modulebay %} + {% if 'bulk_edit' in actions %} @@ -25,7 +25,7 @@ Edit {% endif %} - {% if perms.dcim.delete_modulebay %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index d312fbbd0..f9880a4b1 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_poweroutlet %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index cf71e81ba..fc426a023 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_powerport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index 73341990f..eee67b6fd 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_rearport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_rearport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index d1b48429a..8256236f4 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -25,12 +25,12 @@
- {% if perms.ipam.change_prefix %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_prefix %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index d9ac77fd0..61b2ee335 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -23,12 +23,12 @@
- {% if perms.ipam.change_ipaddress %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_ipaddress %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index d734b825f..31a22497d 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -23,12 +23,12 @@
- {% if perms.ipam.change_ipaddress %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_ipaddress %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index 268c290a1..45b1d4fd0 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -23,12 +23,12 @@
- {% if perms.ipam.change_iprange %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_iprange %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 5d42596ba..46fa29581 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -25,12 +25,12 @@
- {% if perms.ipam.change_prefix %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_prefix %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html index 953d9f940..9cb33258f 100644 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -14,12 +14,12 @@
- {% if perms.virtualization.change_virtualmachine %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.virtualization.delete_virtualmachine %} + {% if 'bulk_delete' in actions %} From a0f9b5e47b29b5e04ba921741f4d24e588caf36f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Jun 2022 14:30:47 -0400 Subject: [PATCH 20/50] Document support for ObjectChildrenView --- docs/plugins/development/views.md | 25 ++++++++++++++++--------- docs/release-notes/version-3.3.md | 1 + 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 92626f8d3..cabcd7045 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -51,15 +51,16 @@ This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Rem NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. -| View Class | Description | -|--------------------|--------------------------------| -| `ObjectView` | View a single object | -| `ObjectEditView` | Create or edit a single object | -| `ObjectDeleteView` | Delete a single object | -| `ObjectListView` | View a list of objects | -| `BulkImportView` | Import a set of new objects | -| `BulkEditView` | Edit multiple objects | -| `BulkDeleteView` | Delete multiple objects | +| View Class | Description | +|----------------------|--------------------------------------------------------| +| `ObjectView` | View a single object | +| `ObjectEditView` | Create or edit a single object | +| `ObjectDeleteView` | Delete a single object | +| `ObjectChildrenView` | A list of child objects within the context of a parent | +| `ObjectListView` | View a list of objects | +| `BulkImportView` | Import a set of new objects | +| `BulkEditView` | Edit multiple objects | +| `BulkDeleteView` | Delete multiple objects | !!! warning Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. @@ -99,6 +100,12 @@ Below are the class definitions for NetBox's object views. These views handle CR members: - get_object +::: netbox.views.generic.ObjectChildrenView + selection: + members: + - get_children + - prep_table_data + ## Multi-Object Views Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1e18de1e6..efcf570fa 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -34,6 +34,7 @@ ### Plugins API +* [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes ### Other Changes From 779969f150b47251592a4619a2b038cbefd1c8f5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Jun 2022 16:36:10 -0400 Subject: [PATCH 21/50] Closes #9070: Hide navigation menu items based on user permissions --- docs/release-notes/version-3.3.md | 1 + .../utilities/templates/navigation/menu.html | 91 ++++++++----------- netbox/utilities/templatetags/navigation.py | 21 ++++- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index efcf570fa..62644f55f 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -27,6 +27,7 @@ * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields * [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times diff --git a/netbox/utilities/templates/navigation/menu.html b/netbox/utilities/templates/navigation/menu.html index dfc85968a..33a476081 100644 --- a/netbox/utilities/templates/navigation/menu.html +++ b/netbox/utilities/templates/navigation/menu.html @@ -1,58 +1,43 @@ {% load helpers %} +
+ + {% endfor %} diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index ede8792fa..ef0657446 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -13,7 +13,26 @@ def nav(context: Context) -> Dict: """ Render the navigation menu. """ + user = context['request'].user + nav_items = [] + + # Construct the navigation menu based upon the current user's permissions + for menu in MENUS: + groups = [] + for group in menu.groups: + items = [] + for item in group.items: + if user.has_perms(item.permissions): + buttons = [ + button for button in item.buttons if user.has_perms(button.permissions) + ] + items.append((item, buttons)) + if items: + groups.append((group, items)) + if groups: + nav_items.append((menu, groups)) + return { - "nav_items": MENUS, + "nav_items": nav_items, "request": context["request"] } From 3be9f6c4f3a74ab74f35f6cdc4de77593583c409 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 16:01:20 -0500 Subject: [PATCH 22/50] #8157 - Final work on L2VPN model --- netbox/dcim/api/serializers.py | 4 +- netbox/dcim/models/device_components.py | 7 +- netbox/ipam/api/nested_serializers.py | 8 +- netbox/ipam/api/serializers.py | 5 +- netbox/ipam/api/views.py | 2 +- netbox/ipam/filtersets.py | 51 ++++++++- netbox/ipam/forms/bulk_edit.py | 5 + netbox/ipam/graphql/schema.py | 6 ++ netbox/ipam/graphql/types.py | 16 +++ netbox/ipam/models/vlans.py | 7 +- netbox/ipam/tests/test_api.py | 38 +++---- netbox/ipam/tests/test_filtersets.py | 62 +++++------ netbox/ipam/tests/test_models.py | 81 ++++++++++++-- netbox/ipam/tests/test_views.py | 138 ++++++++++++++++++++++-- netbox/ipam/urls.py | 1 + netbox/ipam/views.py | 21 ++-- netbox/templates/dcim/interface.html | 4 + netbox/templates/ipam/vlan.html | 4 + 18 files changed, 376 insertions(+), 84 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8ac2aa738..32709000b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -10,6 +10,7 @@ from dcim.constants import * from dcim.models import * from ipam.api.nested_serializers import ( NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, + NestedL2VPNTerminationSerializer, ) from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField @@ -823,6 +824,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( @@ -841,7 +843,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', - 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'vrf', 'l2vpn_termination', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4d19a2d8d..70c21c165 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -649,10 +649,11 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo object_id_field='interface_id', related_query_name='+' ) - l2vpn = GenericRelation( + l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_query_name='interface', ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] @@ -828,6 +829,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo def link(self): return self.cable or self.wireless_link + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() + # # Pass-through ports diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 8316cb992..39305a017 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -11,6 +11,8 @@ __all__ = [ 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', + 'NestedL2VPNSerializer', + 'NestedL2VPNTerminationSerializer', 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', @@ -203,17 +205,17 @@ class NestedL2VPNSerializer(WritableNestedSerializer): class Meta: model = L2VPN fields = [ - 'id', 'url', 'display', 'name', 'type' + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type' ] class NestedL2VPNTerminationSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn_termination-detail') + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') l2vpn = NestedL2VPNSerializer() class Meta: model = L2VPNTermination fields = [ - 'id', 'url', 'display', 'l2vpn', 'assigned_object' + 'id', 'url', 'display', 'l2vpn' ] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a51043e27..36102f853 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -207,13 +207,14 @@ class VLANSerializer(NetBoxModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VLAN fields = [ - 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'prefix_count', + 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', + 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 36a6f02b6..f5a61c031 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -165,7 +165,7 @@ class L2VPNViewSet(NetBoxModelViewSet): class L2VPNTerminationViewSet(NetBoxModelViewSet): - queryset = L2VPNTermination.objects + queryset = L2VPNTermination.objects.prefetch_related('assigned_object') serializer_class = serializers.L2VPNTerminationSerializer filterset_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 03189a7cb..f682009ee 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -957,7 +957,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = L2VPN - fields = ['identifier', 'name', 'type', 'description'] + fields = ['id', 'identifier', 'name', 'type', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -977,13 +977,60 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): to_field_name='name', label='L2VPN (name)', ) + device = MultiValueCharFilter( + method='filter_device', + field_name='name', + label='Device (name)', + ) + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label='Device (ID)', + ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (name)', + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label='Interface (ID)', + ) + vlan = django_filters.ModelMultipleChoiceFilter( + field_name='vlan__name', + queryset=VLAN.objects.all(), + to_field_name='name', + label='VLAN (name)', + ) + vlan_vid = django_filters.NumberFilter( + field_name='vlan__vid', + label='VLAN number (1-4094)', + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + field_name='vlan', + queryset=VLAN.objects.all(), + label='VLAN (ID)', + ) class Meta: model = L2VPNTermination - fields = ['l2vpn'] + fields = ['id', ] def search(self, queryset, name, value): if not value.strip(): return queryset qs_filter = Q(l2vpn__name__icontains=value) return queryset.filter(qs_filter) + + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): + return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return queryset.filter( + interface__in=interface_ids + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index bbfa5bf9f..50fc51522 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -19,6 +19,7 @@ __all__ = ( 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', 'L2VPNBulkEditForm', + 'L2VPNTerminationBulkEditForm', 'PrefixBulkEditForm', 'RIRBulkEditForm', 'RoleBulkEditForm', @@ -458,3 +459,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): (None, ('tenant', 'description')), ) nullable_fields = ('tenant', 'description',) + + +class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): + model = L2VPN diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index f466c1857..5cd5e030e 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -17,6 +17,12 @@ class IPAMQuery(graphene.ObjectType): ip_range = ObjectField(IPRangeType) ip_range_list = ObjectListField(IPRangeType) + l2vpn = ObjectField(L2VPNType) + l2vpn_list = ObjectListField(L2VPNType) + + l2vpn_termination = ObjectField(L2VPNTerminationType) + l2vpn_termination_list = ObjectListField(L2VPNTerminationType) + prefix = ObjectField(PrefixType) prefix_list = ObjectListField(PrefixType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index ca206b4b8..5af2ca72a 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -11,6 +11,8 @@ __all__ = ( 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', + 'L2VPNType', + 'L2VPNTerminationType', 'PrefixType', 'RIRType', 'RoleType', @@ -151,3 +153,17 @@ class VRFType(NetBoxObjectType): model = models.VRF fields = '__all__' filterset_class = filtersets.VRFFilterSet + + +class L2VPNType(NetBoxObjectType): + class Meta: + model = models.L2VPN + fields = '__all__' + filtersets_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationType(NetBoxObjectType): + class Meta: + model = models.L2VPNTermination + fields = '__all__' + filtersets_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 3a7969405..f0e062721 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -174,10 +174,11 @@ class VLAN(NetBoxModel): blank=True ) - l2vpn = GenericRelation( + l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_query_name='vlan' ) objects = VLANQuerySet.as_manager() @@ -234,3 +235,7 @@ class VLAN(NetBoxModel): Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk) ).distinct() + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 0e93bd43e..a5ebef2c7 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -947,28 +947,28 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): l2vpns = ( - L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD ) L2VPN.objects.bulk_create(l2vpns) class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): model = L2VPNTermination - brief_fields = ['display', 'id', 'l2vpn', 'assigned_object', 'assigned_object_id', 'assigned_object_type', 'url'] + brief_fields = ['display', 'id', 'l2vpn', 'url'] @classmethod def setUpTestData(cls): vlans = ( - VLAN(name='VLAN 1', vid=650001), - VLAN(name='VLAN 2', vid=650002), - VLAN(name='VLAN 3', vid=650003), - VLAN(name='VLAN 4', vid=650004), - VLAN(name='VLAN 5', vid=650005), - VLAN(name='VLAN 6', vid=650006), - VLAN(name='VLAN 7', vid=650007) + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) ) VLAN.objects.bulk_create(vlans) @@ -986,24 +986,26 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + cls.create_data = [ { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[3], + 'assigned_object_id': vlans[3].pk, }, { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[4], + 'assigned_object_id': vlans[4].pk, }, { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[5], + 'assigned_object_id': vlans[5].pk, }, ] cls.bulk_update_data = { - 'l2vpn': l2vpns[2] + 'l2vpn': l2vpns[2].pk } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c5cffc7dc..2b5fb0759 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1465,8 +1465,7 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): - # TODO: L2VPN Tests +class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPN.objects.all() filterset = L2VPNFilterSet @@ -1480,20 +1479,8 @@ class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): ) L2VPN.objects.bulk_create(l2vpns) - def test_created(self): - from datetime import date, date - pk_list = self.queryset.values_list('pk', flat=True)[:2] - print(pk_list) - self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'created': '2021-01-01T00:00:00'} - fs = self.filterset({}, self.queryset).qs.all() - for res in fs: - print(f'{res.name}:{res.created}') - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - -class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): - # TODO: L2VPN Termination Tests +class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPNTermination.objects.all() filterset = L2VPNTerminationFilterSet @@ -1511,22 +1498,24 @@ class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): device_role=device_role, status='active' ) - interfaces = Interface.objects.bulk_create( - Interface(name='GigabitEthernet1/0/1', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/2', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/3', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/4', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/5', device=device, type='1000baset'), + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + Interface(name='Interface 6', device=device, type='1000baset') ) + Interface.objects.bulk_create(interfaces) + vlans = ( - VLAN(name='VLAN 1', vid=650001), - VLAN(name='VLAN 2', vid=650002), - VLAN(name='VLAN 3', vid=650003), - VLAN(name='VLAN 4', vid=650004), - VLAN(name='VLAN 5', vid=650005), - VLAN(name='VLAN 6', vid=650006), - VLAN(name='VLAN 7', vid=650007) + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655) ) VLAN.objects.bulk_create(vlans) @@ -1534,26 +1523,33 @@ class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): l2vpns = ( L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 3', type='vpls'), # No RD, ) L2VPN.objects.bulk_create(l2vpns) l2vpnterminations = ( L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]), ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + def test_l2vpns(self): l2vpns = L2VPN.objects.all()[:2] params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_interfaces(self): interfaces = Interface.objects.all()[:2] params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + qs = self.filterset(params, self.queryset).qs + results = qs.all() self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'interface': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index ce4643516..1b5fbadc3 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -2,8 +2,9 @@ from netaddr import IPNetwork, IPSet from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination class TestAggregate(TestCase): @@ -540,11 +541,75 @@ class TestVLANGroup(TestCase): self.assertEqual(vlangroup.get_next_available_vid(), 105) -class TestL2VPN(TestCase): - # TODO: L2VPN Tests - pass - - class TestL2VPNTermination(TestCase): - # TODO: L2VPN Termination Tests - pass + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + ) + + Interface.objects.bulk_create(interfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + def test_duplicate_interface_terminations(self): + device = Device.objects.first() + interface = Interface.objects.filter(device=device).first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface) + + self.assertRaises(ValidationError, duplicate.clean) + + def test_duplicate_vlan_terminations(self): + vlan = Interface.objects.first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) + self.assertRaises(ValidationError, duplicate.clean) + diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8d1b9bd1b..dd3733d4d 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,14 +1,18 @@ import datetime +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface +from extras.choices import ObjectChangeActionChoices +from extras.models import ObjectChange from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from utilities.testing import ViewTestCases, create_tags +from users.models import ObjectPermission +from utilities.testing import ViewTestCases, create_tags, post_data class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -749,10 +753,130 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): - # TODO: L2VPN Tests - pass + model = L2VPN + csv_data = ( + 'name,slug,type,identifier', + 'L2VPN 5,l2vpn-5,vxlan,456', + 'L2VPN 6,l2vpn-6,vxlan,444', + ) + bulk_edit_data = { + 'description': 'New Description', + } + + @classmethod + def setUpTestData(cls): + rts = ( + RouteTarget(name='64534:123'), + RouteTarget(name='64534:321') + ) + RouteTarget.objects.bulk_create(rts) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier='650001'), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vxlan', identifier='650002'), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vxlan', identifier='650003') + ) + + L2VPN.objects.bulk_create(l2vpns) + + cls.form_data = { + 'name': 'L2VPN 8', + 'slug': 'l2vpn-8', + 'type': 'vxlan', + 'identifier': 123, + 'description': 'Description', + 'import_targets': [rts[0].pk], + 'export_targets': [rts[1].pk] + } + + print(cls.form_data) -class L2VPNTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): - # TODO: L2VPN Termination Tests - pass +class L2VPNTerminationTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + + model = L2VPNTermination + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + + interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') + l2vpn = L2VPN.objects.create(name='L2VPN 1', type='vxlan', identifier=650001) + l2vpn_vlans = L2VPN.objects.create(name='L2VPN 2', type='vxlan', identifier=650002) + + vlans = ( + VLAN(name='Vlan 1', vid=1001), + VLAN(name='Vlan 2', vid=1002), + VLAN(name='Vlan 3', vid=1003), + VLAN(name='Vlan 4', vid=1004), + VLAN(name='Vlan 5', vid=1005), + VLAN(name='Vlan 6', vid=1006) + ) + VLAN.objects.bulk_create(vlans) + + terminations = ( + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2]) + ) + L2VPNTermination.objects.bulk_create(terminations) + + cls.form_data = { + 'l2vpn': l2vpn.pk, + 'device': device.pk, + 'interface': interface.pk, + } + + cls.csv_data = ( + "l2vpn,vlan", + "L2VPN 2,Vlan 4", + "L2VPN 2,Vlan 5", + "L2VPN 2,Vlan 6", + ) + + cls.bulk_edit_data = {} + + # + # Custom assertions + # + + def assertInstanceEqual(self, instance, data, exclude=None, api=False): + """ + Override parent + """ + if exclude is None: + exclude = [] + + fields = [k for k in data.keys() if k not in exclude] + model_dict = self.model_to_dict(instance, fields=fields, api=api) + + # Omit any dictionary keys which are not instance attributes or have been excluded + relevant_data = { + k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude + } + + # Handle relations on the model + for k, v in model_dict.items(): + if isinstance(v, object) and hasattr(v, 'first'): + model_dict[k] = v.first().pk + + self.assertDictEqual(model_dict, relevant_data) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 65a6b55ad..e00b0365f 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -201,6 +201,7 @@ urlpatterns = [ path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-termination/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 77539434c..35103be48 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1141,6 +1141,13 @@ class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable + form = forms.ServiceBulkEditForm + + +class ServiceBulkDeleteView(generic.BulkDeleteView): + queryset = Service.objects.prefetch_related('device', 'virtual_machine') + filterset = filtersets.ServiceFilterSet + table = tables.ServiceTable # L2VPN @@ -1232,14 +1239,14 @@ class L2VPNTerminationBulkImportView(generic.BulkImportView): table = tables.L2VPNTerminationTable +class L2VPNTerminationBulkEditView(generic.BulkEditView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable + form = forms.L2VPNTerminationBulkEditForm + + class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): queryset = L2VPNTermination.objects.all() filterset = filtersets.L2VPNTerminationFilterSet table = tables.L2VPNTerminationTable - form = forms.ServiceBulkEditForm - - -class ServiceBulkDeleteView(generic.BulkDeleteView): - queryset = Service.objects.prefetch_related('device', 'virtual_machine') - filterset = filtersets.ServiceFilterSet - table = tables.ServiceTable diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index e98750518..247592e14 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -104,6 +104,10 @@ LAG {{ object.lag|linkify|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} +
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index fd0ba36a3..53bb75b8f 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -64,6 +64,10 @@ Description {{ object.description|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} +
From 6e983d154264b6af6586db01ef93586a7276261f Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 16:14:30 -0500 Subject: [PATCH 23/50] Fix up some PEP errors --- netbox/ipam/api/nested_serializers.py | 1 - netbox/ipam/choices.py | 31 +++++++++++++-------------- netbox/ipam/tests/test_models.py | 1 - 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 39305a017..07a7c9598 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -218,4 +218,3 @@ class NestedL2VPNTerminationSerializer(WritableNestedSerializer): fields = [ 'id', 'url', 'display', 'l2vpn' ] - diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index a867b05bc..72cd4ff73 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -192,26 +192,25 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_VPLS, 'VPLS'), )), ('E-Line', ( - (TYPE_EPL, 'EPL'), - (TYPE_EVPL, 'EVPL'), - )), + (TYPE_EPL, 'EPL'), + (TYPE_EVPL, 'EVPL'), + )), ('E-LAN', ( - (TYPE_EPLAN, 'Ethernet Private LAN'), - (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), - )), + (TYPE_EPLAN, 'Ethernet Private LAN'), + (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), + )), ('E-Tree', ( - (TYPE_EPTREE, 'Ethernet Private Tree'), - (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), - )), + (TYPE_EPTREE, 'Ethernet Private Tree'), + (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), + )), ('VXLAN', ( - (TYPE_VXLAN, 'VXLAN'), - (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), - )), + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), ('L2VPN E-VPN', ( - (TYPE_MPLS_EVPN, 'MPLS EVPN'), - (TYPE_PBB_EVPN, 'PBB EVPN'), - )) - + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )) ) P2P = ( diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 1b5fbadc3..3bd7e8ccb 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -612,4 +612,3 @@ class TestL2VPNTermination(TestCase): L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) self.assertRaises(ValidationError, duplicate.clean) - From dd6bfed565fc25f841f58bc3f70e339da37f69ba Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 18:37:31 -0500 Subject: [PATCH 24/50] Add migration file --- .../0059_l2vpn_l2vpntermination_and_more.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py diff --git a/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py b/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py new file mode 100644 index 000000000..a8e5ace25 --- /dev/null +++ b/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 4.0.5 on 2022-06-28 04:57 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0007_contact_link'), + ('extras', '0076_configcontext_locations'), + ('ipam', '0058_ipaddress_nat_inside_nonunique'), + ] + + operations = [ + migrations.CreateModel( + name='L2VPN', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField()), + ('type', models.CharField(max_length=50)), + ('identifier', models.BigIntegerField(blank=True, null=True, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')), + ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='l2vpns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'L2VPN', + 'ordering': ('identifier', 'name'), + }, + ), + migrations.CreateModel( + name='L2VPNTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('assigned_object_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('assigned_object_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'L2VPN Termination', + 'ordering': ('l2vpn',), + }, + ), + migrations.AddConstraint( + model_name='l2vpntermination', + constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_assigned_object'), + ), + ] From 5b397a98272c15cc2dc582abb10d629e8f753429 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 30 Jun 2022 08:29:08 -0500 Subject: [PATCH 25/50] Add docs --- docs/core-functionality/ipam.md | 5 +++++ docs/development/models.md | 2 ++ docs/models/ipam/l2vpn.md | 19 +++++++++++++++++++ docs/models/ipam/l2vpntermination.md | 12 ++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 docs/models/ipam/l2vpn.md create mode 100644 docs/models/ipam/l2vpntermination.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 01bb3c76d..c86819380 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -26,3 +26,8 @@ --- {!models/ipam/asn.md!} + +--- + +{!models/ipam/l2vpn.md!} +{!models/ipam/l2vpntermination.md!} diff --git a/docs/development/models.md b/docs/development/models.md index ae1bab7e7..b6b2e4da2 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) * [ipam.IPAddress](../models/ipam/ipaddress.md) * [ipam.IPRange](../models/ipam/iprange.md) +* [ipam.L2VPN](../models/ipam/l2vpn.md) +* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md) * [ipam.Prefix](../models/ipam/prefix.md) * [ipam.RouteTarget](../models/ipam/routetarget.md) * [ipam.Service](../models/ipam/service.md) diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md new file mode 100644 index 000000000..9c50a6407 --- /dev/null +++ b/docs/models/ipam/l2vpn.md @@ -0,0 +1,19 @@ +# L2VPN + +A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS or EPL. Each L2VPN can be identified by name as well as an optional unique identifier (VNI would be an example). + +Each L2VPN instance must have one of the following type associated with it: + +* VPLS +* VPWS +* EPL +* EVPL +* EP-LAN +* EVP-LAN +* EP-TREE +* EVP-TREE +* VXLAN +* VXLAN EVPN +* MPLS-EVPN +* PBB-EVPN + diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md new file mode 100644 index 000000000..9135f72a3 --- /dev/null +++ b/docs/models/ipam/l2vpntermination.md @@ -0,0 +1,12 @@ +# L2VPN Termination + +A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPN's may only have 2 termination points (point-to-point) while others may have many terminations (multipoint). + +Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN. + +The following types of L2VPN's are considered point-to-point: + +* VPWS +* EPL +* EP-LAN +* EP-TREE \ No newline at end of file From 65f4895dd652e31e1e9f432dc549b669e7cdb339 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Jun 2022 14:35:41 -0400 Subject: [PATCH 26/50] Add ActionsMixin to __all__ --- netbox/netbox/views/generic/mixins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 4b3fa0740..8e363f0a5 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -3,6 +3,7 @@ from collections import defaultdict from utilities.permissions import get_permission_for_model __all__ = ( + 'ActionsMixin', 'TableMixin', ) From 3a6f46bf38e2a6f8fd8076620c8199311e0c6950 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Jun 2022 15:15:07 -0400 Subject: [PATCH 27/50] Closes #9075: Introduce AbortRequest exception for cleanly interrupting object mutations --- docs/plugins/development/exceptions.md | 28 +++++++++++++ docs/release-notes/version-3.3.md | 1 + mkdocs.yml | 1 + netbox/netbox/api/viewsets/__init__.py | 9 ++++ netbox/netbox/views/generic/bulk_views.py | 46 +++++++++++---------- netbox/netbox/views/generic/object_views.py | 29 +++++++------ netbox/utilities/exceptions.py | 17 +++++++- 7 files changed, 95 insertions(+), 36 deletions(-) create mode 100644 docs/plugins/development/exceptions.md diff --git a/docs/plugins/development/exceptions.md b/docs/plugins/development/exceptions.md new file mode 100644 index 000000000..80f5db258 --- /dev/null +++ b/docs/plugins/development/exceptions.md @@ -0,0 +1,28 @@ +# Exceptions + +The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios. + +## `AbortRequest` + +NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer. + +For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model: + +```python +from django.db.models.signals import pre_save +from django.dispatch import receiver +from dcim.models import Site +from utilities.exceptions import AbortRequest + +PROHIBITED_NAMES = ('foo', 'bar', 'baz') + +@receiver(pre_save, sender=Site) +def test_abort_request(instance, **kwargs): + if instance.name.lower() in PROHIBITED_NAMES: + raise AbortRequest(f"Site name can't be {instance.name}!") +``` + +An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy. + +!!! tip "Consider custom validation rules" + This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 62644f55f..a0abb81c4 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -35,6 +35,7 @@ ### Plugins API +* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations * [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes diff --git a/mkdocs.yml b/mkdocs.yml index 507b25627..88a2794e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -118,6 +118,7 @@ nav: - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' + - Exceptions: 'plugins/development/exceptions.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 462c07c6f..2d3780bde 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet from extras.models import ExportTemplate from netbox.api.exceptions import SerializerNotFound from utilities.api import get_serializer_for_model +from utilities.exceptions import AbortRequest from .mixins import * __all__ = ( @@ -125,6 +126,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali *args, **kwargs ) + except AbortRequest as e: + logger.debug(e.message) + return self.finalize_response( + request, + Response({'detail': e.message}, status=400), + *args, + **kwargs + ) def list(self, request, *args, **kwargs): """ diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index bb1c2b8e3..82244bcd2 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -1,6 +1,5 @@ import logging import re -from collections import defaultdict from copy import deepcopy from django.contrib import messages @@ -12,11 +11,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django_tables2.export import TableExport +from django.utils.safestring import mark_safe from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import PermissionsViolation +from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, ) @@ -264,10 +264,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: pass - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) + clear_webhooks.send(sender=self) else: logger.debug("Form validation failed") @@ -392,10 +392,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): except ValidationError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object import failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -542,10 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): messages.error(self.request, ", ".join(e.messages)) clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -639,10 +637,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): messages.success(request, f"Renamed {len(selected_objects)} {model_name}") return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -717,11 +714,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): if hasattr(obj, 'snapshot'): obj.snapshot() obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete objects") handle_protectederror(queryset, request, e) return redirect(self.get_return_url(request)) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(self.get_return_url(request)) + msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}" logger.info(msg) messages.success(request, msg) @@ -829,10 +832,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not form.errors: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 82867b429..dc078a7e2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortTransaction, PermissionsViolation +from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model @@ -246,10 +246,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): except AbortTransaction: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not model_form.errors: @@ -410,10 +409,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): return redirect(return_url) - except PermissionsViolation: - msg = "Object save failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -489,11 +487,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): try: obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete object") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(obj.get_absolute_url()) + msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj) logger.info(msg) messages.success(request, msg) @@ -603,10 +607,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): else: return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) return render(request, self.template_name, { diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 4ba62bc01..657e90745 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -1,6 +1,13 @@ from rest_framework import status from rest_framework.exceptions import APIException +__all__ = ( + 'AbortRequest', + 'AbortTransaction', + 'PermissionsViolation', + 'RQWorkerNotRunningException', +) + class AbortTransaction(Exception): """ @@ -9,12 +16,20 @@ class AbortTransaction(Exception): pass +class AbortRequest(Exception): + """ + Raised to cleanly abort a request (for example, by a pre_save signal receiver). + """ + def __init__(self, message): + self.message = message + + class PermissionsViolation(Exception): """ Raised when an operation was prevented because it would violate the allowed permissions. """ - pass + message = "Operation failed due to object-level permissions violation" class RQWorkerNotRunningException(APIException): From be778353b7b97b779261f7c06aa9a3d823ff49e4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Jun 2022 17:13:26 -0400 Subject: [PATCH 28/50] #1099: Restore PoE fields on interface edit form --- netbox/templates/dcim/interface_edit.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index ddda1ae31..d6fdfd0e1 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -72,6 +72,14 @@
{% endif %} +
+
+
Power over Ethernet (PoE)
+
+ {% render_field form.poe_mode %} + {% render_field form.poe_type %} +
+
802.1Q Switching
From 29f629156a4ed2136c002480d9cf6238e64b686c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 11:36:34 -0400 Subject: [PATCH 29/50] Update NOTICE file --- NOTICE | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NOTICE b/NOTICE index e6dc6408a..4e4dd600c 100644 --- a/NOTICE +++ b/NOTICE @@ -1 +1,7 @@ Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC. + +This project contains code developed expressly for NetBox, and its reuse in +other projects may introduce issues affecting performance, data integrity, +and security. + +For more information, please see https://github.com/netbox-community/netbox. From c6dfdf10e5938e8ee7af18e31663023fb4b8390d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 11:38:39 -0400 Subject: [PATCH 30/50] Introduce qs_filter_from_constraints() for constructing object permission QS filters --- netbox/netbox/authentication.py | 22 +++++++++------------- netbox/utilities/permissions.py | 24 ++++++++++++++++++++++++ netbox/utilities/querysets.py | 27 +++++++-------------------- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 00fb3ee66..c16095fdc 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -9,7 +9,9 @@ from django.core.exceptions import ImproperlyConfigured from django.db.models import Q from users.models import ObjectPermission -from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct +from utilities.permissions import ( + permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, +) UserModel = get_user_model() @@ -99,8 +101,10 @@ class ObjectPermissionMixin: if not user_obj.is_active or user_obj.is_anonymous: return False + object_permissions = self.get_all_permissions(user_obj) + # If no applicable ObjectPermissions have been created for this user/permission, deny permission - if perm not in self.get_all_permissions(user_obj): + if perm not in object_permissions: return False # If no object has been specified, grant permission. (The presence of a permission in this set tells @@ -113,21 +117,13 @@ class ObjectPermissionMixin: if model._meta.label_lower != '.'.join((app_label, model_name)): raise ValueError(f"Invalid permission {perm} for model {model}") - # Compile a query filter that matches all instances of the specified model - obj_perm_constraints = self.get_all_permissions(user_obj)[perm] - constraints = Q() - for perm_constraints in obj_perm_constraints: - if perm_constraints: - constraints |= Q(**perm_constraints) - else: - # Found ObjectPermission with null constraints; allow model-level access - constraints = Q() - break + # Compile a QuerySet filter that matches all instances of the specified model + qs_filter = qs_filter_from_constraints(object_permissions[perm]) # Permission to perform the requested action on the object depends on whether the specified object matches # the specified constraints. Note that this check is made against the *database* record representing the object, # not the instance itself. - return model.objects.filter(constraints, pk=obj.pk).exists() + return model.objects.filter(qs_filter, pk=obj.pk).exists() class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend): diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index b11bf504a..123df9e45 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -1,5 +1,14 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +__all__ = ( + 'get_permission_for_model', + 'permission_is_exempt', + 'qs_filter_from_constraints', + 'resolve_permission', + 'resolve_permission_ct', +) def get_permission_for_model(model, action): @@ -69,3 +78,18 @@ def permission_is_exempt(name): return True return False + + +def qs_filter_from_constraints(constraints): + """ + Construct a Q filter object from an iterable of ObjectPermission constraints. + """ + params = Q() + for constraint in constraints: + if constraint: + params |= Q(**constraint) + else: + # Found null constraint; permit model-level access + return Q() + + return params diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 97d2e8779..8ec6012bf 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,6 +1,6 @@ -from django.db.models import Q, QuerySet +from django.db.models import QuerySet -from utilities.permissions import permission_is_exempt +from utilities.permissions import permission_is_exempt, qs_filter_from_constraints class RestrictedQuerySet(QuerySet): @@ -28,23 +28,10 @@ class RestrictedQuerySet(QuerySet): # Filter the queryset to include only objects with allowed attributes else: - attrs = Q() - for perm_attrs in user._object_perm_cache[permission_required]: - if type(perm_attrs) is list: - for p in perm_attrs: - attrs |= Q(**p) - elif perm_attrs: - attrs |= Q(**perm_attrs) - else: - # Any permission with null constraints grants access to _all_ instances - attrs = Q() - break - else: - # for else, when no break - # avoid duplicates when JOIN on many-to-many fields without using DISTINCT. - # DISTINCT acts globally on the entire request, which may not be desirable. - allowed_objects = self.model.objects.filter(attrs) - attrs = Q(pk__in=allowed_objects) - qs = self.filter(attrs) + attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required]) + # #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT. + # DISTINCT acts globally on the entire request, which may not be desirable. + allowed_objects = self.model.objects.filter(attrs) + qs = self.filter(pk__in=allowed_objects) return qs From 12c138b341faff6d2413c620488e124cbbacf8ec Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 13:34:10 -0400 Subject: [PATCH 31/50] Closes #9074: Enable referencing the current user when evaluating permission constraints --- docs/administration/permissions.md | 2 +- docs/models/users/objectpermission.md | 14 ++++++++++++++ docs/release-notes/version-3.3.md | 2 ++ netbox/netbox/authentication.py | 6 +++++- netbox/users/admin/forms.py | 9 ++++++--- netbox/users/constants.py | 2 ++ netbox/utilities/permissions.py | 15 +++++++++++++-- netbox/utilities/querysets.py | 6 +++++- 8 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index f859266af..60717c28a 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -4,7 +4,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replaces {!models/users/objectpermission.md!} -### Example Constraint Definitions +#### Example Constraint Definitions | Constraints | Description | | ----------- | ----------- | diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md index 48970dd05..075a2cae5 100644 --- a/docs/models/users/objectpermission.md +++ b/docs/models/users/objectpermission.md @@ -53,3 +53,17 @@ To achieve a logical OR with a different set of constraints, define multiple obj ``` Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. + +### Tokens + +!!! info "This feature was introduced in NetBox v3.3" + +When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as: + +```json +{ + "created_by": "$user" +} +``` + +The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index a0abb81c4..ea9e67a38 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -15,6 +15,8 @@ #### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) +#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index c16095fdc..62512943e 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -8,6 +8,7 @@ from django.contrib.auth.models import Group, AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.db.models import Q +from users.constants import CONSTRAINT_TOKEN_USER from users.models import ObjectPermission from utilities.permissions import ( permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, @@ -118,7 +119,10 @@ class ObjectPermissionMixin: raise ValueError(f"Invalid permission {perm} for model {model}") # Compile a QuerySet filter that matches all instances of the specified model - qs_filter = qs_filter_from_constraints(object_permissions[perm]) + tokens = { + CONSTRAINT_TOKEN_USER: user_obj, + } + qs_filter = qs_filter_from_constraints(object_permissions[perm], tokens) # Permission to perform the requested action on the object depends on whether the specified object matches # the specified constraints. Note that this check is made against the *database* record representing the object, diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index bc3d44862..540735ecc 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -3,11 +3,11 @@ from django.contrib.auth.models import Group, User from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError -from django.db.models import Q -from users.constants import OBJECTPERMISSION_OBJECT_TYPES +from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES from users.models import ObjectPermission, Token from utilities.forms.fields import ContentTypeMultipleChoiceField +from utilities.permissions import qs_filter_from_constraints __all__ = ( 'GroupAdminForm', @@ -125,7 +125,10 @@ class ObjectPermissionForm(forms.ModelForm): for ct in object_types: model = ct.model_class() try: - model.objects.filter(*[Q(**c) for c in constraints]).exists() + tokens = { + CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID + } + model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() except FieldError as e: raise ValidationError({ 'constraints': f'Invalid filter for {model}: {e}' diff --git a/netbox/users/constants.py b/netbox/users/constants.py index e6917c482..1e6e7c71c 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -6,3 +6,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( Q(app_label='auth', model__in=['group', 'user']) | Q(app_label='users', model__in=['objectpermission', 'token']) ) + +CONSTRAINT_TOKEN_USER = '$user' diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 123df9e45..b20aafce0 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -80,14 +80,25 @@ def permission_is_exempt(name): return False -def qs_filter_from_constraints(constraints): +def qs_filter_from_constraints(constraints, tokens=None): """ Construct a Q filter object from an iterable of ObjectPermission constraints. + + Args: + tokens: A dictionary mapping string tokens to be replaced with a value. """ + if tokens is None: + tokens = {} + + def _replace_tokens(value, tokens): + if type(value) is list: + return list(map(lambda v: tokens.get(v, v), value)) + return tokens.get(value, value) + params = Q() for constraint in constraints: if constraint: - params |= Q(**constraint) + params |= Q(**{k: _replace_tokens(v, tokens) for k, v in constraint.items()}) else: # Found null constraint; permit model-level access return Q() diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 8ec6012bf..955a10d64 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,5 +1,6 @@ from django.db.models import QuerySet +from users.constants import CONSTRAINT_TOKEN_USER from utilities.permissions import permission_is_exempt, qs_filter_from_constraints @@ -28,7 +29,10 @@ class RestrictedQuerySet(QuerySet): # Filter the queryset to include only objects with allowed attributes else: - attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required]) + tokens = { + CONSTRAINT_TOKEN_USER: user, + } + attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required], tokens) # #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT. # DISTINCT acts globally on the entire request, which may not be desirable. allowed_objects = self.model.objects.filter(attrs) From c11af40a061ac5346d7d44579dd2decad4826001 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 13:52:37 -0400 Subject: [PATCH 32/50] prepare_cloned_fields() should always return a QueryDict --- netbox/utilities/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 731b67e43..51c411004 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -286,7 +286,7 @@ def prepare_cloned_fields(instance): """ # Generate the clone attributes from the instance if not hasattr(instance, 'clone'): - return None + return QueryDict() attrs = instance.clone() # Prepare querydict parameters From a57398b0d678405d68f785f08d0e2a393eb21768 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 14:45:22 -0400 Subject: [PATCH 33/50] Closes #9647: Introduce customfield_value template tag --- docs/plugins/development/templates.md | 2 ++ docs/release-notes/version-3.3.md | 1 + .../templates/inc/panels/custom_fields.html | 28 +------------------ .../templates/builtins/customfield_value.html | 27 ++++++++++++++++++ .../utilities/templatetags/builtins/tags.py | 15 ++++++++++ 5 files changed, 46 insertions(+), 27 deletions(-) create mode 100644 netbox/utilities/templates/builtins/customfield_value.html diff --git a/docs/plugins/development/templates.md b/docs/plugins/development/templates.md index 64616c442..20838149f 100644 --- a/docs/plugins/development/templates.md +++ b/docs/plugins/development/templates.md @@ -215,6 +215,8 @@ The following custom template tags are available in NetBox. ::: utilities.templatetags.builtins.tags.checkmark +::: utilities.templatetags.builtins.tags.customfield_value + ::: utilities.templatetags.builtins.tags.tag ## Filters diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ea9e67a38..f5cb8eee1 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -40,6 +40,7 @@ * [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations * [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes +* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag ### Other Changes diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index b18d44030..90059447f 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -16,33 +16,7 @@ {{ field }} - {% if field.type == 'integer' and value is not None %} - {{ value }} - {% elif field.type == 'longtext' and value %} - {{ value|markdown }} - {% elif field.type == 'boolean' and value == True %} - {% checkmark value true="True" %} - {% elif field.type == 'boolean' and value == False %} - {% checkmark value false="False" %} - {% elif field.type == 'url' and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 'json' and value %} -
{{ value|json }}
- {% elif field.type == 'multiselect' and value %} - {{ value|join:", " }} - {% elif field.type == 'object' and value %} - {{ value|linkify }} - {% elif field.type == 'multiobject' and value %} - {% for obj in value %} - {{ obj|linkify }}{% if not forloop.last %}
{% endif %} - {% endfor %} - {% elif value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - {{ ''|placeholder }} - {% endif %} + {% customfield_value field value %} {% endfor %} diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html new file mode 100644 index 000000000..8fedb03d5 --- /dev/null +++ b/netbox/utilities/templates/builtins/customfield_value.html @@ -0,0 +1,27 @@ +{% if field.type == 'integer' and value is not None %} + {{ value }} +{% elif field.type == 'longtext' and value %} + {{ value|markdown }} +{% elif field.type == 'boolean' and value == True %} + {% checkmark value true="True" %} +{% elif field.type == 'boolean' and value == False %} + {% checkmark value false="False" %} +{% elif field.type == 'url' and value %} + {{ value|truncatechars:70 }} +{% elif field.type == 'json' and value %} +
{{ value|json }}
+{% elif field.type == 'multiselect' and value %} + {{ value|join:", " }} +{% elif field.type == 'object' and value %} + {{ value|linkify }} +{% elif field.type == 'multiobject' and value %} + {% for object in value %} + {{ object|linkify }}{% if not forloop.last %}
{% endif %} + {% endfor %} +{% elif value %} + {{ value }} +{% elif field.required %} + Not defined +{% else %} + {{ ''|placeholder }} +{% endif %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 666b6a31c..ed464b332 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -18,6 +18,21 @@ def tag(value, viewname=None): } +@register.inclusion_tag('builtins/customfield_value.html') +def customfield_value(customfield, value): + """ + Render a custom field value according to the field type. + + Args: + customfield: A CustomField instance + value: The custom field value applied to an object + """ + return { + 'customfield': customfield, + 'value': value, + } + + @register.inclusion_tag('builtins/badge.html') def badge(value, bg_color=None, show_empty=False): """ From a5124ab9c835896ff434812bf870d1add82a3afe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 15:10:31 -0400 Subject: [PATCH 34/50] Closes #8511: Enable custom fields and tags for circuit terminations --- docs/release-notes/version-3.3.md | 3 + netbox/circuits/api/serializers.py | 4 +- netbox/circuits/filtersets.py | 2 +- netbox/circuits/forms/models.py | 4 +- netbox/circuits/graphql/types.py | 3 +- ...7_circuittermination_tags_custom_fields.py | 24 +++ netbox/circuits/models/circuits.py | 13 +- netbox/templates/circuits/circuit.html | 140 +++++++++--------- .../circuits/circuittermination_edit.html | 8 + .../circuits/inc/circuit_termination.html | 33 ++++- 10 files changed, 155 insertions(+), 79 deletions(-) create mode 100644 netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f5cb8eee1..d158e6cf9 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -28,6 +28,7 @@ * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping +* [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results * [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields @@ -51,6 +52,8 @@ * circuits.Circuit * Added optional `termination_date` field +* circuits.CircuitTermination + * Added 'custom_fields' and 'tags' fields * dcim.Device * The `position` field has been changed from an integer to a decimal * dcim.DeviceType diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 2bb3cd266..844cfce89 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -98,7 +98,7 @@ class CircuitSerializer(NetBoxModelSerializer): ] -class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer): +class CircuitTerminationSerializer(NetBoxModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False, allow_null=True) @@ -110,5 +110,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - '_occupied', 'created', 'last_updated', + '_occupied', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 67a0d1b02..a74ff5c5a 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte ).distinct() -class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet): +class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 907c39586..7bd7abbbf 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -116,7 +116,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm): } -class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): +class CircuitTerminationForm(NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False, @@ -161,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): model = CircuitTermination fields = [ 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', - 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags', ] help_texts = { 'port_speed': "Physical circuit speed", diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 027b53203..094b78d07 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,5 @@ from circuits import filtersets, models +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( @@ -10,7 +11,7 @@ __all__ = ( ) -class CircuitTerminationType(ObjectType): +class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): class Meta: model = models.CircuitTermination diff --git a/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py b/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py new file mode 100644 index 000000000..c87bc4219 --- /dev/null +++ b/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py @@ -0,0 +1,24 @@ +import django.core.serializers.json +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0076_configcontext_locations'), + ('circuits', '0036_circuit_termination_date'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='circuittermination', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 5df6f1b85..cf6ffc503 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,7 +5,9 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel +from netbox.models import ( + ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, +) from netbox.models.features import WebhooksMixin __all__ = ( @@ -141,7 +143,14 @@ class Circuit(NetBoxModel): return CircuitStatusChoices.colors.get(self.status) -class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): +class CircuitTermination( + CustomFieldsMixin, + CustomLinksMixin, + TagsMixin, + WebhooksMixin, + ChangeLoggedModel, + LinkTermination +): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index a4c41f871..a11139032 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -8,74 +8,78 @@ {% endblock %} {% block content %} -
-
-
-
- Circuit -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Provider{{ object.provider|linkify }}
Circuit ID{{ object.cid }}
Type{{ object.type|linkify }}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
Tenant - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
Install Date{{ object.install_date|annotated_date|placeholder }}
Termination Date{{ object.termination_date|annotated_date|placeholder }}
Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
Description{{ object.description|placeholder }}
-
+
+
+
+
Circuit
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Provider{{ object.provider|linkify }}
Circuit ID{{ object.cid }}
Type{{ object.type|linkify }}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
Install Date{{ object.install_date|annotated_date|placeholder }}
Termination Date{{ object.termination_date|annotated_date|placeholder }}
Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
Description{{ object.description|placeholder }}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% 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/panels/contacts.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} +
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
-
+
+ {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.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' %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index f8393f945..606e12b5e 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -10,6 +10,7 @@ {% render_field form.provider %} {% render_field form.circuit %} {% render_field form.term_side %} + {% render_field form.tags %} {% render_field form.mark_connected %} {% with providernetwork_tab_active=form.initial.provider_network %}
@@ -47,6 +48,13 @@ {% render_field form.pp_info %} {% render_field form.description %}
+ +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
{% endblock %} {# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index b673cd4a3..f6bb377ec 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -2,7 +2,6 @@
- Termination - {{ side }} Side
{% if not termination and perms.circuits.add_circuittermination %} @@ -10,10 +9,10 @@ {% endif %} {% if termination and perms.circuits.change_circuittermination %} - + Edit - + Swap {% endif %} @@ -23,6 +22,7 @@ {% endif %}
+
Termination {{ side }}
{% if termination %} @@ -110,6 +110,33 @@ Description {{ termination.description|placeholder }} + + Tags + + {% for tag in termination.tags.all %} + {% tag tag %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} + + + {% for group_name, fields in termination.get_custom_fields_by_group.items %} + + + {{ group_name|default:"Custom Fields" }} + + + {% for field, value in fields.items %} + + + {{ field }} + + + {% customfield_value field value %} + + + {% endfor %} + {% endfor %} {% else %} None From 23f391c5b59d5e01321cf5b83e5337c45f9a09ac Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 15:52:16 -0400 Subject: [PATCH 35/50] Closes #9228: Add serialize_object() method to ChangeLoggingMixin --- docs/release-notes/version-3.3.md | 1 + netbox/extras/webhooks.py | 13 ++++++++++--- netbox/netbox/models/features.py | 14 +++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index d158e6cf9..0e3cfd35f 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -40,6 +40,7 @@ * [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations * [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view +* [#9228](https://github.com/netbox-community/netbox/issues/9228) - Subclasses of `ChangeLoggingMixin` can override `serialize_object()` to control JSON serialization for change logging * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes * [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index d8739cb55..334539026 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,6 +1,5 @@ import hashlib import hmac -from collections import defaultdict from django.contrib.contenttypes.models import ContentType from django.utils import timezone @@ -27,10 +26,18 @@ def serialize_for_webhook(instance): def get_snapshots(instance, action): - return { + snapshots = { 'prechange': getattr(instance, '_prechange_snapshot', None), - 'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None, + 'postchange': None, } + if action != ObjectChangeActionChoices.ACTION_DELETE: + # Use model's serialize() method if defined; fall back to serialize_object + if hasattr(instance, 'serialize_object'): + snapshots['postchange'] = instance.serialize_object() + else: + snapshots['postchange'] = serialize_object(instance) + + return snapshots def generate_signature(request_body, secret): diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 817da526b..6b2ee1f94 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -49,11 +49,19 @@ class ChangeLoggingMixin(models.Model): class Meta: abstract = True + def serialize_object(self): + """ + Return a JSON representation of the instance. Models can override this method to replace or extend the default + serialization logic provided by the `serialize_object()` utility function. + """ + return serialize_object(self) + def snapshot(self): """ - Save a snapshot of the object's current state in preparation for modification. + Save a snapshot of the object's current state in preparation for modification. The snapshot is saved as + `_prechange_snapshot` on the instance. """ - self._prechange_snapshot = serialize_object(self) + self._prechange_snapshot = self.serialize_object() def to_objectchange(self, action): """ @@ -69,7 +77,7 @@ class ChangeLoggingMixin(models.Model): if hasattr(self, '_prechange_snapshot'): objectchange.prechange_data = self._prechange_snapshot if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE): - objectchange.postchange_data = serialize_object(self) + objectchange.postchange_data = self.serialize_object() return objectchange From 277c2ff8697ea85854bee0cb56a8964331331069 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 16:36:24 -0400 Subject: [PATCH 36/50] Closes #8171: Populate next available address when cloning an IP --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/models/ip.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 0e3cfd35f..92401c217 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -25,6 +25,7 @@ * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit * [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location +* [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index db662f49c..0bc0e2364 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -857,6 +857,25 @@ class IPAddress(NetBoxModel): address__net_host=str(self.address.ip) ).exclude(pk=self.pk) + def get_next_available_ip(self): + """ + Return the next available IP address within this IP's network (if any) + """ + if self.address and self.address.broadcast: + start_ip = self.address.ip + 1 + end_ip = self.address.broadcast - 1 + if start_ip <= end_ip: + available_ips = netaddr.IPSet(netaddr.IPRange(start_ip, end_ip)) + available_ips -= netaddr.IPSet([ + address.ip for address in IPAddress.objects.filter( + vrf=self.vrf, + address__gt=self.address, + address__net_contained_or_equal=self.address.cidr + ).values_list('address', flat=True) + ]) + if available_ips: + return next(iter(available_ips)) + def clean(self): super().clean() @@ -907,6 +926,15 @@ class IPAddress(NetBoxModel): super().save(*args, **kwargs) + def clone(self): + attrs = super().clone() + + # Populate the address field with the next available IP (if any) + if next_available_ip := self.get_next_available_ip(): + attrs['address'] = next_available_ip + + return attrs + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.assigned_object From b1729f212799a468184ddf65faedb14a4a9555c3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:02:29 -0500 Subject: [PATCH 37/50] Change Virtual Circuits to L2VPN Co-authored-by: Jeremy Stretch --- netbox/ipam/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 36102f853..d331a0f7d 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -439,7 +439,7 @@ class ServiceSerializer(NetBoxModelSerializer): ] # -# Virtual Circuits +# L2VPN # From aa856e75e8897d8a4a32b571ae7f44521b0f8695 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:02:44 -0500 Subject: [PATCH 38/50] Change Virtual Circuits to L2VPN Co-authored-by: Jeremy Stretch --- netbox/ipam/api/nested_serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 07a7c9598..e74d60fb2 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -195,7 +195,7 @@ class NestedServiceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] # -# Virtual Circuits +# L2VPN # From 8e39e7f8306f96080d6d13ce829b4376402bc094 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:03:07 -0500 Subject: [PATCH 39/50] Change API urls to plural form Co-authored-by: Jeremy Stretch --- netbox/ipam/api/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index b588b6974..20e31f4d4 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -46,8 +46,8 @@ router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) # L2VPN -router.register('l2vpn', views.L2VPNViewSet) -router.register('l2vpn-termination', views.L2VPNTerminationViewSet) +router.register('l2vpns', views.L2VPNViewSet) +router.register('l2vpn-terminations', views.L2VPNTerminationViewSet) app_name = 'ipam-api' From dbb1773e158d3243bcbcf9a950ab266ac1fa7a1b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:04:33 -0500 Subject: [PATCH 40/50] Remove extraneous imports Co-authored-by: Jeremy Stretch --- netbox/ipam/forms/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 7ef47ed2f..5f4b37729 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -8,8 +8,6 @@ from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * -from ipam.models import ASN -from ipam.models.l2vpn import L2VPN, L2VPNTermination from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from tenancy.models import Tenant From 0004b834fb21230c0a494d262138495fd9bc03d3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:17:50 -0500 Subject: [PATCH 41/50] Commit fixes Jeremy suggested Co-authored-by: Jeremy Stretch --- netbox/ipam/api/serializers.py | 1 - netbox/ipam/api/views.py | 4 ++-- netbox/ipam/forms/models.py | 3 +++ netbox/ipam/models/l2vpn.py | 12 ++++++++---- netbox/netbox/navigation_menu.py | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index d331a0f7d..c6e0027f1 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -483,7 +483,6 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', 'assigned_object', - # Extra Fields 'tags', 'custom_fields', 'created', 'last_updated' ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f5a61c031..0407c6d39 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -18,7 +18,7 @@ from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import serializers -from ..models.l2vpn import L2VPN, L2VPNTermination +from ipam.models import L2VPN, L2VPNTermination class IPAMRootView(APIRootView): @@ -159,7 +159,7 @@ class ServiceViewSet(NetBoxModelViewSet): class L2VPNViewSet(NetBoxModelViewSet): - queryset = L2VPN.objects + queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags') serializer_class = serializers.L2VPNSerializer filterset_class = filtersets.L2VPNFilterSet diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 5f4b37729..bd1dce6fd 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -893,6 +893,9 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): fields = ( 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' ) + widgets = { + 'type': StaticSelect(), + } class L2VPNTerminationForm(NetBoxModelForm): diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index b086fa109..46cad72f8 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -34,7 +34,7 @@ class L2VPN(NetBoxModel): description = models.TextField(null=True, blank=True) tenant = models.ForeignKey( to='tenancy.Tenant', - on_delete=models.SET_NULL, + on_delete=models.PROTECT, related_name='l2vpns', blank=True, null=True @@ -85,7 +85,6 @@ class L2VPNTermination(NetBoxModel): class Meta: ordering = ('l2vpn',) verbose_name = 'L2VPN Termination' - constraints = ( models.UniqueConstraint( fields=('assigned_object_type', 'assigned_object_id'), @@ -112,5 +111,10 @@ class L2VPNTermination(NetBoxModel): # Only check if L2VPN is set and is of type P2P if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P: - if L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() >= 2: - raise ValidationError(f'P2P Type L2VPNs can only have 2 terminations; first delete a termination') + terminations_count = L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() + if terminations_count >= 2: + l2vpn_type = self.l2vpn.get_type_display() + raise ValidationError( + f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already ' + f'defined.' + ) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index f2245f68b..513cf4d9e 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -263,7 +263,7 @@ IPAM_MENU = Menu( MenuGroup( label='L2VPNs', items=( - get_model_item('ipam', 'l2vpn', 'L2VPN'), + get_model_item('ipam', 'l2vpn', 'L2VPNs'), get_model_item('ipam', 'l2vpntermination', 'Terminations'), ), ), From 30350e3b40dbf699fd9d9ae1c8998e41dae2d6fb Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:57:15 -0500 Subject: [PATCH 42/50] More fixes as a result of code review --- netbox/ipam/api/serializers.py | 7 +--- netbox/ipam/choices.py | 16 ++++---- netbox/ipam/forms/models.py | 32 +++++++++++++-- netbox/ipam/models/l2vpn.py | 18 +++------ netbox/ipam/tables/l2vpn.py | 23 ++++++++++- netbox/ipam/urls.py | 40 +++++++++---------- .../templates/ipam/l2vpntermination_edit.html | 16 ++++++-- 7 files changed, 98 insertions(+), 54 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c6e0027f1..9cde08374 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -464,9 +464,7 @@ class L2VPNSerializer(NetBoxModelSerializer): model = L2VPN fields = [ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', - 'description', 'tenant', - # Extra Fields - 'tags', 'custom_fields', 'created', 'last_updated' + 'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] @@ -482,8 +480,7 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): model = L2VPNTermination fields = [ 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', - 'assigned_object', - 'tags', 'custom_fields', 'created', 'last_updated' + 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 72cd4ff73..298baa643 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -191,6 +191,14 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_VPWS, 'VPWS'), (TYPE_VPLS, 'VPLS'), )), + ('VXLAN', ( + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), + ('L2VPN E-VPN', ( + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )), ('E-Line', ( (TYPE_EPL, 'EPL'), (TYPE_EVPL, 'EVPL'), @@ -203,14 +211,6 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_EPTREE, 'Ethernet Private Tree'), (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), )), - ('VXLAN', ( - (TYPE_VXLAN, 'VXLAN'), - (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), - )), - ('L2VPN E-VPN', ( - (TYPE_MPLS_EVPN, 'MPLS EVPN'), - (TYPE_PBB_EVPN, 'PBB EVPN'), - )) ) P2P = ( diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index bd1dce6fd..d2797c1cf 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -929,6 +929,20 @@ class L2VPNTerminationForm(NetBoxModelForm): } ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + query_params={} + ) + + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine' + } + ) + class Meta: model = L2VPNTermination fields = ('l2vpn', ) @@ -943,6 +957,8 @@ class L2VPNTerminationForm(NetBoxModelForm): initial['interface'] = instance.assigned_object elif type(instance.assigned_object) is VLAN: initial['vlan'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['vminterface'] = instance.assigned_object kwargs['initial'] = initial super().__init__(*args, **kwargs) @@ -950,11 +966,21 @@ class L2VPNTerminationForm(NetBoxModelForm): def clean(self): super().clean() - if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + interface = self.cleaned_data.get('interface') + vlan = self.cleaned_data.get('vlan') + vminterface = self.cleaned_data.get('vminterface') + + if not (interface or vlan or vminterface): raise ValidationError('You must have either a interface or a VLAN') - if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + if interface and vlan and vminterface: + raise ValidationError('Cannot assign a interface, vlan and vminterface') + elif interface and vlan: raise ValidationError('Cannot assign both a interface and vlan') + elif interface and vminterface: + raise ValidationError('Cannot assign both a interface and vminterface') + elif vlan and vminterface: + raise ValidationError('Cannot assign both a vlan and vminterface') - obj = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') + obj = interface or vlan or vminterface self.instance.assigned_object = obj diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 46cad72f8..dd8c51984 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -60,23 +60,15 @@ class L2VPNTermination(NetBoxModel): l2vpn = models.ForeignKey( to='ipam.L2VPN', on_delete=models.CASCADE, - related_name='terminations', - blank=False, - null=False + related_name='terminations' ) - assigned_object_type = models.ForeignKey( to=ContentType, limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, - related_name='+', - blank=True, - null=True - ) - assigned_object_id = models.PositiveBigIntegerField( - blank=True, - null=True + related_name='+' ) + assigned_object_id = models.PositiveBigIntegerField() assigned_object = GenericForeignKey( ct_field='assigned_object_type', fk_field='assigned_object_id' @@ -95,13 +87,13 @@ class L2VPNTermination(NetBoxModel): def __str__(self): if self.pk is not None: return f'{self.assigned_object} <> {self.l2vpn}' - return '' + return super().__str__() def get_absolute_url(self): return reverse('ipam:l2vpntermination', args=[self.pk]) def clean(self): - # Only check is assigned_object is set + # Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown. if self.assigned_object: obj_id = self.assigned_object.pk obj_type = ContentType.objects.get_for_model(self.assigned_object) diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 551f692bb..a0e2f5d67 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -9,25 +9,44 @@ __all__ = ( 'L2VPNTerminationTable', ) +L2VPN_TARGETS = """ +{% for rt in value.all %} + {{ rt }}{% if not forloop.last %}
{% endif %} +{% endfor %} +""" + class L2VPNTable(NetBoxTable): pk = columns.ToggleColumn() name = tables.Column( linkify=True ) + import_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) + export_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) class Meta(NetBoxTable.Meta): model = L2VPN - fields = ('pk', 'name', 'description', 'slug', 'type', 'tenant', 'actions') - default_columns = ('pk', 'name', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'actions') + default_columns = ('pk', 'name', 'type', 'description', 'actions') class L2VPNTerminationTable(NetBoxTable): pk = columns.ToggleColumn() + l2vpn = tables.Column( + verbose_name='L2VPN', + linkify=True + ) assigned_object_type = columns.ContentTypeColumn( verbose_name='Object Type' ) assigned_object = tables.Column( + verbose_name='Assigned Object', linkify=True, orderable=False ) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index e00b0365f..d27209fd2 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -187,25 +187,25 @@ urlpatterns = [ path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), # L2VPN - path('l2vpn/', views.L2VPNListView.as_view(), name='l2vpn_list'), - path('l2vpn/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), - path('l2vpn/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), - path('l2vpn/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), - path('l2vpn/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), - path('l2vpn//', views.L2VPNView.as_view(), name='l2vpn'), - path('l2vpn//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), - path('l2vpn//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), - path('l2vpn//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), - path('l2vpn//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), + path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), + path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), + path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), + path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), + path('l2vpns//', views.L2VPNView.as_view(), name='l2vpn'), + path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), + path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), + path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), + path('l2vpns//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), - path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), - path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), - path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), - path('l2vpn-termination/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), - path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), - path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), - path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), - path('l2vpn-termination//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), - path('l2vpn-termination//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), - path('l2vpn-termination//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), + path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), + path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), + path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), + path('l2vpn-terminations//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), + path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), + path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), + path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), ] diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index 3fb0460b5..4ba079eb5 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -12,7 +12,7 @@
- {% render_field form.device %} -
+
+ {% render_field form.device %} {% render_field form.vlan %}
+ {% render_field form.device %} {% render_field form.interface %}
+
+ {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
From 5bcc3a3fb9636d0b24c42a46383dc964f01287e3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:00:33 -0500 Subject: [PATCH 43/50] Update docs --- docs/models/ipam/l2vpn.md | 2 ++ docs/models/ipam/l2vpntermination.md | 5 ++++- netbox/ipam/forms/models.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md index 9c50a6407..9f9b4703c 100644 --- a/docs/models/ipam/l2vpn.md +++ b/docs/models/ipam/l2vpn.md @@ -17,3 +17,5 @@ Each L2VPN instance must have one of the following type associated with it: * MPLS-EVPN * PBB-EVPN +!!!note + Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add 2 terminations to a given L2VPN. diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md index 9135f72a3..cc1843639 100644 --- a/docs/models/ipam/l2vpntermination.md +++ b/docs/models/ipam/l2vpntermination.md @@ -9,4 +9,7 @@ The following types of L2VPN's are considered point-to-point: * VPWS * EPL * EP-LAN -* EP-TREE \ No newline at end of file +* EP-TREE + +!!!note + Choosing any of the above types of L2VPN's will result in only being able to add 2 terminations to a given L2VPN. diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d2797c1cf..43e33dd4d 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -976,7 +976,7 @@ class L2VPNTerminationForm(NetBoxModelForm): if interface and vlan and vminterface: raise ValidationError('Cannot assign a interface, vlan and vminterface') elif interface and vlan: - raise ValidationError('Cannot assign both a interface and vlan') + raise Validatio`nError('Cannot assign both a interface and vlan') elif interface and vminterface: raise ValidationError('Cannot assign both a interface and vminterface') elif vlan and vminterface: From f1c8926252e923589161ede1cc7b4cd6432806c1 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:01:08 -0500 Subject: [PATCH 44/50] Fix error --- netbox/ipam/forms/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 43e33dd4d..d2797c1cf 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -976,7 +976,7 @@ class L2VPNTerminationForm(NetBoxModelForm): if interface and vlan and vminterface: raise ValidationError('Cannot assign a interface, vlan and vminterface') elif interface and vlan: - raise Validatio`nError('Cannot assign both a interface and vlan') + raise ValidationError('Cannot assign both a interface and vlan') elif interface and vminterface: raise ValidationError('Cannot assign both a interface and vminterface') elif vlan and vminterface: From 878c465c56b5a656c4f99ebc9499d11274a7692e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:10:10 -0500 Subject: [PATCH 45/50] Update Termination table rendering on L2VPN View --- netbox/templates/ipam/l2vpn.html | 34 ++------------------------------ 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index 59cc6234b..130940b02 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -59,39 +59,9 @@
-
L2VPN Terminations
+
Terminations
- {% with terminations=object.terminations.all %} - {% if terminations.exists %} - - - - - - - {% for termination in terminations %} - - - - - - {% endfor %} -
Termination TypeTermination
{{ termination.assigned_object|meta:"verbose_name" }}{{ termination.assigned_object|linkify }} - {% if perms.ipam.change_l2vpntermination %} - - - - {% endif %} - {% if perms.ipam.delete_l2vpntermination %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} - {% endwith %} + {% render_table terminations_table 'inc/table.html' %}
{% if perms.ipam.add_l2vpntermination %}