Repository: nautobot/nautobot

Issue:
Refine natural keys for VLANGroup and Prefix
- [x] Remove overridden natural-key for VLANGroup, allow it to just be `name`
- [x] Investigate feasibility of using `prefix` instead of `network, prefix_length` as the composite-key for Prefix


Discussion:

Resolving patch:
diff --git a/nautobot/core/models/__init__.py b/nautobot/core/models/__init__.py
--- a/nautobot/core/models/__init__.py
+++ b/nautobot/core/models/__init__.py
@@ -1,7 +1,9 @@
 import uuid
 
+from django.core.exceptions import FieldDoesNotExist
 from django.db import models
 from django.urls import NoReverseMatch, reverse
+from django.utils.encoding import is_protected_type
 from django.utils.functional import classproperty
 
 from nautobot.core.models.managers import BaseManager
@@ -93,6 +95,8 @@ def natural_key(self) -> list:
                 val = getattr(val, lookup)
                 if val is None:
                     break
+            if not is_protected_type(val):
+                val = str(val)
             vals.append(val)
         # Strip trailing Nones from vals
         while vals and vals[-1] is None:
@@ -159,7 +163,14 @@ def natural_key_field_lookups(cls):  # pylint: disable=no-self-argument
             for field_component in field_name.split("__")[:-1]:
                 model = model._meta.get_field(field_component).remote_field.model
 
-            field = model._meta.get_field(field_name.split("__")[-1])
+            try:
+                field = model._meta.get_field(field_name.split("__")[-1])
+            except FieldDoesNotExist:
+                # Not a database field, maybe it's a property instead?
+                if hasattr(model, field_name) and isinstance(getattr(model, field_name), property):
+                    natural_key_field_lookups.append(field_name)
+                    continue
+                raise
 
             if getattr(field, "remote_field", None) is None:
                 # Not a related field, so the field name is the field lookup
diff --git a/nautobot/ipam/models.py b/nautobot/ipam/models.py
--- a/nautobot/ipam/models.py
+++ b/nautobot/ipam/models.py
@@ -531,6 +531,8 @@ def __init__(self, *args, **kwargs):
     def __str__(self):
         return str(self.prefix)
 
+    natural_key_field_names = ["namespace", "prefix"]
+
     def _deconstruct_prefix(self, prefix):
         if prefix:
             if isinstance(prefix, str):
@@ -1223,8 +1225,6 @@ class Meta:
         verbose_name = "VLAN group"
         verbose_name_plural = "VLAN groups"
 
-    natural_key_field_names = ["name", "location"]  # location needs to be last since it's a variadic natural key
-
     def clean(self):
         super().clean()
 
@@ -1306,7 +1306,7 @@ class VLAN(PrimaryModel):
         "description",
     ]
 
