Repository: nautobot/nautobot

Issue:
Relationship Associations remain when associated objects are deleted
<!--
    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: 3.8.5
* Nautobot version: v1.0.1

<!--
    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. Create a many-to-many relationship between ipam.vlan and ipam.vlan_groups
2. Create multiple VLANs in site-X
3. Create a Vlan Group in site-X
4. Create associations by assigning VLANs to the VLAN Group 
5. Delete the VLAN Group object

# Relationship
```
        {
            "id": "f33aac1f-3b3c-4d35-89e4-a6a565a6594c",
            "url": "http://127.0.0.1/api/extras/relationships/f33aac1f-3b3c-4d35-89e4-a6a565a6594c/",
            "name": "vlan-vlangroup",
            "slug": "vlan-vlangroup",
            "description": "VLAN to VLAN Group mapping",
            "type": "many-to-many",
            "source_type": "ipam.vlan",
            "source_label": "",
            "source_hidden": false,
            "source_filter": {
                "site": "brq2"
            },
            "destination_type": "ipam.vlangroup",
            "destination_label": "",
            "destination_hidden": false,
            "destination_filter": null
        }
```

<!-- What did you expect to happen? -->
### Expected Behavior
Any relationship associations including the deleted VLAN Group, should also be deleted.

<!-- What happened instead? -->
### Observed Behavior
All relationship associations remain in the database.

```
        {
            "id": "cb2f5c23-e323-4d6e-bf41-62be5ec30c26",
            "relationship": {
                "id": "f33aac1f-3b3c-4d35-89e4-a6a565a6594c",
                "url": "http://127.0.0.1/api/extras/relationships/f33aac1f-3b3c-4d35-89e4-a6a565a6594c/",
                "name": "vlan-vlangroup",
                "slug": "vlan-vlangroup",
                "display": "vlan-vlangroup"
            },
            "source_type": "ipam.vlan",
            "source_id": "15cda72f-bdb8-5d9c-88d8-17c6d5fdd979",
            "destination_type": "ipam.vlangroup",
            "destination_id": "fa0eecbd-fcc1-4435-9d44-72e31554f176"
        },
```

NOTE: no VLANs exist with the native 1:M mapping during the procedure.

The relationships are available to view on the UI. The VLAN Group target object is `None` on the UI.

