Repository: nautobot/nautobot

Issue:
Unsetting/clearing a RelationshipAssociation while editing a model doesn't take effect.
<!--
    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
* Python version:
* Nautobot version: 1.0.0b4 (both before and after #321)

<!--
    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. Define a Relationship between models - for example a One-to-One relation between Device <--> IPAddress
2. On an instance of Device, set an IPAddress for this relationship and save the model
3. Confirm that the Device detail page correctly shows the defined relationship association
4. Edit the Device again, clear the association form field (empty value) and save the model again
5. The Device detail page still shows the defined relationship association

<!-- What did you expect to happen? -->
### Expected Behavior

The relationship association should be removed.

<!-- What happened instead? -->
### Observed Behavior

The relationship association still exists.


Discussion:

Resolving patch:
diff --git a/nautobot/extras/forms.py b/nautobot/extras/forms.py
--- a/nautobot/extras/forms.py
+++ b/nautobot/extras/forms.py
@@ -246,11 +246,11 @@ def _append_relationships(self):
                 self.relationships.append(field_name)
 
     def _save_relationships(self):
-        """Save all Relationships on form save."""
+        """Update RelationshipAssociations for all Relationships on form save."""
 
         for field_name in self.relationships:
 
-            # Extract the sidefrom the field_name
+            # Extract the side from the field_name
             # Based on the side, find the list of existing RelationshipAssociation
             side = field_name.split("__")[-1]
             peer_side = RelationshipSideChoices.OPPOSITE[side]
@@ -259,41 +259,50 @@ def _save_relationships(self):
                 f"{side}_type": self.obj_type,
                 f"{side}_id": self.instance.pk,
             }
-            existing_cras = RelationshipAssociation.objects.filter(**filters)
+            existing_associations = RelationshipAssociation.objects.filter(**filters)
 
-            # Extract the list of ids of the target peers
+            # Get the list of target peer ids (PKs) that are specified in the form
             target_peer_ids = []
             if hasattr(self.cleaned_data[field_name], "__iter__"):
+                # One-to-many or many-to-many association
                 target_peer_ids = [item.pk for item in self.cleaned_data[field_name]]
             elif self.cleaned_data[field_name]:
+                # Many-to-one or one-to-one association
                 target_peer_ids = [self.cleaned_data[field_name].pk]
             else:
-                continue
+                # Unset/delete case
+                target_peer_ids = []
 
-            # Delete all existing CRA that are not in cleaned_data/target_peer_ids list
-            # Remove from target_peer_ids all peers that already exist
-            for cra in existing_cras:
-                found_peer = False
+            # Create/delete RelationshipAssociations as needed to match the target_peer_ids list
+
+            # First, for each existing association, if it's one that's already in target_peer_ids,
+            # we can discard it from target_peer_ids (no update needed to this association).
+            # Conversely, if it's *not* in target_peer_ids, we should delete it.
+            for association in existing_associations:
                 for peer_id in target_peer_ids:
-                    if peer_id == getattr(cra, f"{peer_side}_id"):
-                        found_peer = peer_id
-                if not found_peer:
-                    cra.delete()
+                    if peer_id == getattr(association, f"{peer_side}_id"):
+                        # This association already exists, so we can ignore it
+                        target_peer_ids.remove(peer_id)
+                        break
                 else:
-                    target_peer_ids.remove(found_peer)
+                    # This association is not in target_peer_ids, so delete it
+                    association.delete()
 
-            for cra_peer_id in target_peer_ids:
+            # Anything remaining in target_peer_ids now does not exist yet and needs to be created.
+            for peer_id in target_peer_ids:
                 relationship = self.fields[field_name].model