-    natural_key_field_names = ["vid", "vlan_group"]
+    natural_key_field_names = ["pk"]
 
     class Meta:
         ordering = (
@@ -1426,7 +1426,7 @@ def __str__(self):
     def parent(self):
         return self.device or self.virtual_machine
 
-    natural_key_field_names = ["name", "device", "virtual_machine"]
+    natural_key_field_names = ["name", "virtual_machine", "device"]
 
     def clean(self):
         super().clean()
diff --git a/nautobot/ipam/querysets.py b/nautobot/ipam/querysets.py
--- a/nautobot/ipam/querysets.py
+++ b/nautobot/ipam/querysets.py
@@ -6,6 +6,7 @@
 from django.db.models.functions import Length
 
 from nautobot.core.models.querysets import RestrictedQuerySet
+from nautobot.core.models.utils import deconstruct_composite_key
 from nautobot.ipam.constants import IPV4_BYTE_LENGTH, IPV6_BYTE_LENGTH
 
 
@@ -260,10 +261,17 @@ def get(self, *args, prefix=None, **kwargs):
             kwargs["broadcast"] = last_ip
         return super().get(*args, **kwargs)
 
-    def filter(self, *args, prefix=None, **kwargs):
+    def filter(self, *args, **kwargs):
         """
         Provide a convenience for `.filter(prefix=<prefix>)`
         """
+        # Copied from RestrictedQuerySet.filter() because this needs to happen *before* we deconstruct 'prefix'
+        composite_key = kwargs.pop("composite_key", None)
+        if composite_key:
+            values = deconstruct_composite_key(composite_key)
+            kwargs.update(self.model.natural_key_args_to_kwargs(values))
+
+        prefix = kwargs.pop("prefix", None)
         if prefix:
             _prefix = netaddr.IPNetwork(prefix)
             last_ip = self._get_last_ip(_prefix)


Test patch:
diff --git a/nautobot/ipam/tests/test_models.py b/nautobot/ipam/tests/test_models.py
--- a/nautobot/ipam/tests/test_models.py
+++ b/nautobot/ipam/tests/test_models.py
@@ -11,14 +11,22 @@
 from nautobot.dcim import choices as dcim_choices
 from nautobot.dcim.models import Device, DeviceType, Interface, Location, LocationType
 from nautobot.extras.models import Role, Status
-from nautobot.ipam.choices import IPAddressTypeChoices, PrefixTypeChoices
-from nautobot.ipam.models import IPAddress, IPAddressToInterface, Namespace, Prefix, VLAN, VLANGroup
+from nautobot.ipam.choices import IPAddressTypeChoices, PrefixTypeChoices, ServiceProtocolChoices
+from nautobot.ipam.models import (
+    IPAddress,
+    IPAddressToInterface,
+    Namespace,
+    Prefix,
+    RIR,
+    RouteTarget,
+    Service,
+    VLAN,
+    VLANGroup,
+    VRF,
+)
 from nautobot.virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
 
 
-# TODO(jathan): Add VRF model tests.
-
-
 class IPAddressToInterfaceTest(TestCase):
     """Tests for `nautobot.ipam.models.IPAddressToInterface`."""
 
@@ -258,6 +266,10 @@ def test_get_db_prep_value_mysql(self):
         self.assertEqual(prepped, manual)
 
 
+class TestNamespace(ModelTestCases.BaseModelTestCase):
+    model = Namespace
+
+
 class TestPrefix(ModelTestCases.BaseModelTestCase):
     model = Prefix
 
@@ -935,6 +947,34 @@ def test_parenting_constraints(self):
             IPAddress.objects.create(address="11.0.0.9/32", status=self.status, namespace=namespace)
 
 
+class TestRIR(ModelTestCases.BaseModelTestCase):
+    model = RIR
+
+
+class TestRouteTarget(ModelTestCases.BaseModelTestCase):
+    model = RouteTarget
+
+
+class TestService(ModelTestCases.BaseModelTestCase):
+    model = Service
+
+    @classmethod
+    def setUpTestData(cls):
+        location = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
+        devicetype = DeviceType.objects.first()
+        devicerole = Role.objects.get_for_model(Device).first()
+        devicestatus = Status.objects.get_for_model(Device).first()
+        device = Device.objects.create(
+            name="Device 1", location=location, device_type=devicetype, role=devicerole, status=devicestatus
+        )
+        Service.objects.create(
+            device=device,
+            name="Service 1",
+            protocol=ServiceProtocolChoices.PROTOCOL_TCP,
+            ports=[101],
+        )
+
+
 class TestVLANGroup(ModelTestCases.BaseModelTestCase):
     model = VLANGroup
 
@@ -963,7 +1003,7 @@ def test_get_next_available_vid(self):
         self.assertEqual(vlangroup.get_next_available_vid(), 6)
 
 
-class VLANTestCase(ModelTestCases.BaseModelTestCase):
+class TestVLAN(ModelTestCases.BaseModelTestCase):
     model = VLAN
 
     def test_vlan_validation(self):
@@ -990,3 +1030,8 @@ def test_vlan_validation(self):
             f'The assigned group belongs to a location that does not include location "{location.name}"',
             str(cm.exception),
         )
+
+
+class TestVRF(ModelTestCases.BaseModelTestCase):
+    model = VRF
+    # TODO(jathan): Add VRF model tests.