Repository: nautobot/nautobot

Issue:
IPAddress Missing ip_version when created by host/mask_length
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Nautobot version (Docker tag too if applicable): 2.1.2
* Python version: 3.9
* Database platform, version: Postgres
* Middleware(s): SSoT Framework

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Setup Nautobot.
2. Create IPAddress object using `host`, `mask_length`, and `namespace` fields.
3. Attempt to assign IPAddress as primary IP to Device and run validated_save().
4. See ValidationError regarding IPAddress not being IPv4.
5. Evaluate IPAddress field values and see that ip_version is None.

<!-- What did you expect to happen? -->
### Expected Behavior
Expect that IPAddress is created and has ip_version defined using host and mask_length.

<!-- What happened instead? -->
### Observed Behavior
It appears IPAddress is created but the `_deconstruct_address` method is never called to have ip_version defined so will fail when trying to use as primary IP.
IPAddress Missing ip_version when created by host/mask_length
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Nautobot version (Docker tag too if applicable): 2.1.2
* Python version: 3.9
* Database platform, version: Postgres
* Middleware(s): SSoT Framework

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Setup Nautobot.
2. Create IPAddress object using `host`, `mask_length`, and `namespace` fields.
3. Attempt to assign IPAddress as primary IP to Device and run validated_save().
4. See ValidationError regarding IPAddress not being IPv4.
5. Evaluate IPAddress field values and see that ip_version is None.

<!-- What did you expect to happen? -->
### Expected Behavior
Expect that IPAddress is created and has ip_version defined using host and mask_length.

<!-- What happened instead? -->
### Observed Behavior
It appears IPAddress is created but the `_deconstruct_address` method is never called to have ip_version defined so will fail when trying to use as primary IP.


Discussion:
This will directly impact anybody trying to create IPAddresses in an SSoT App if they're using the NautobotModel(s)/NautobotAdapter pattern as you can't use class properties as class attributes on the NautobotModel.
Lets add the logic in `save` to populate this field in 2.1 and then make the field non-nullable in 2.2.
Similar logic needed in the Prefix model as well.
Actually Prefix works correctly currently due to a nuance of its implementation. I'll still add some test cases to prove this though.
This will directly impact anybody trying to create IPAddresses in an SSoT App if they're using the NautobotModel(s)/NautobotAdapter pattern as you can't use class properties as class attributes on the NautobotModel.
Lets add the logic in `save` to populate this field in 2.1 and then make the field non-nullable in 2.2.
Similar logic needed in the Prefix model as well.
Actually Prefix works correctly currently due to a nuance of its implementation. I'll still add some test cases to prove this though.