-                cra = RelationshipAssociation(
+                association = RelationshipAssociation(
                     relationship=relationship,
+                    **{
+                        f"{side}_type": self.obj_type,
+                        f"{side}_id": self.instance.pk,
+                        f"{peer_side}_type": getattr(relationship, f"{peer_side}_type"),
+                        f"{peer_side}_id": peer_id,
+                    },
                 )
-                setattr(cra, f"{side}_id", self.instance.pk)
-                setattr(cra, f"{side}_type", self.obj_type)
-                setattr(cra, f"{peer_side}_id", cra_peer_id)
-                setattr(cra, f"{peer_side}_type", getattr(relationship, f"{peer_side}_type"))
 
                 # FIXME Run Clean
-                cra.save()
+                association.save()
 
     def save(self, commit=True):
 


Test patch:
diff --git a/nautobot/dcim/tests/test_views.py b/nautobot/dcim/tests/test_views.py
--- a/nautobot/dcim/tests/test_views.py
+++ b/nautobot/dcim/tests/test_views.py
@@ -4,6 +4,7 @@
 import yaml
 
 from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
 
 # from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
@@ -13,7 +14,8 @@
 from nautobot.dcim.choices import *
 from nautobot.dcim.constants import *
 from nautobot.dcim.models import *
-from nautobot.extras.models import Status
+from nautobot.extras.choices import CustomFieldTypeChoices, RelationshipTypeChoices
+from nautobot.extras.models import CustomField, CustomFieldChoice, Relationship, RelationshipAssociation, Status
 from nautobot.ipam.models import VLAN, IPAddress
 from nautobot.utilities.testing import ViewTestCases, post_data
 
@@ -77,9 +79,52 @@ def setUpTestData(cls):
         status_active = statuses.get(slug="active")
         status_planned = statuses.get(slug="planned")
 
-        Site.objects.create(name="Site 1", slug="site-1", region=regions[0], status=status_planned)
-        Site.objects.create(name="Site 2", slug="site-2", region=regions[0], status=status_planned)
-        Site.objects.create(name="Site 3", slug="site-3", region=regions[0], status=status_planned)
+        cls.custom_fields = (
+            CustomField.objects.create(type=CustomFieldTypeChoices.TYPE_TEXT, name="contact_slack", default=""),
+        )
+        for custom_field in cls.custom_fields:
+            custom_field.content_types.set([ContentType.objects.get_for_model(Site)])
+
+        sites = (
+            Site.objects.create(
+                name="Site 1",
+                slug="site-1",
+                region=regions[0],
+                status=status_planned,
+                _custom_field_data={"contact_slack": "@site-1-manager"},
+            ),
+            Site.objects.create(
+                name="Site 2",
+                slug="site-2",
+                region=regions[0],
+                status=status_planned,
+                _custom_field_data={"contact_slack": "@site-2-manager"},
+            ),
+            Site.objects.create(
+                name="Site 3",
+                slug="site-3",
+                region=regions[0],
+                status=status_planned,
+                _custom_field_data={"contact_slack": "@site-3-manager"},
+            ),
+        )
+
+        cls.relationships = (
+            Relationship.objects.create(
+                name="Region related sites",
+                slug="region-related-sites",
+                type=RelationshipTypeChoices.TYPE_ONE_TO_MANY,
+                source_type=ContentType.objects.get_for_model(Region),
+                source_label="Related sites",
+                destination_type=ContentType.objects.get_for_model(Site),
+                destination_label="Related region",
+            ),
+        )
+
+        for site in sites:
+            RelationshipAssociation.objects.create(
+                relationship=cls.relationships[0], source=site, destination=regions[1]
+            )
 
         tags = cls.create_tags("Alpha", "Bravo", "Charlie")
 
@@ -102,6 +147,8 @@ def setUpTestData(cls):
             "contact_email": "hank@stricklandpropane.com",
             "comments": "Test site",
             "tags": [t.pk for t in tags],
+            "cf_contact_slack": "@site-x-manager",
+            "cr_region-related-sites__destination": regions[0].pk,
         }
 
         cls.csv_data = (
@@ -241,9 +288,41 @@ def setUpTestData(cls):
         statuses = Status.objects.get_for_model(Rack)
         status_active = statuses.get(slug="active")
 
-        Rack.objects.create(name="Rack 1", site=sites[0], status=status_active)
-        Rack.objects.create(name="Rack 2", site=sites[0], status=status_active)
-        Rack.objects.create(name="Rack 3", site=sites[0], status=status_active)
+        cls.custom_fields = (
+            CustomField.objects.create(type=CustomFieldTypeChoices.TYPE_MULTISELECT, name="rack-colors", default=[]),
+        )
+        CustomFieldChoice.objects.create(field=cls.custom_fields[0], value="red")
+        CustomFieldChoice.objects.create(field=cls.custom_fields[0], value="green")
+        CustomFieldChoice.objects.create(field=cls.custom_fields[0], value="blue")
+        for custom_field in cls.custom_fields:
+            custom_field.content_types.set([ContentType.objects.get_for_model(Rack)])
+
+        racks = (
+            Rack.objects.create(
+                name="Rack 1", site=sites[0], status=status_active, _custom_field_data={"rack-colors": ["red"]}
+            ),
+            Rack.objects.create(
+                name="Rack 2", site=sites[0], status=status_active, _custom_field_data={"rack-colors": ["green"]}
+            ),
+            Rack.objects.create(
+                name="Rack 3", site=sites[0], status=status_active, _custom_field_data={"rack-colors": ["blue"]}
+            ),
+        )
+
+        cls.relationships = (
+            Relationship.objects.create(
+                name="Backup Sites",
+                slug="backup-sites",
+                type=RelationshipTypeChoices.TYPE_MANY_TO_MANY,
+                source_type=ContentType.objects.get_for_model(Rack),
+                source_label="Backup site(s)",
+                destination_type=ContentType.objects.get_for_model(Site),
+                destination_label="Racks using this site as a backup",
+            ),
+        )
+
+        for rack in racks:
+            RelationshipAssociation.objects.create(relationship=cls.relationships[0], source=rack, destination=sites[1])
 
         tags = cls.create_tags("Alpha", "Bravo", "Charlie")
 
@@ -266,6 +345,8 @@ def setUpTestData(cls):
             "outer_unit": RackDimensionUnitChoices.UNIT_MILLIMETER,
             "comments": "Some comments",
             "tags": [t.pk for t in tags],
+            "cf_rack-colors": ["red", "green", "blue"],
+            "cr_backup-sites__destination": [sites[0].pk],
         }
 
         cls.csv_data = (
@@ -931,34 +1012,67 @@ def setUpTestData(cls):
         statuses = Status.objects.get_for_model(Device)
         status_active = statuses.get(slug="active")
 
-        Device.objects.create(
-            name="Device 1",
-            site=sites[0],
-            rack=racks[0],
-            device_type=devicetypes[0],
-            device_role=deviceroles[0],
-            platform=platforms[0],
-            status=status_active,
-        )
-        Device.objects.create(
-            name="Device 2",
-            site=sites[0],
-            rack=racks[0],
-            device_type=devicetypes[0],
-            device_role=deviceroles[0],
-            platform=platforms[0],
-            status=status_active,
-        )
-        Device.objects.create(
-            name="Device 3",
-            site=sites[0],
-            rack=racks[0],
-            device_type=devicetypes[0],
-            device_role=deviceroles[0],
-            platform=platforms[0],
-            status=status_active,
+        cls.custom_fields = (
+            CustomField.objects.create(type=CustomFieldTypeChoices.TYPE_INTEGER, name="crash-counter", default=0),
+        )
+        cls.custom_fields[0].content_types.set([ContentType.objects.get_for_model(Device)])
+
+        devices = (
+            Device.objects.create(
+                name="Device 1",
+                site=sites[0],
+                rack=racks[0],
+                device_type=devicetypes[0],
+                device_role=deviceroles[0],
+                platform=platforms[0],
+                status=status_active,
+                _custom_field_data={"crash-counter": 5},
+            ),
+            Device.objects.create(
+                name="Device 2",
+                site=sites[0],
+                rack=racks[0],
+                device_type=devicetypes[0],
+                device_role=deviceroles[0],
+                platform=platforms[0],
+                status=status_active,
+                _custom_field_data={"crash-counter": 10},
+            ),
+            Device.objects.create(
+                name="Device 3",
+                site=sites[0],
+                rack=racks[0],
+                device_type=devicetypes[0],
+                device_role=deviceroles[0],
+                platform=platforms[0],
+                status=status_active,
+                _custom_field_data={"crash-counter": 15},
+            ),
+        )
+
+        cls.relationships = (
+            Relationship.objects.create(
+                name="BGP Router-ID",
+                slug="router-id",
+                type=RelationshipTypeChoices.TYPE_ONE_TO_ONE,
+                source_type=ContentType.objects.get_for_model(Device),
+                source_label="BGP Router ID",
+                destination_type=ContentType.objects.get_for_model(IPAddress),
+                destination_label="Device using this as BGP router-ID",
+            ),
         )
 
+        ipaddresses = (
+            IPAddress.objects.create(address="1.1.1.1/32"),
+            IPAddress.objects.create(address="2.2.2.2/32"),
+            IPAddress.objects.create(address="3.3.3.3/32"),
+        )
+
+        for device, ipaddress in zip(devices, ipaddresses):
+            RelationshipAssociation.objects.create(
+                relationship=cls.relationships[0], source=device, destination=ipaddress
+            )
+
         tags = cls.create_tags("Alpha", "Bravo", "Charlie")
 
         cls.form_data = {
@@ -983,6 +1097,8 @@ def setUpTestData(cls):
             "comments": "A new device",
             "tags": [t.pk for t in tags],
             "local_context_data": None,
+            "cf_crash-counter": -1,
+            "cr_router-id": None,
         }
 
         cls.csv_data = (
diff --git a/nautobot/utilities/testing/views.py b/nautobot/utilities/testing/views.py
--- a/nautobot/utilities/testing/views.py
+++ b/nautobot/utilities/testing/views.py
@@ -12,6 +12,7 @@
 from netaddr import IPNetwork
 from taggit.managers import TaggableManager
 
+from nautobot.extras.choices import CustomFieldTypeChoices, RelationshipSideChoices
 from nautobot.extras.models import Tag
 from nautobot.users.models import ObjectPermission
 from nautobot.utilities.permissions import resolve_permission_ct
@@ -196,6 +197,12 @@ class ModelTestCase(TestCase):
     """
 
     model = None
+    # Optional, list of Relationships populated in setUpTestData for testing with this model
+    # Be sure to also create RelationshipAssociations using these Relationships!
+    relationships = None
+    # Optional, list of CustomFields populated in setUpTestData for testing with this model
+    # Be sure to also populate these fields on your test data!
+    custom_fields = None
 
     def _get_queryset(self):
         """
@@ -278,7 +285,34 @@ def test_get_object_with_permission(self):
             obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
 
             # Try GET with model-level permission
-            self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
+            response = self.client.get(instance.get_absolute_url())
+            self.assertHttpStatus(response, 200)
+
+            # The object's display name or string representation should appear in the response
+            self.assertIn(getattr(instance, "display", str(instance)), str(response.content))
+
+            # If any Relationships are defined, they should appear in the response
+            if self.relationships:
+                for relationship in self.relationships:
+                    content_type = ContentType.objects.get_for_model(instance)
+                    if content_type == relationship.source_type:
+                        self.assertIn(
+                            relationship.get_label(RelationshipSideChoices.SIDE_SOURCE), str(response.content)
+                        )
+                    if content_type == relationship.destination_type:
+                        self.assertIn(
+                            relationship.get_label(RelationshipSideChoices.SIDE_DESTINATION), str(response.content)
+                        )
+
+            # If any Custom Fields are defined, they should appear in the response
+            if self.custom_fields:
+                for custom_field in self.custom_fields:
+                    self.assertIn(str(custom_field), str(response.content))
+                    if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+                        for value in instance.cf.get(custom_field.name):
+                            self.assertIn(str(value), str(response.content))
+                    else:
+                        self.assertIn(str(instance.cf.get(custom_field.name) or ""), str(response.content))
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_get_object_with_constrained_permission(self):