From 112a1c46a79211f8f775e0f4ebbc2ab37232f563 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sat, 19 Jul 2025 16:35:55 +0200 Subject: [PATCH 1/6] Improve StaticRouteForm user experience All fields are grouped into fieldsets. They now support translations if translated messages are available. --- netbox_routing/forms/static.py | 28 ++++++++++++++++++++++++++-- netbox_routing/models/static.py | 15 ++++++++------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/netbox_routing/forms/static.py b/netbox_routing/forms/static.py index dbf8097..c55c602 100644 --- a/netbox_routing/forms/static.py +++ b/netbox_routing/forms/static.py @@ -1,21 +1,45 @@ +from django.utils.translation import gettext as _ + from dcim.models import Device from ipam.models import VRF from netbox.forms import NetBoxModelForm from netbox_routing.models import StaticRoute from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, CommentField +from utilities.forms.rendering import FieldSet class StaticRouteForm(NetBoxModelForm): devices = DynamicModelMultipleChoiceField( - queryset=Device.objects.all() + queryset=Device.objects.all(), + label=_('Devices'), ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF'), ) comments = CommentField() + fieldsets = ( + FieldSet( + 'devices', + 'vrf', + ), + FieldSet( + 'prefix', + 'next_hop', + 'metric', + name=_('Route'), + ), + FieldSet( + 'name', + 'description', + 'tag', + 'permanent', + name=_('Metadata'), + ), + ) + class Meta: model = StaticRoute fields = ( diff --git a/netbox_routing/models/static.py b/netbox_routing/models/static.py index 3ae96f2..b4086a2 100644 --- a/netbox_routing/models/static.py +++ b/netbox_routing/models/static.py @@ -1,6 +1,7 @@ from django.db import models from django.db.models import CheckConstraint, Q from django.urls import reverse +from django.utils.translation import gettext as _ from ipam.fields import IPNetworkField from netbox.models import PrimaryModel @@ -23,16 +24,18 @@ class StaticRoute(PrimaryModel): related_name='staticroutes', blank=True, null=True, - verbose_name='VRF' ) - prefix = IPNetworkField(help_text='IPv4 or IPv6 network with mask') - next_hop = IPAddressField() + prefix = IPNetworkField( + help_text=_('IPv4 or IPv6 network with mask'), + ) + next_hop = IPAddressField( + verbose_name=_('Next Hop'), + ) name = models.CharField( max_length=50, verbose_name='Name', blank=True, null=True, - help_text='Optional name for this static route' ) metric = models.PositiveSmallIntegerField( verbose_name='Metric', @@ -40,10 +43,8 @@ class StaticRoute(PrimaryModel): default=1, ) permanent = models.BooleanField(default=False, blank=True, null=True,) - tag = models.IntegerField( verbose_name='Tag', - help_text='Optional tag for this static route', blank=True, null=True ) @@ -63,7 +64,7 @@ class Meta: models.UniqueConstraint( 'vrf', 'prefix', 'next_hop', name='%(app_label)s_%(class)s_unique_vrf_prefix_nexthop', - violation_error_message="VRF, Prefix and Next Hop must be unique." + violation_error_message=_('VRF, Prefix and Next Hop must be unique.'), ), ) From f77a05722f85cd640a85f5491baa10ce012d8a0a Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sat, 19 Jul 2025 17:00:22 +0200 Subject: [PATCH 2/6] Validate static route IP versions For static routes, the IP versions must match. For example, an IPv4 route must use an IPv4 next-hop gateway. The StaticRoute model now validates this constraint. --- netbox_routing/models/static.py | 8 +++++++ netbox_routing/tests/static/test_models.py | 26 ++++++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/netbox_routing/models/static.py b/netbox_routing/models/static.py index b4086a2..a24a478 100644 --- a/netbox_routing/models/static.py +++ b/netbox_routing/models/static.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.db.models import CheckConstraint, Q from django.urls import reverse @@ -75,3 +76,10 @@ def __str__(self): def get_absolute_url(self): return reverse('plugins:netbox_routing:staticroute', args=[self.pk]) + + def clean(self): + super().clean() + + # IPv4 and IPv6 cannot be mixed. + if self.prefix.version != self.next_hop.version: + raise ValidationError(_('The IP version must be the same for the prefix and next hop.')) diff --git a/netbox_routing/tests/static/test_models.py b/netbox_routing/tests/static/test_models.py index aab5f05..6342278 100644 --- a/netbox_routing/tests/static/test_models.py +++ b/netbox_routing/tests/static/test_models.py @@ -1,9 +1,10 @@ -# from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError from django.test import TestCase +from netaddr import IPAddress, IPNetwork -# from utilities.testing import create_test_device +from utilities.testing import create_test_device -# from netbox_routing.models import * +from netbox_routing.models.static import StaticRoute __all__ = ( 'StaticRouteTestCase', @@ -11,4 +12,21 @@ class StaticRouteTestCase(TestCase): - pass + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.device = create_test_device(name='Device 1') + + def test_clean_ip_versions(self): + # pass: both IPv4 + route1 = StaticRoute(prefix=IPNetwork('0.0.0.0/0'), next_hop=IPAddress('1.2.3.4')) + route1.clean() + + # pass: both IPv6 + route2 = StaticRoute(prefix=IPNetwork('::/0'), next_hop=IPAddress('fe80::1')) + route2.clean() + + # fail: mixed + route3 = StaticRoute(prefix=IPNetwork('0.0.0.0/0'), next_hop=IPAddress('fe80::1')) + self.assertRaises(ValidationError, route3.clean) From 77246c2a4be399e41c89247152d5bc7bb4fcde37 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sat, 19 Jul 2025 17:34:19 +0200 Subject: [PATCH 3/6] Hide static routes if no permissions or empty --- netbox_routing/views/static.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox_routing/views/static.py b/netbox_routing/views/static.py index 0f0563c..c2abc83 100644 --- a/netbox_routing/views/static.py +++ b/netbox_routing/views/static.py @@ -48,6 +48,8 @@ class StaticRouteDevicesView(ObjectChildrenView): tab = ViewTab( label='Assigned Devices', badge=lambda obj: Device.objects.filter(static_routes=obj).count(), + permission='netbox_routing.view_staticroute', + hide_if_empty=True, ) def get_children(self, request, parent): From 1c7baccc7650ba76f72ce76ae5d9dc170ad24b77 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sat, 19 Jul 2025 17:45:39 +0200 Subject: [PATCH 4/6] Pluralice static routes menu item --- netbox_routing/navigation/static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_routing/navigation/static.py b/netbox_routing/navigation/static.py index f5e9013..ae252b5 100644 --- a/netbox_routing/navigation/static.py +++ b/netbox_routing/navigation/static.py @@ -7,7 +7,7 @@ static = PluginMenuItem( link='plugins:netbox_routing:staticroute_list', - link_text='Static Route', + link_text='Static Routes', permissions=['netbox_routing.view_staticroute'], buttons=( PluginMenuButton('plugins:netbox_routing:staticroute_add', 'Add', 'mdi mdi-plus', ColorChoices.GREEN), From 9153b180181a338ee6ea047ac28135f7b5e25650 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sat, 2 Aug 2025 18:17:50 +0200 Subject: [PATCH 5/6] Fix StaticRouteDevicesView permissions --- netbox_routing/views/static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_routing/views/static.py b/netbox_routing/views/static.py index c2abc83..5a99b80 100644 --- a/netbox_routing/views/static.py +++ b/netbox_routing/views/static.py @@ -48,7 +48,7 @@ class StaticRouteDevicesView(ObjectChildrenView): tab = ViewTab( label='Assigned Devices', badge=lambda obj: Device.objects.filter(static_routes=obj).count(), - permission='netbox_routing.view_staticroute', + permission='dcim.view_device', hide_if_empty=True, ) From 476050d015bcd6e97ea0fd2b766f69bd5a583c84 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sat, 2 Aug 2025 22:29:55 +0200 Subject: [PATCH 6/6] Revert "Validate static route IP versions" This reverts commit f77a05722f85cd640a85f5491baa10ce012d8a0a, as IP routes indeed may mix IP versions for forwarding. --- netbox_routing/models/static.py | 8 ------- netbox_routing/tests/static/test_models.py | 25 +--------------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/netbox_routing/models/static.py b/netbox_routing/models/static.py index e145124..af87a69 100644 --- a/netbox_routing/models/static.py +++ b/netbox_routing/models/static.py @@ -1,4 +1,3 @@ -from django.core.exceptions import ValidationError from django.db import models from django.db.models import CheckConstraint, Q from django.urls import reverse @@ -73,10 +72,3 @@ def __str__(self): def get_absolute_url(self): return reverse('plugins:netbox_routing:staticroute', args=[self.pk]) - - def clean(self): - super().clean() - - # IPv4 and IPv6 cannot be mixed. - if self.prefix.version != self.next_hop.version: - raise ValidationError(_('The IP version must be the same for the prefix and next hop.')) diff --git a/netbox_routing/tests/static/test_models.py b/netbox_routing/tests/static/test_models.py index 6342278..4bcb98a 100644 --- a/netbox_routing/tests/static/test_models.py +++ b/netbox_routing/tests/static/test_models.py @@ -1,10 +1,4 @@ -from django.core.exceptions import ValidationError from django.test import TestCase -from netaddr import IPAddress, IPNetwork - -from utilities.testing import create_test_device - -from netbox_routing.models.static import StaticRoute __all__ = ( 'StaticRouteTestCase', @@ -12,21 +6,4 @@ class StaticRouteTestCase(TestCase): - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.device = create_test_device(name='Device 1') - - def test_clean_ip_versions(self): - # pass: both IPv4 - route1 = StaticRoute(prefix=IPNetwork('0.0.0.0/0'), next_hop=IPAddress('1.2.3.4')) - route1.clean() - - # pass: both IPv6 - route2 = StaticRoute(prefix=IPNetwork('::/0'), next_hop=IPAddress('fe80::1')) - route2.clean() - - # fail: mixed - route3 = StaticRoute(prefix=IPNetwork('0.0.0.0/0'), next_hop=IPAddress('fe80::1')) - self.assertRaises(ValidationError, route3.clean) + pass