![Screenshot 2021-05-17 at 14 39 49](https://user-images.githubusercontent.com/23073233/118557161-b5ce4300-b75c-11eb-8656-53c59ab335f2.png)


Discussion:

Resolving patch:
diff --git a/nautobot/extras/models/relationships.py b/nautobot/extras/models/relationships.py
--- a/nautobot/extras/models/relationships.py
+++ b/nautobot/extras/models/relationships.py
@@ -1,7 +1,7 @@
 import logging
 from collections import OrderedDict
 from django.contrib.contenttypes.models import ContentType
-from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.validators import ValidationError
 from django.db import models
@@ -32,6 +32,22 @@ class RelationshipModel(models.Model):
     class Meta:
         abstract = True
 
+    # Define GenericRelations so that deleting a RelationshipModel instance
+    # cascades to deleting any RelationshipAssociations that were using this instance,
+    # and also for convenience in looking up the RelationshipModels associated to any given RelationshipAssociation
+    source_for_associations = GenericRelation(
+        "extras.RelationshipAssociation",
+        content_type_field="source_type",
+        object_id_field="source_id",
+        related_query_name="source_%(app_label)s_%(class)s",  # e.g. 'source_dcim_site', 'source_ipam_vlan'
+    )
+    destination_for_associations = GenericRelation(
+        "extras.RelationshipAssociation",
+        content_type_field="destination_type",
+        object_id_field="destination_id",
+        related_query_name="destination_%(app_label)s_%(class)s",  # e.g. 'destination_dcim_rack'
+    )
+
     def get_relationships(self, include_hidden=False):
         """
         Return a dictionary of queryset for all custom relationships
diff --git a/tasks.py b/tasks.py
--- a/tasks.py
+++ b/tasks.py
@@ -501,13 +501,14 @@ def integration_test(
 @task(
     help={
         "lint-only": "Only run linters; unit tests will be excluded.",
+        "keepdb": "Save and re-use test database between test runs for faster re-testing.",
     }
 )
-def tests(context, lint_only=False):
+def tests(context, lint_only=False, keepdb=False):
     """Run all tests and linters."""
     black(context)
     flake8(context)
     hadolint(context)
     check_migrations(context)
     if not lint_only:
-        unittest(context)
+        unittest(context, keepdb=keepdb)


Test patch:
diff --git a/nautobot/extras/tests/test_relationships.py b/nautobot/extras/tests/test_relationships.py
--- a/nautobot/extras/tests/test_relationships.py
+++ b/nautobot/extras/tests/test_relationships.py
@@ -166,7 +166,6 @@ def test_clean_valid(self):
         )
 
         m2m.clean()
-        self.assertTrue(True)
 
     def test_get_label_input(self):
         with self.assertRaises(ValueError):
@@ -279,9 +278,13 @@ def test_clean_check_quantity_o2m(self):
         cra.save()
 
         with self.assertRaises(ValidationError) as handler:
-            cra = RelationshipAssociation(relationship=self.o2o_1, source=self.sites[2], destination=self.vlans[0])
+            cra = RelationshipAssociation(relationship=self.o2m_1, source=self.sites[2], destination=self.vlans[0])
             cra.clean()
-        expected_errors = {"source_type": ["source_type has a different value than defined in Primary Rack per Site"]}
+        expected_errors = {
+            "destination": [
+                "Unable to create more than one generic site to vlan association to VLAN A (100) (destination)",
+            ],
+        }
         self.assertEqual(handler.exception.message_dict, expected_errors)
 
     def test_clean_check_quantity_m2m(self):
@@ -302,9 +305,66 @@ def test_clean_check_quantity_m2m(self):
         cra.clean()
 
     def test_get_peer(self):
+        """Validate that the get_peer() method works correctly."""
         cra = RelationshipAssociation(relationship=self.m2m_1, source=self.racks[0], destination=self.vlans[0])
         cra.save()
 
         self.assertEqual(cra.get_peer(self.racks[0]), self.vlans[0])
         self.assertEqual(cra.get_peer(self.vlans[0]), self.racks[0])
         self.assertEqual(cra.get_peer(self.vlans[1]), None)
+
+    def test_delete_cascade(self):
+        """Verify that a RelationshipAssociation is deleted if either of the associated records is deleted."""
+        RelationshipAssociation.objects.create(relationship=self.m2m_1, source=self.racks[0], destination=self.vlans[0])
+        RelationshipAssociation.objects.create(relationship=self.m2m_1, source=self.racks[0], destination=self.vlans[1])
+        RelationshipAssociation.objects.create(relationship=self.m2m_1, source=self.racks[1], destination=self.vlans[0])
+
+        self.assertEqual(3, RelationshipAssociation.objects.count())
+
+        # Test automatic deletion of RelationshipAssociations when their 'source' object is deleted
+        self.racks[0].delete()
+
+        # Both relations involving racks[0] should have been deleted
+        # The relation between racks[1] and vlans[0] should remain
+        self.assertEqual(1, RelationshipAssociation.objects.count())
+
+        # Test automatic deletion of RelationshipAssociations when their 'destination' object is deleted
+        self.vlans[0].delete()
+
+        self.assertEqual(0, RelationshipAssociation.objects.count())
+
+    def test_generic_relation(self):
+        """Verify that the GenericRelations on the involved models work correctly."""
+        associations = (
+            RelationshipAssociation(relationship=self.m2m_1, source=self.racks[0], destination=self.vlans[0]),
+            RelationshipAssociation(relationship=self.m2m_1, source=self.racks[0], destination=self.vlans[1]),
+            RelationshipAssociation(relationship=self.o2o_1, source=self.racks[0], destination=self.sites[0]),
+        )
+        for association in associations:
+            association.validated_save()
+
+        # Check that the GenericRelation lookup works correctly
+        self.assertEqual(3, self.racks[0].source_for_associations.count())
+        self.assertEqual(0, self.racks[0].destination_for_associations.count())
+        self.assertEqual(0, self.vlans[0].source_for_associations.count())
+        self.assertEqual(1, self.vlans[0].destination_for_associations.count())
+
+        # Check that the related_query_names work correctly for each individual RelationshipAssociation
+        self.assertEqual([self.racks[0]], list(associations[0].source_dcim_rack.all()))
+        self.assertEqual([self.vlans[0]], list(associations[0].destination_ipam_vlan.all()))
+        self.assertEqual([], list(associations[0].destination_dcim_site.all()))
+
+        self.assertEqual([self.racks[0]], list(associations[1].source_dcim_rack.all()))
+        self.assertEqual([self.vlans[1]], list(associations[1].destination_ipam_vlan.all()))
+        self.assertEqual([], list(associations[1].destination_dcim_site.all()))
+
+        self.assertEqual([self.racks[0]], list(associations[2].source_dcim_rack.all()))
+        self.assertEqual([], list(associations[2].destination_ipam_vlan.all()))
+        self.assertEqual([self.sites[0]], list(associations[2].destination_dcim_site.all()))
+
+        # Check that the related query names can be used for filtering as well
+        self.assertEqual(3, RelationshipAssociation.objects.filter(source_dcim_rack=self.racks[0]).count())
+        self.assertEqual(2, RelationshipAssociation.objects.filter(destination_ipam_vlan__isnull=False).count())
+        self.assertEqual(1, RelationshipAssociation.objects.filter(destination_ipam_vlan=self.vlans[0]).count())
+        self.assertEqual(1, RelationshipAssociation.objects.filter(destination_ipam_vlan=self.vlans[1]).count())
+        self.assertEqual(1, RelationshipAssociation.objects.filter(destination_dcim_site=self.sites[0]).count())