Resolving patch:
diff --git a/nautobot/ipam/models.py b/nautobot/ipam/models.py
--- a/nautobot/ipam/models.py
+++ b/nautobot/ipam/models.py
@@ -617,6 +617,8 @@ def delete(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         if isinstance(self.prefix, netaddr.IPNetwork):
             # Clear host bits from prefix
+            # This also has the subtle side effect of calling self._deconstruct_prefix(),
+            # which will (re)set the broadcast and ip_version values of this instance to their correct values.
             self.prefix = self.prefix.cidr
 
         # Determine if a parent exists and set it to the closest ancestor by `prefix_length`.
@@ -1117,6 +1119,8 @@ def save(self, *args, **kwargs):
         #     err_msg = f"IP addresses cannot be created in {self.parent.type} prefixes. You must create a network prefix first."
         #     raise ValidationError({"address": err_msg})
 
+        self.address = self.address  # not a no-op - forces re-calling of self._deconstruct_address()
+
         # Force dns_name to lowercase
         if not self.dns_name.islower:
             self.dns_name = self.dns_name.lower()


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
@@ -303,6 +303,56 @@ def test_prefix_validation(self):
             prefix.validated_save()
         self.assertIn(f'Prefixes may not associate to locations of type "{location_type.name}"', str(cm.exception))
 
+    def test_create_field_population(self):
+        """Test the various ways of creating a Prefix all result in correctly populated fields."""
+        with self.subTest("Creation with a prefix and status"):
+            prefix = Prefix(prefix="192.0.3.0/24", status=self.status)
+            prefix.save()
+            self.assertEqual(prefix.network, "192.0.3.0")
+            self.assertEqual(prefix.broadcast, "192.0.3.255")
+            self.assertEqual(prefix.prefix_length, 24)
+            self.assertEqual(prefix.type, PrefixTypeChoices.TYPE_NETWORK)  # default value
+            # parent field is tested exhaustively below
+            self.assertEqual(prefix.ip_version, 4)
+            self.assertEqual(prefix.namespace, get_default_namespace())  # default value
+
+        with self.subTest("Creation with a network, broadcast, and prefix_length"):
+            prefix = Prefix(network="192.0.4.0", broadcast="192.0.4.255", prefix_length=24, status=self.status)
+            prefix.save()
+            self.assertEqual(prefix.network, "192.0.4.0")
+            self.assertEqual(prefix.broadcast, "192.0.4.255")
+            self.assertEqual(prefix.prefix_length, 24)
+            self.assertEqual(prefix.type, PrefixTypeChoices.TYPE_NETWORK)  # default value
+            # parent field is tested exhaustively below
+            self.assertEqual(prefix.ip_version, 4)
+            self.assertEqual(prefix.namespace, get_default_namespace())  # default value
+            self.assertEqual(prefix.prefix, netaddr.IPNetwork("192.0.4.0/24"))
+
+        with self.subTest("Creation with conflicting values - prefix takes precedence"):
+            prefix = Prefix(
+                prefix="192.0.5.0/24",
+                status=self.status,
+                network="1.1.1.1",
+                broadcast="2.2.2.2",
+                prefix_length=27,
+                ip_version=6,
+            )
+            prefix.save()
+            self.assertEqual(prefix.network, "192.0.5.0")
+            self.assertEqual(prefix.broadcast, "192.0.5.255")
+            self.assertEqual(prefix.prefix_length, 24)
+            self.assertEqual(prefix.ip_version, 4)
+
+        with self.subTest("Creation with conflicting values - network and prefix_length take precedence"):
+            prefix = Prefix(
+                status=self.status, network="192.0.6.0", prefix_length=24, broadcast="192.0.6.127", ip_version=6
+            )
+            prefix.save()
+            self.assertEqual(prefix.network, "192.0.6.0")
+            self.assertEqual(prefix.broadcast, "192.0.6.255")
+            self.assertEqual(prefix.prefix_length, 24)
+            self.assertEqual(prefix.ip_version, 4)
+
     def test_tree_methods(self):
         """Test the various tree methods work as expected."""
 
@@ -855,6 +905,40 @@ def setUp(self):
         self.status = Status.objects.get(name="Active")
         self.prefix = Prefix.objects.create(prefix="192.0.2.0/24", status=self.status, namespace=self.namespace)
 
+    def test_create_field_population(self):
+        """Test that the various ways of creating an IPAddress result in correctly populated fields."""
+        if self.namespace != get_default_namespace():
+            prefix = Prefix.objects.create(prefix="192.0.2.0/24", status=self.status, namespace=get_default_namespace())
+        else:
+            prefix = self.prefix
+
+        with self.subTest("Creation with an address"):
+            ip = IPAddress(address="192.0.2.1/24", status=self.status)
+            ip.save()
+            self.assertEqual(ip.host, "192.0.2.1")
+            self.assertEqual(ip.mask_length, 24)
+            self.assertEqual(ip.type, IPAddressTypeChoices.TYPE_HOST)  # default value
+            self.assertEqual(ip.parent, prefix)
+            self.assertEqual(ip.ip_version, 4)
+
+        with self.subTest("Creation with a host and mask_length"):
+            ip = IPAddress(host="192.0.2.2", mask_length=24, status=self.status)
+            ip.save()
+            self.assertEqual(ip.host, "192.0.2.2")
+            self.assertEqual(ip.mask_length, 24)
+            self.assertEqual(ip.type, IPAddressTypeChoices.TYPE_HOST)  # default value
+            self.assertEqual(ip.parent, prefix)
+            self.assertEqual(ip.ip_version, 4)
+
+        with self.subTest("Creation with conflicting values - address takes precedence"):
+            ip = IPAddress(address="192.0.2.3/24", host="1.1.1.1", mask_length=32, ip_version=6, status=self.status)
+            ip.save()
+            self.assertEqual(ip.host, "192.0.2.3")
+            self.assertEqual(ip.mask_length, 24)
+            self.assertEqual(ip.type, IPAddressTypeChoices.TYPE_HOST)  # default value
+            self.assertEqual(ip.parent, prefix)
+            self.assertEqual(ip.ip_version, 4)
+
     #
     # Uniqueness enforcement tests
     #