From 4677a912dbb5d94ff49a3024123718448b246dd1 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Wed, 26 Jun 2024 19:16:42 +0200 Subject: [PATCH 01/10] feat(models): Add ACITenant field to BridgeDomain An ACI Bridge Domain can be linked to an ACI VRF of the "common" ACI Tenant. Therefore the model needs its own ACI Tenant ForeignKey field. --- netbox_aci_plugin/api/serializers.py | 3 +++ netbox_aci_plugin/api/views.py | 1 + .../filtersets/tenant_networks.py | 7 +++-- netbox_aci_plugin/forms/tenant_networks.py | 14 +++++++--- netbox_aci_plugin/graphql/types.py | 1 + netbox_aci_plugin/models/tenant_networks.py | 27 +++++++++++-------- netbox_aci_plugin/tests/test_api.py | 26 +++++++++++++++--- netbox_aci_plugin/tests/test_models.py | 12 +++++---- netbox_aci_plugin/views/tenant_networks.py | 5 ++++ netbox_aci_plugin/views/tenants.py | 17 +----------- 10 files changed, 69 insertions(+), 44 deletions(-) diff --git a/netbox_aci_plugin/api/serializers.py b/netbox_aci_plugin/api/serializers.py index 4089fb3..66e175f 100644 --- a/netbox_aci_plugin/api/serializers.py +++ b/netbox_aci_plugin/api/serializers.py @@ -144,6 +144,7 @@ class ACIBridgeDomainSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( view_name="plugins-api:netbox_aci_plugin-api:acibridgedomain-detail" ) + aci_tenant = ACITenantSerializer(nested=True, required=True) aci_vrf = ACIVRFSerializer(nested=True, required=True) nb_tenant = TenantSerializer(nested=True, required=False, allow_null=True) @@ -156,6 +157,7 @@ class Meta: "name", "name_alias", "description", + "aci_tenant", "aci_vrf", "nb_tenant", "advertise_host_routes_enabled", @@ -191,6 +193,7 @@ class Meta: "name", "name_alias", "description", + "aci_tenant", "aci_vrf", "nb_tenant", ) diff --git a/netbox_aci_plugin/api/views.py b/netbox_aci_plugin/api/views.py index 2588255..b3ecf47 100644 --- a/netbox_aci_plugin/api/views.py +++ b/netbox_aci_plugin/api/views.py @@ -71,6 +71,7 @@ class ACIBridgeDomainListViewSet(NetBoxModelViewSet): """API view for listing ACI Bridge Domain instances.""" queryset = ACIBridgeDomain.objects.prefetch_related( + "aci_tenant", "aci_vrf", "nb_tenant", "tags", diff --git a/netbox_aci_plugin/filtersets/tenant_networks.py b/netbox_aci_plugin/filtersets/tenant_networks.py index 73cfe1b..4fa43f5 100644 --- a/netbox_aci_plugin/filtersets/tenant_networks.py +++ b/netbox_aci_plugin/filtersets/tenant_networks.py @@ -112,13 +112,11 @@ class ACIBridgeDomainFilterSet(NetBoxModelFilterSet): """Filter set for ACI Bridge Domain model.""" aci_tenant = django_filters.ModelMultipleChoiceFilter( - field_name="aci_vrf__aci_tenant", queryset=ACITenant.objects.all(), to_field_name="name", label=_("ACI Tenant (name)"), ) aci_tenant_id = django_filters.ModelMultipleChoiceFilter( - field_name="aci_vrf__aci_tenant", queryset=ACITenant.objects.all(), to_field_name="id", label=_("ACI Tenant (ID)"), @@ -175,6 +173,7 @@ class Meta: "name", "name_alias", "description", + "aci_tenant", "aci_vrf", "nb_tenant", "advertise_host_routes_enabled", @@ -223,13 +222,13 @@ class ACIBridgeDomainSubnetFilterSet(NetBoxModelFilterSet): """Filter set for ACI Bridge Domain Subnet model.""" aci_tenant = django_filters.ModelMultipleChoiceFilter( - field_name="aci_bridge_domain__aci_vrf__aci_tenant", + field_name="aci_bridge_domain__aci_tenant", queryset=ACITenant.objects.all(), to_field_name="name", label=_("ACI Tenant (name)"), ) aci_tenant_id = django_filters.ModelMultipleChoiceFilter( - field_name="aci_bridge_domain__aci_vrf__aci_tenant", + field_name="aci_bridge_domain__aci_tenant", queryset=ACITenant.objects.all(), to_field_name="id", label=_("ACI Tenant (ID)"), diff --git a/netbox_aci_plugin/forms/tenant_networks.py b/netbox_aci_plugin/forms/tenant_networks.py index 43a2bc7..a592866 100644 --- a/netbox_aci_plugin/forms/tenant_networks.py +++ b/netbox_aci_plugin/forms/tenant_networks.py @@ -522,7 +522,6 @@ class ACIBridgeDomainForm(NetBoxModelForm): aci_tenant = DynamicModelChoiceField( queryset=ACITenant.objects.all(), initial_params={"aci_vrfs": "$aci_vrf"}, - required=False, label=_("ACI Tenant"), ) aci_vrf = DynamicModelChoiceField( @@ -710,6 +709,7 @@ class Meta: "name", "name_alias", "description", + "aci_tenant", "aci_vrf", "nb_tenant", "advertise_host_routes_enabled", @@ -750,6 +750,11 @@ class ACIBridgeDomainBulkEditForm(NetBoxModelBulkEditForm): required=False, label=_("Description"), ) + aci_tenant = DynamicModelChoiceField( + queryset=ACITenant.objects.all(), + required=False, + label=_("ACI Tenant"), + ) aci_vrf = DynamicModelChoiceField( queryset=ACIVRF.objects.all(), required=False, @@ -878,6 +883,7 @@ class ACIBridgeDomainBulkEditForm(NetBoxModelBulkEditForm): FieldSet( "name", "name_alias", + "aci_tenant", "aci_vrf", "description", "tags", @@ -1124,7 +1130,7 @@ class ACIBridgeDomainImportForm(NetBoxModelImportForm): to_field_name="name", required=True, label=_("ACI Tenant"), - help_text=_("Parent ACI Tenant of ACI VRF"), + help_text=_("Assigned ACI Tenant"), ) aci_vrf = CSVModelChoiceField( queryset=ACIVRF.objects.all(), @@ -1765,9 +1771,9 @@ def __init__(self, data=None, *args, **kwargs) -> None: self.fields["aci_vrf"].queryset = ACIVRF.objects.filter( aci_tenant__name=data["aci_tenant"] ) - # Limit ACIBridgeDomain queryset by parent ACIVRF + # Limit ACIBridgeDomain queryset by parent ACIVRF and ACITenant aci_bd_queryset = ACIBridgeDomain.objects.filter( - aci_vrf__aci_tenant__name=data["aci_tenant"], + aci_tenant__name=data["aci_tenant"], aci_vrf__name=data["aci_vrf"], ) self.fields["aci_bridge_domain"].queryset = aci_bd_queryset diff --git a/netbox_aci_plugin/graphql/types.py b/netbox_aci_plugin/graphql/types.py index 63ee5d8..514cdb4 100644 --- a/netbox_aci_plugin/graphql/types.py +++ b/netbox_aci_plugin/graphql/types.py @@ -59,6 +59,7 @@ class ACIVRFType(NetBoxObjectType): class ACIBridgeDomainType(NetBoxObjectType): """GraphQL type definition for ACIBridgeDomain model.""" + aci_tenant: ACITenantType aci_vrf: ACIVRFType nb_tenant: Optional[TenantType] dhcp_labels: Optional[List[str]] diff --git a/netbox_aci_plugin/models/tenant_networks.py b/netbox_aci_plugin/models/tenant_networks.py index 6a945aa..5942899 100644 --- a/netbox_aci_plugin/models/tenant_networks.py +++ b/netbox_aci_plugin/models/tenant_networks.py @@ -224,6 +224,12 @@ class ACIBridgeDomain(NetBoxModel): ACIPolicyDescriptionValidator, ], ) + aci_tenant = models.ForeignKey( + to=ACITenant, + on_delete=models.PROTECT, + related_name="aci_bridge_domains", + verbose_name=_("ACI Tenant"), + ) aci_vrf = models.ForeignKey( to=ACIVRF, on_delete=models.PROTECT, @@ -425,6 +431,7 @@ class ACIBridgeDomain(NetBoxModel): clone_fields: tuple = ( "description", + "aci_tenant", "aci_vrf", "nb_tenant", "advertise_host_routes_enabled", @@ -448,27 +455,25 @@ class ACIBridgeDomain(NetBoxModel): "unknown_unicast", "virtual_mac_address", ) - prerequisite_models: tuple = ("netbox_aci_plugin.ACIVRF",) + prerequisite_models: tuple = ( + "netbox_aci_plugin.ACITenant", + "netbox_aci_plugin.ACIVRF", + ) class Meta: constraints: list[models.UniqueConstraint] = [ models.UniqueConstraint( - fields=("aci_vrf", "name"), - name="unique_aci_bridge_domain_name_per_aci_vrf", + fields=("aci_tenant", "name"), + name="unique_aci_bridge_domain_name_per_aci_tenant", ), ] - ordering: tuple = ("aci_vrf", "name") + ordering: tuple = ("aci_tenant", "aci_vrf", "name") verbose_name: str = _("ACI Bridge Domain") def __str__(self) -> str: """Return string representation of the instance.""" return self.name - @property - def aci_tenant(self) -> ACITenant: - """Return the ACITenant instance of related ACIVRF.""" - return self.aci_vrf.aci_tenant - def get_absolute_url(self) -> str: """Return the absolute URL of the instance.""" return reverse( @@ -672,8 +677,8 @@ def __str__(self) -> str: @property def aci_tenant(self) -> ACITenant: - """Return the ACITenant instance of related ACIBridgeDomain's ACIVRF.""" - return self.aci_bridge_domain.aci_vrf.aci_tenant + """Return the ACITenant instance of related ACIBridgeDomain.""" + return self.aci_bridge_domain.aci_tenant @property def aci_vrf(self) -> ACIVRF: diff --git a/netbox_aci_plugin/tests/test_api.py b/netbox_aci_plugin/tests/test_api.py index 8b14e94..d7a0859 100644 --- a/netbox_aci_plugin/tests/test_api.py +++ b/netbox_aci_plugin/tests/test_api.py @@ -290,6 +290,7 @@ class ACIBridgeDomainAPIViewTestCase(APIViewTestCases.APIViewTestCase): model = ACIBridgeDomain view_namespace: str = f"plugins-api:{app_name}" brief_fields: list[str] = [ + "aci_tenant", "aci_vrf", "description", "display", @@ -324,6 +325,7 @@ def setUpTestData(cls) -> None: name_alias="Testing", description="First ACI Test", comments="# ACI Test 1", + aci_tenant=aci_tenant1, aci_vrf=aci_vrf1, nb_tenant=nb_tenant1, advertise_host_routes_enabled=False, @@ -355,6 +357,7 @@ def setUpTestData(cls) -> None: name_alias="Testing", description="Second ACI Test", comments="# ACI Test 2", + aci_tenant=aci_tenant2, aci_vrf=aci_vrf2, nb_tenant=nb_tenant1, ), @@ -363,6 +366,7 @@ def setUpTestData(cls) -> None: name_alias="Testing", description="Third ACI Test", comments="# ACI Test 3", + aci_tenant=aci_tenant1, aci_vrf=aci_vrf1, nb_tenant=nb_tenant2, advertise_host_routes_enabled=True, @@ -396,6 +400,7 @@ def setUpTestData(cls) -> None: "name_alias": "Testing", "description": "Forth ACI Test", "comments": "# ACI Test 4", + "aci_tenant": aci_tenant2.id, "aci_vrf": aci_vrf2.id, "nb_tenant": nb_tenant1.id, "advertise_host_routes_enabled": False, @@ -424,6 +429,7 @@ def setUpTestData(cls) -> None: "name_alias": "Testing", "description": "Fifth ACI Test", "comments": "# ACI Test 5", + "aci_tenant": aci_tenant1.id, "aci_vrf": aci_vrf1.id, "nb_tenant": nb_tenant2.id, "advertise_host_routes_enabled": True, @@ -493,10 +499,16 @@ def setUpTestData(cls) -> None: nb_vrf=nb_vrf2, ) aci_bd1 = ACIBridgeDomain.objects.create( - name="ACI-BD-API-1", aci_vrf=aci_vrf1, nb_tenant=nb_tenant1 + name="ACI-BD-API-1", + aci_tenant=aci_tenant1, + aci_vrf=aci_vrf1, + nb_tenant=nb_tenant1, ) aci_bd2 = ACIBridgeDomain.objects.create( - name="ACI-BD-API-2", aci_vrf=aci_vrf2, nb_tenant=nb_tenant2 + name="ACI-BD-API-2", + aci_tenant=aci_tenant2, + aci_vrf=aci_vrf2, + nb_tenant=nb_tenant2, ) gw_ip1 = IPAddress.objects.create(address="10.0.0.1/24", vrf=nb_vrf1) gw_ip2 = IPAddress.objects.create(address="10.0.1.1/24", vrf=nb_vrf1) @@ -646,10 +658,16 @@ def setUpTestData(cls) -> None: nb_vrf=nb_vrf2, ) aci_bd1 = ACIBridgeDomain.objects.create( - name="ACI-BD-API-1", aci_vrf=aci_vrf1, nb_tenant=nb_tenant1 + name="ACI-BD-API-1", + aci_tenant=aci_tenant1, + aci_vrf=aci_vrf1, + nb_tenant=nb_tenant1, ) aci_bd2 = ACIBridgeDomain.objects.create( - name="ACI-BD-API-2", aci_vrf=aci_vrf2, nb_tenant=nb_tenant2 + name="ACI-BD-API-2", + aci_tenant=aci_tenant2, + aci_vrf=aci_vrf2, + nb_tenant=nb_tenant2, ) aci_epgs: tuple = ( diff --git a/netbox_aci_plugin/tests/test_models.py b/netbox_aci_plugin/tests/test_models.py index d07d8f9..1228cd0 100644 --- a/netbox_aci_plugin/tests/test_models.py +++ b/netbox_aci_plugin/tests/test_models.py @@ -295,6 +295,7 @@ def setUp(self) -> None: name_alias=acibd_name_alias, description=acibd_description, comments=acibd_comments, + aci_tenant=aci_tenant, aci_vrf=aci_vrf, nb_tenant=nb_tenant, advertise_host_routes_enabled=acibd_advertise_host_routes_enabled, @@ -380,12 +381,13 @@ def test_invalid_aci_bridge_domain_description(self) -> None: ) self.assertRaises(ValidationError, bd.full_clean) - def test_constraint_unique_aci_bridge_domain_name_per_aci_vrf( + def test_constraint_unique_aci_bridge_domain_name_per_aci_tenant( self, ) -> None: - """Test unique constraint of ACI Bridge Domain name per ACI VRF.""" + """Test unique constraint of ACI Bridge Domain name per ACI Tenant.""" + tenant = ACITenant.objects.get(name="ACITestTenant1") vrf = ACIVRF.objects.get(name="VRFTest1") - bd = ACIBridgeDomain(name="BDTest1", aci_vrf=vrf) + bd = ACIBridgeDomain(name="BDTest1", aci_tenant=tenant, aci_vrf=vrf) self.assertRaises(IntegrityError, bd.save) @@ -419,7 +421,7 @@ def setUp(self) -> None: name=acivrf_name, aci_tenant=aci_tenant ) aci_bridge_domain = ACIBridgeDomain.objects.create( - name=acibd_name, aci_vrf=aci_vrf + name=acibd_name, aci_tenant=aci_tenant, aci_vrf=aci_vrf ) aci_bd_gateway = IPAddress.objects.create( address=acisnet_gateway_ip_address @@ -563,7 +565,7 @@ def setUp(self) -> None: name=acivrf_name, aci_tenant=aci_tenant ) aci_bd = ACIBridgeDomain.objects.create( - name=acibd_name, aci_vrf=aci_vrf + name=acibd_name, aci_tenant=aci_tenant, aci_vrf=aci_vrf ) nb_tenant = Tenant.objects.create(name="NetBox Tenant") diff --git a/netbox_aci_plugin/views/tenant_networks.py b/netbox_aci_plugin/views/tenant_networks.py index 821ad56..e265f7a 100644 --- a/netbox_aci_plugin/views/tenant_networks.py +++ b/netbox_aci_plugin/views/tenant_networks.py @@ -83,6 +83,7 @@ def get_children(self, request, parent): return ACIBridgeDomain.objects.restrict( request.user, "view" ).prefetch_related( + "aci_tenant", "aci_vrf", "nb_tenant", "tags", @@ -223,6 +224,7 @@ class ACIBridgeDomainView(generic.ObjectView): """Detail view for displaying a single object of ACI Bridge Domain.""" queryset = ACIBridgeDomain.objects.prefetch_related( + "aci_tenant", "aci_vrf", "nb_tenant", "tags", @@ -244,6 +246,7 @@ class ACIBridgeDomainListView(generic.ObjectListView): """List view for listing all objects of ACI Bridge Domain.""" queryset = ACIBridgeDomain.objects.prefetch_related( + "aci_tenant", "aci_vrf", "nb_tenant", "tags", @@ -258,6 +261,7 @@ class ACIBridgeDomainEditView(generic.ObjectEditView): """Edit view for editing an object of ACI Bridge Domain.""" queryset = ACIBridgeDomain.objects.prefetch_related( + "aci_tenant", "aci_vrf", "nb_tenant", "tags", @@ -270,6 +274,7 @@ class ACIBridgeDomainDeleteView(generic.ObjectDeleteView): """Delete view for deleting an object of ACI Bridge Domain.""" queryset = ACIBridgeDomain.objects.prefetch_related( + "aci_tenant", "aci_vrf", "nb_tenant", "tags", diff --git a/netbox_aci_plugin/views/tenants.py b/netbox_aci_plugin/views/tenants.py index b1db0f8..d3731dc 100644 --- a/netbox_aci_plugin/views/tenants.py +++ b/netbox_aci_plugin/views/tenants.py @@ -15,7 +15,6 @@ ACITenantImportForm, ) from ..models.tenant_app_profiles import ACIEndpointGroup -from ..models.tenant_networks import ACIBridgeDomain from ..models.tenants import ACITenant from ..tables.tenants import ACITenantTable from .tenant_app_profiles import ( @@ -54,12 +53,6 @@ def get_extra_context(self, request, instance) -> dict: # Get related models of directly referenced models related_sub_models: list[tuple] = [ - ( - ACIBridgeDomain.objects.restrict(request.user, "view").filter( - aci_vrf__aci_tenant=instance - ), - "aci_tenant_id", - ), ( ACIEndpointGroup.objects.restrict(request.user, "view").filter( aci_app_profile__aci_tenant=instance @@ -161,14 +154,6 @@ class ACITenantBridgeDomainView(ACIBridgeDomainChildrenView): """Children view of ACI Bridge Domain of ACI Tenant.""" queryset = ACITenant.objects.all() - tab = ViewTab( - label=_("Bridge Domains"), - badge=lambda obj: ACITenantBridgeDomainView.child_model.objects.filter( - aci_vrf__aci_tenant=obj.pk - ).count(), - permission="netbox_aci_plugin.view_acibridgedomain", - weight=1000, - ) template_name = "netbox_aci_plugin/acitenant_bridgedomains.html" def get_children(self, request, parent): @@ -176,7 +161,7 @@ def get_children(self, request, parent): return ( super() .get_children(request, parent) - .filter(aci_vrf__aci_tenant=parent.pk) + .filter(aci_tenant_id=parent.pk) ) def get_table(self, *args, **kwargs): From d6afa1da29b737c43675a73104c9037d7c6daa97 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Sun, 30 Jun 2024 19:30:52 +0200 Subject: [PATCH 02/10] feat(forms): Add validation of ACI BD for ACI VRF ACI Bridge Domain can only assign an ACI VRF from the same ACI Tenant or from the special ACI Tenant "common". --- .../filtersets/tenant_networks.py | 15 ++++++++++++ netbox_aci_plugin/forms/tenant_networks.py | 24 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/netbox_aci_plugin/filtersets/tenant_networks.py b/netbox_aci_plugin/filtersets/tenant_networks.py index 4fa43f5..cb32ea1 100644 --- a/netbox_aci_plugin/filtersets/tenant_networks.py +++ b/netbox_aci_plugin/filtersets/tenant_networks.py @@ -67,6 +67,11 @@ class ACIVRFFilterSet(NetBoxModelFilterSet): choices=VRFPCEnforcementPreferenceChoices, null_value=None, ) + present_in_aci_tenant_or_common = django_filters.ModelChoiceFilter( + queryset=ACITenant.objects.all(), + method="filter_present_in_aci_tenant_or_common", + label=_("ACI Tenant (ID)"), + ) class Meta: model = ACIVRF @@ -107,6 +112,16 @@ def search(self, queryset, name, value): ) return queryset.filter(queryset_filter) + def filter_present_in_aci_tenant_or_common( + self, queryset, name, aci_tenant + ): + """Return a QuerySet filtered by given ACI Tenant or 'common'.""" + if aci_tenant is None: + return queryset.none + return queryset.filter( + Q(aci_tenant=aci_tenant) | Q(aci_tenant__name="common") + ) + class ACIBridgeDomainFilterSet(NetBoxModelFilterSet): """Filter set for ACI Bridge Domain model.""" diff --git a/netbox_aci_plugin/forms/tenant_networks.py b/netbox_aci_plugin/forms/tenant_networks.py index a592866..41c1069 100644 --- a/netbox_aci_plugin/forms/tenant_networks.py +++ b/netbox_aci_plugin/forms/tenant_networks.py @@ -521,12 +521,11 @@ class ACIBridgeDomainForm(NetBoxModelForm): aci_tenant = DynamicModelChoiceField( queryset=ACITenant.objects.all(), - initial_params={"aci_vrfs": "$aci_vrf"}, label=_("ACI Tenant"), ) aci_vrf = DynamicModelChoiceField( queryset=ACIVRF.objects.all(), - query_params={"aci_tenant_id": "$aci_tenant"}, + query_params={"present_in_aci_tenant_or_common": "$aci_tenant"}, label=_("ACI VRF"), ) nb_tenant_group = DynamicModelChoiceField( @@ -736,6 +735,27 @@ class Meta: "tags", ) + def clean(self): + """Cleaning and validation of ACI VRF Form.""" + + super().clean() + + aci_tenant = self.cleaned_data.get("aci_tenant") + aci_vrf = self.cleaned_data.get("aci_vrf") + + if ( + not aci_tenant.id == aci_vrf.aci_tenant.id + and not aci_vrf.aci_tenant.name == "common" + ): + raise forms.ValidationError( + { + "aci_vrf": _( + "A VRF can only be assigned from the same ACI Tenant" + " as the Bridge Domain or ACI Tenant 'common'." + ) + } + ) + class ACIBridgeDomainBulkEditForm(NetBoxModelBulkEditForm): """NetBox bulk edit form for ACI Bridge Domain model.""" From 4a5cc6373ecbe654bc6f5dcaa67d818af48c394a Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 1 Jul 2024 20:37:43 +0200 Subject: [PATCH 03/10] feat(forms): Add import control for VRF in common An import of an ACI Bridge Domain may reference an ACI VRF in the ACI Tenant "common". The boolean field "is_aci_vrf_in_common" allows to control, whether the specified ACI VRF is in the "common" ACI Tenant. --- netbox_aci_plugin/forms/tenant_networks.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/netbox_aci_plugin/forms/tenant_networks.py b/netbox_aci_plugin/forms/tenant_networks.py index 41c1069..bf9ce52 100644 --- a/netbox_aci_plugin/forms/tenant_networks.py +++ b/netbox_aci_plugin/forms/tenant_networks.py @@ -1159,6 +1159,11 @@ class ACIBridgeDomainImportForm(NetBoxModelImportForm): label=_("ACI VRF"), help_text=_("Assigned ACI VRF"), ) + is_aci_vrf_in_common = forms.BooleanField( + label=_("Is ACI VRF in 'common'"), + required=False, + help_text=_("Assigned ACI VRF is in ACI Tenant 'common'"), + ) nb_tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name="name", @@ -1203,6 +1208,7 @@ class Meta: "aci_vrf", "description", "nb_tenant", + "is_aci_vrf_in_common", "advertise_host_routes_enabled", "arp_flooding_enabled", "clear_remote_mac_enabled", @@ -1235,8 +1241,13 @@ def __init__(self, data=None, *args, **kwargs) -> None: if not data: return + # Limit ACIVRF queryset by "common" ACITenant + if data.get("is_aci_vrf_in_common") == "true": + self.fields["aci_vrf"].queryset = ACIVRF.objects.filter( + aci_tenant__name="common" + ) # Limit ACIVRF queryset by parent ACITenant - if data.get("aci_tenant"): + elif data.get("aci_tenant"): self.fields["aci_vrf"].queryset = ACIVRF.objects.filter( aci_tenant__name=data["aci_tenant"] ) From f8bf3e520629debcc57da0b2e47664f1c06b20bc Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 1 Jul 2024 20:59:37 +0200 Subject: [PATCH 04/10] feat(models): Append "common" to shared ACI VRF --- netbox_aci_plugin/models/tenant_networks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox_aci_plugin/models/tenant_networks.py b/netbox_aci_plugin/models/tenant_networks.py index 5942899..58db411 100644 --- a/netbox_aci_plugin/models/tenant_networks.py +++ b/netbox_aci_plugin/models/tenant_networks.py @@ -176,7 +176,10 @@ class Meta: def __str__(self) -> str: """Return string representation of the instance.""" - return self.name + if self.aci_tenant.name == "common": + return f"{self.name} ({self.aci_tenant.name})" + else: + return self.name def get_absolute_url(self) -> str: """Return the absolute URL of the instance.""" From 87a0d71cd1ab56136c2fb44ea7cccf42e4ee606d Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 1 Jul 2024 21:19:20 +0200 Subject: [PATCH 05/10] docs(bridgedomain): Add ACITenant field reference --- docs/features/tenants.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/features/tenants.md b/docs/features/tenants.md index bf8b8cb..b628fe7 100644 --- a/docs/features/tenants.md +++ b/docs/features/tenants.md @@ -103,6 +103,7 @@ The *ACIBridgeDomain* model has the following fields: *Required fields*: - **Name**: represent the Bridge Domain name in the ACI +- **ACI Tenant**: a reference to the ACITenant model. - **ACI VRF**: a reference to the ACIVRF model. *Optional fields*: From 46a76a76765955dd1e3484f5dedead4f5f7c7438 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 1 Jul 2024 21:36:16 +0200 Subject: [PATCH 06/10] docs(forms): Fix clean method comment of ACI BD --- netbox_aci_plugin/forms/tenant_networks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_aci_plugin/forms/tenant_networks.py b/netbox_aci_plugin/forms/tenant_networks.py index bf9ce52..68d3542 100644 --- a/netbox_aci_plugin/forms/tenant_networks.py +++ b/netbox_aci_plugin/forms/tenant_networks.py @@ -736,7 +736,7 @@ class Meta: ) def clean(self): - """Cleaning and validation of ACI VRF Form.""" + """Cleaning and validation of ACI Bridge Domain Form.""" super().clean() From 5d47c6565fce7e6ede379db7690390ff27232341 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 1 Jul 2024 22:01:29 +0200 Subject: [PATCH 07/10] feat(models): Append "common" to shared ACI BD --- netbox_aci_plugin/models/tenant_networks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox_aci_plugin/models/tenant_networks.py b/netbox_aci_plugin/models/tenant_networks.py index 58db411..9499aa0 100644 --- a/netbox_aci_plugin/models/tenant_networks.py +++ b/netbox_aci_plugin/models/tenant_networks.py @@ -475,7 +475,10 @@ class Meta: def __str__(self) -> str: """Return string representation of the instance.""" - return self.name + if self.aci_tenant.name == "common": + return f"{self.name} ({self.aci_tenant.name})" + else: + return self.name def get_absolute_url(self) -> str: """Return the absolute URL of the instance.""" From eda434ad6edf969e222896651f187c2d05f1f78f Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 1 Jul 2024 22:12:44 +0200 Subject: [PATCH 08/10] feat(forms): Add validation of ACI EPG for ACI BD ACI Endpoint Group can only assign an ACI Bridge Domain from the same ACI Tenant or from the special ACI Tenant "common". --- .../filtersets/tenant_networks.py | 15 +++++++++++ .../forms/tenant_app_profiles.py | 27 +++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/netbox_aci_plugin/filtersets/tenant_networks.py b/netbox_aci_plugin/filtersets/tenant_networks.py index cb32ea1..790af0f 100644 --- a/netbox_aci_plugin/filtersets/tenant_networks.py +++ b/netbox_aci_plugin/filtersets/tenant_networks.py @@ -164,6 +164,11 @@ class ACIBridgeDomainFilterSet(NetBoxModelFilterSet): choices=BDMultiDestinationFloodingChoices, null_value=None, ) + present_in_aci_tenant_or_common = django_filters.ModelChoiceFilter( + queryset=ACITenant.objects.all(), + method="filter_present_in_aci_tenant_or_common", + label=_("ACI Tenant (ID)"), + ) unknown_ipv4_multicast = django_filters.MultipleChoiceFilter( choices=BDUnknownMulticastChoices, null_value=None, @@ -232,6 +237,16 @@ def search(self, queryset, name, value): ) return queryset.filter(queryset_filter) + def filter_present_in_aci_tenant_or_common( + self, queryset, name, aci_tenant + ): + """Return a QuerySet filtered by given ACI Tenant or 'common'.""" + if aci_tenant is None: + return queryset.none + return queryset.filter( + Q(aci_tenant=aci_tenant) | Q(aci_tenant__name="common") + ) + class ACIBridgeDomainSubnetFilterSet(NetBoxModelFilterSet): """Filter set for ACI Bridge Domain Subnet model.""" diff --git a/netbox_aci_plugin/forms/tenant_app_profiles.py b/netbox_aci_plugin/forms/tenant_app_profiles.py index cba2e09..54652fd 100644 --- a/netbox_aci_plugin/forms/tenant_app_profiles.py +++ b/netbox_aci_plugin/forms/tenant_app_profiles.py @@ -237,7 +237,7 @@ class ACIEndpointGroupForm(NetBoxModelForm): ) aci_vrf = DynamicModelChoiceField( queryset=ACIVRF.objects.all(), - query_params={"aci_tenant_id": "$aci_tenant"}, + query_params={"present_in_aci_tenant_or_common": "$aci_tenant"}, initial_params={"aci_bridge_domains": "$aci_bridge_domain"}, required=False, label=_("ACI VRF"), @@ -245,7 +245,7 @@ class ACIEndpointGroupForm(NetBoxModelForm): aci_bridge_domain = DynamicModelChoiceField( queryset=ACIBridgeDomain.objects.all(), query_params={ - "aci_tenant_id": "$aci_tenant", + "present_in_aci_tenant_or_common": "$aci_tenant", "aci_vrf_id": "$aci_vrf", }, label=_("ACI Bridge Domain"), @@ -367,6 +367,29 @@ class Meta: "tags", ) + def clean(self): + """Cleaning and validation of ACI Endpoint Group Form.""" + + super().clean() + + aci_app_profile = self.cleaned_data.get("aci_app_profile") + aci_bridge_domain = self.cleaned_data.get("aci_bridge_domain") + + if ( + not aci_app_profile.aci_tenant.id + == aci_bridge_domain.aci_tenant.id + and not aci_bridge_domain.aci_tenant.name == "common" + ): + raise forms.ValidationError( + { + "aci_bridge_domain": _( + "A Bridge Domain can only be assigned from the same" + " ACI Tenant as the Endpoint Group or ACI Tenant" + " 'common'." + ) + } + ) + class ACIEndpointGroupBulkEditForm(NetBoxModelBulkEditForm): """NetBox bulk edit form for ACI Endpoint Group model.""" From dfe3e2e30f180256081dd73d3f29bae8eb8120b5 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Tue, 2 Jul 2024 19:43:47 +0200 Subject: [PATCH 09/10] feat(forms): Remove VRF dependency from EPG import ACI Bridge Domains names are unique for each ACI Tenant. Since the model has been extended to include ACITenant, the import QuerySet of ACI Bridge Domains can be filtered by ACITenant only. --- .../forms/tenant_app_profiles.py | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/netbox_aci_plugin/forms/tenant_app_profiles.py b/netbox_aci_plugin/forms/tenant_app_profiles.py index 54652fd..9837d44 100644 --- a/netbox_aci_plugin/forms/tenant_app_profiles.py +++ b/netbox_aci_plugin/forms/tenant_app_profiles.py @@ -659,13 +659,6 @@ class ACIEndpointGroupImportForm(NetBoxModelImportForm): label=_("ACI Application Profile"), help_text=_("Assigned ACI Application Profile"), ) - aci_vrf = CSVModelChoiceField( - queryset=ACIVRF.objects.all(), - to_field_name="name", - required=True, - label=_("ACI VRF"), - help_text=_("Parent ACI VRF of ACI Bridge Domain"), - ) aci_bridge_domain = CSVModelChoiceField( queryset=ACIBridgeDomain.objects.all(), to_field_name="name", @@ -697,7 +690,6 @@ class Meta: "name_alias", "aci_tenant", "aci_app_profile", - "aci_vrf", "aci_bridge_domain", "description", "nb_tenant", @@ -728,15 +720,10 @@ def __init__(self, data=None, *args, **kwargs) -> None: ) self.fields["aci_app_profile"].queryset = aci_appprofile_queryset - # Limit ACIBridgeDomain queryset by parent ACIVRF and ACITenant - if data.get("aci_tenant") and data.get("aci_vrf"): - # Limit ACIVRF queryset by parent ACITenant - self.fields["aci_vrf"].queryset = ACIVRF.objects.filter( - aci_tenant__name=data["aci_tenant"] - ) - # Limit ACIBridgeDomain queryset by parent ACIVRF + # Limit ACIBridgeDomain queryset by ACITenant + if data.get("aci_tenant") and data.get("aci_bridge_domain"): + # Limit ACIBridgeDomain queryset by parent ACITenant aci_bd_queryset = ACIBridgeDomain.objects.filter( - aci_vrf__aci_tenant__name=data["aci_tenant"], - aci_vrf__name=data["aci_vrf"], + aci_tenant__name=data["aci_tenant"] ) self.fields["aci_bridge_domain"].queryset = aci_bd_queryset From 5673271c6ed4fd033d1399a5d3b45e30088cf975 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Tue, 2 Jul 2024 19:50:57 +0200 Subject: [PATCH 10/10] feat(forms): Add import control for BD in common An import of an ACI Endpoint Group may reference an ACI Bridge Domain in the ACI Tenant "common". The boolean field "is_aci_bd_in_common" allows to control, whether the specified ACI Bridge Domain is in the "common" ACI Tenant. --- netbox_aci_plugin/forms/tenant_app_profiles.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/netbox_aci_plugin/forms/tenant_app_profiles.py b/netbox_aci_plugin/forms/tenant_app_profiles.py index 9837d44..5e746aa 100644 --- a/netbox_aci_plugin/forms/tenant_app_profiles.py +++ b/netbox_aci_plugin/forms/tenant_app_profiles.py @@ -666,6 +666,11 @@ class ACIEndpointGroupImportForm(NetBoxModelImportForm): label=_("ACI Bridge Domain"), help_text=_("Assigned ACI Bridge Domain"), ) + is_aci_bd_in_common = forms.BooleanField( + label=_("Is ACI Bridge Domain in 'common'"), + required=False, + help_text=_("Assigned ACI Bridge Domain is in ACI Tenant 'common'"), + ) nb_tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name="name", @@ -693,6 +698,7 @@ class Meta: "aci_bridge_domain", "description", "nb_tenant", + "is_aci_bd_in_common", "admin_shutdown", "custom_qos_policy_name", "flood_in_encap_enabled", @@ -720,8 +726,14 @@ def __init__(self, data=None, *args, **kwargs) -> None: ) self.fields["aci_app_profile"].queryset = aci_appprofile_queryset + # Limit ACIBridgeDomain queryset by "common" ACITenant + if data.get("is_aci_bd_in_common") == "true": + aci_bd_queryset = ACIBridgeDomain.objects.filter( + aci_tenant__name="common" + ) + self.fields["aci_bridge_domain"].queryset = aci_bd_queryset # Limit ACIBridgeDomain queryset by ACITenant - if data.get("aci_tenant") and data.get("aci_bridge_domain"): + elif data.get("aci_tenant") and data.get("aci_bridge_domain"): # Limit ACIBridgeDomain queryset by parent ACITenant aci_bd_queryset = ACIBridgeDomain.objects.filter( aci_tenant__name=data["aci_tenant"]