Repository: nautobot/nautobot

Issue:
Custom Relationships require get_absolute_url method
### Environment
* Python version: 3.7.12
* Nautobot version: 1.2.3

### Steps to Reproduce

1. Go to add a custom relationship, such as https://demo.nautobot.com/extras/relationships/add/
2. Create with a source type `circuit | termination`
3. Create with a destination type `circuit | provider`
4. Add a relationship from `circuit | termination`
5. Go to detail on that provider

<!-- What did you expect to happen? -->
### Expected Behavior

To render correctly

### Observed Behavior

![image](https://user-images.githubusercontent.com/9260483/149604219-843bd65f-d476-4161-a539-a334ff975e26.png)



It would seem that `get_absolute_url` is required. I am not sure it should be required or if all models should have that method. 
Custom Relationships require get_absolute_url method
### Environment
* Python version: 3.7.12
* Nautobot version: 1.2.3

### Steps to Reproduce

1. Go to add a custom relationship, such as https://demo.nautobot.com/extras/relationships/add/
2. Create with a source type `circuit | termination`
3. Create with a destination type `circuit | provider`
4. Add a relationship from `circuit | termination`
5. Go to detail on that provider

<!-- What did you expect to happen? -->
### Expected Behavior

To render correctly

### Observed Behavior

![image](https://user-images.githubusercontent.com/9260483/149604219-843bd65f-d476-4161-a539-a334ff975e26.png)



It would seem that `get_absolute_url` is required. I am not sure it should be required or if all models should have that method. 


Discussion:
Any of the `*Template` models (`ConsolePortTemplate`, `InterfaceTemplate`, etc.) are also `RelationshipModel`s but also lack individual `get_absolute_url` methods, so they'll show this same issue.
@glennmatthews so I understand that this is the way it is, but is it the way it should be? 
> @glennmatthews so I understand that this is the way it is, but is it the way it should be?

IMHO (as I commented on #1280) the `get_relationships_data()` method should be updated to gracefully handle the case where a RelationshipModel doesn't provide a `get_absolute_url` method, rather than erroring as it does above. However, based on some related investigation that @briddo has done while working on #1279, there are other moving parts (such as the RelationshipAssociation table view) that also rely on `get_absolute_url` and fail if it's missing, so the necessary fix may just be to implement this method on all appropriate models.
ok, thanks! I will try and document this as well. 
As noted in here and #1280, there's some additional work needed to fully resolve this IMHO:

1) Add appropriate `get_absolute_url` implementations to the `*Template` models
2) Fix `get_relationships_data()` so that it doesn't error out if a model doesn't have a `get_absolute_url` method.
Worth capturing as a related symptom in `get_relationships_data()` - if a relationship is defined between a core model and a plugin-defined model, and the plugin is subsequently uninstalled, it appears that viewing the core model will throw an exception:

```
2022-02-17 14:42:32,844 ERROR Internal Server Error: /dcim/devices/a969230a-94cd-4071-af72-21a65c964725/ - django.request - log - /usr/local/lib/python3.7/site-packages/django/utils/log.py:230
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.7/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/usr/local/lib/python3.7/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/nautobot/utilities/views.py", line 94, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/nautobot/core/views/generic.py", line 124, in get
    **self.get_extra_context(request, instance),
  File "/usr/local/lib/python3.7/site-packages/django/shortcuts.py", line 19, in render
    content = loader.render_to_string(template_name, context, request, using=using)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader.py", line 62, in render_to_string
    return template.render(context, request)
  File "/usr/local/lib/python3.7/site-packages/django/template/backends/django.py", line 61, in render
    return self.template.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 170, in render
    return self._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 62, in render
    result = block.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 192, in render
    return template.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 172, in render
    return self._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/defaulttags.py", line 514, in render
    values = {key: val.resolve(context) for key, val in self.extra_context.items()}
  File "/usr/local/lib/python3.7/site-packages/django/template/defaulttags.py", line 514, in <dictcomp>
    values = {key: val.resolve(context) for key, val in self.extra_context.items()}
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 671, in resolve
    obj = self.var.resolve(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 796, in resolve
    value = self._resolve_lookup(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 858, in _resolve_lookup
    current = current()
  File "/usr/local/lib/python3.7/site-packages/nautobot/extras/models/relationships.py", line 187, in get_relationships_data
    peer = association.get_peer(self)
  File "/usr/local/lib/python3.7/site-packages/nautobot/extras/models/relationships.py", line 512, in get_peer
    if obj == self.source:
  File "/usr/local/lib/python3.7/site-packages/django/contrib/contenttypes/fields.py", line 242, in __get__
    rel_obj = ct.get_object_for_this_type(pk=pk_val)
  File "/usr/local/lib/python3.7/site-packages/django/contrib/contenttypes/models.py", line 175, in get_object_for_this_type
    return self.model_class()._base_manager.using(self._state.db).get(**kwargs)
AttributeError: 'NoneType' object has no attribute '_base_manager'
```
Any of the `*Template` models (`ConsolePortTemplate`, `InterfaceTemplate`, etc.) are also `RelationshipModel`s but also lack individual `get_absolute_url` methods, so they'll show this same issue.
@glennmatthews so I understand that this is the way it is, but is it the way it should be? 
> @glennmatthews so I understand that this is the way it is, but is it the way it should be?

IMHO (as I commented on #1280) the `get_relationships_data()` method should be updated to gracefully handle the case where a RelationshipModel doesn't provide a `get_absolute_url` method, rather than erroring as it does above. However, based on some related investigation that @briddo has done while working on #1279, there are other moving parts (such as the RelationshipAssociation table view) that also rely on `get_absolute_url` and fail if it's missing, so the necessary fix may just be to implement this method on all appropriate models.
ok, thanks! I will try and document this as well. 
As noted in here and #1280, there's some additional work needed to fully resolve this IMHO:

1) Add appropriate `get_absolute_url` implementations to the `*Template` models
2) Fix `get_relationships_data()` so that it doesn't error out if a model doesn't have a `get_absolute_url` method.
Worth capturing as a related symptom in `get_relationships_data()` - if a relationship is defined between a core model and a plugin-defined model, and the plugin is subsequently uninstalled, it appears that viewing the core model will throw an exception:

```
2022-02-17 14:42:32,844 ERROR Internal Server Error: /dcim/devices/a969230a-94cd-4071-af72-21a65c964725/ - django.request - log - /usr/local/lib/python3.7/site-packages/django/utils/log.py:230
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.7/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/usr/local/lib/python3.7/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/nautobot/utilities/views.py", line 94, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/nautobot/core/views/generic.py", line 124, in get
    **self.get_extra_context(request, instance),
  File "/usr/local/lib/python3.7/site-packages/django/shortcuts.py", line 19, in render
    content = loader.render_to_string(template_name, context, request, using=using)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader.py", line 62, in render_to_string
    return template.render(context, request)
  File "/usr/local/lib/python3.7/site-packages/django/template/backends/django.py", line 61, in render
    return self.template.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 170, in render
    return self._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 62, in render
    result = block.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/loader_tags.py", line 192, in render
    return template.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 172, in render
    return self._render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 162, in _render
    return self.nodelist.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 938, in render
    bit = node.render_annotated(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 905, in render_annotated
    return self.render(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/defaulttags.py", line 514, in render
    values = {key: val.resolve(context) for key, val in self.extra_context.items()}
  File "/usr/local/lib/python3.7/site-packages/django/template/defaulttags.py", line 514, in <dictcomp>
    values = {key: val.resolve(context) for key, val in self.extra_context.items()}
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 671, in resolve
    obj = self.var.resolve(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 796, in resolve
    value = self._resolve_lookup(context)
  File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 858, in _resolve_lookup
    current = current()
  File "/usr/local/lib/python3.7/site-packages/nautobot/extras/models/relationships.py", line 187, in get_relationships_data
    peer = association.get_peer(self)
  File "/usr/local/lib/python3.7/site-packages/nautobot/extras/models/relationships.py", line 512, in get_peer
    if obj == self.source:
  File "/usr/local/lib/python3.7/site-packages/django/contrib/contenttypes/fields.py", line 242, in __get__
    rel_obj = ct.get_object_for_this_type(pk=pk_val)
  File "/usr/local/lib/python3.7/site-packages/django/contrib/contenttypes/models.py", line 175, in get_object_for_this_type
    return self.model_class()._base_manager.using(self._state.db).get(**kwargs)
AttributeError: 'NoneType' object has no attribute '_base_manager'
```

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,5 +1,4 @@
 import logging
-from collections import OrderedDict
 
 from django import forms
 from django.contrib.contenttypes.models import ContentType
@@ -85,9 +84,9 @@ def get_relationships(self, include_hidden=False, advanced_ui=None):
         }
 
         resp = {
-            RelationshipSideChoices.SIDE_SOURCE: OrderedDict(),
-            RelationshipSideChoices.SIDE_DESTINATION: OrderedDict(),
-            RelationshipSideChoices.SIDE_PEER: OrderedDict(),
+            RelationshipSideChoices.SIDE_SOURCE: {},
+            RelationshipSideChoices.SIDE_DESTINATION: {},
+            RelationshipSideChoices.SIDE_PEER: {},
         }
         for side, relationships in sides.items():
             for relationship in relationships:
@@ -132,26 +131,26 @@ def get_relationships_data(self, advanced_ui=None):
         Returns:
             response {
                 "source": {
-                    <relationship #1>: {
+                    <relationship #1>: {   # one-to-one relationship that self is the source of
                         "label": "...",
                         "peer_type": <ContentType>,
                         "has_many": False,
-                        "value": <model>,
+                        "value": <model>,     # single destination for this relationship
                         "url": "...",
                     },
-                    <relationship #2>: {
+                    <relationship #2>: {   # one-to-many or many-to-many relationship that self is a source for
                         "label": "...",
                         "peer_type": <ContentType>,
                         "has_many": True,
                         "value": None,
-                        "queryset": <queryset #2>
+                        "queryset": <queryset #2>   # set of destinations for the relationship
                     },
                 },
                 "destination": {
-                    (same format as source)
+                    (same format as source - relationships that self is the destination of)
                 },
                 "peer": {
-                    (same format as source)
+                    (same format as source - symmetric relationships that self is involved in)
                 },
             }
         """
@@ -159,9 +158,9 @@ def get_relationships_data(self, advanced_ui=None):
         relationships_by_side = self.get_relationships(advanced_ui=advanced_ui)
 
         resp = {
-            RelationshipSideChoices.SIDE_SOURCE: OrderedDict(),
-            RelationshipSideChoices.SIDE_DESTINATION: OrderedDict(),
-            RelationshipSideChoices.SIDE_PEER: OrderedDict(),
+            RelationshipSideChoices.SIDE_SOURCE: {},
+            RelationshipSideChoices.SIDE_DESTINATION: {},
+            RelationshipSideChoices.SIDE_PEER: {},
         }
         for side, relationships in relationships_by_side.items():
             for relationship, queryset in relationships.items():
@@ -183,6 +182,7 @@ def get_relationships_data(self, advanced_ui=None):
                 if resp[side][relationship]["has_many"]:
                     resp[side][relationship]["queryset"] = queryset
                 else:
+                    resp[side][relationship]["url"] = None
                     association = queryset.first()
                     if not association:
                         continue
@@ -190,7 +190,10 @@ def get_relationships_data(self, advanced_ui=None):
                     peer = association.get_peer(self)
 
                     resp[side][relationship]["value"] = peer
-                    resp[side][relationship]["url"] = peer.get_absolute_url()
+                    if hasattr(peer, "get_absolute_url"):
+                        resp[side][relationship]["url"] = peer.get_absolute_url()
+                    else:
+                        logger.warning("Peer object %s has no get_absolute_url() method", peer)
 
         return resp
 
@@ -525,18 +528,53 @@ class Meta:
         )
 
     def __str__(self):
-        if self.relationship.symmetric:
-            return "{} <-> {} - {}".format(self.source, self.destination, self.relationship)
-        else:
-            return "{} -> {} - {}".format(self.source, self.destination, self.relationship)
+        arrow = "<->" if self.relationship.symmetric else "->"
+        return f"{self.get_source() or 'unknown'} {arrow} {self.get_destination() or 'unknown'} - {self.relationship}"
+
+    def _get_genericforeignkey(self, name):
+        """
+        Backend for get_source and get_destination methods.
+
+        In the case where we have a RelationshipAssociation to a plugin-provided model, but the plugin is
+        not presently installed/enabled, dereferencing the peer GenericForeignKey will throw an AttributeError:
+            AttributeError: 'NoneType' object has no attribute '_base_manager'
+        because ContentType.model_class() returned None unexpectedly.
+
+        This method handles that exception and returns None in such a case.
+        """
+        if name not in ["source", "destination"]:
+            raise RuntimeError(f"Called for unexpected attribute {name}")
+        try:
+            return getattr(self, name)
+        except AttributeError:
+            logger.error(
+                "Unable to locate RelationshipAssociation %s (of type %s). Perhaps a plugin is missing?",
+                name,
+                getattr(self, f"{name}_type"),
+            )
+
+        return None
+
+    def get_source(self):
+        """Accessor for self.source - returns None if the object cannot be located."""
+        return self._get_genericforeignkey("source")
+
+    def get_destination(self):
+        """Accessor for self.destination - returns None if the object cannot be located."""
+        return self._get_genericforeignkey("destination")
 
     def get_peer(self, obj):
+        """
+        Get the object on the opposite side of this RelationshipAssociation from the provided `obj`.
 
-        if obj == self.source:
-            return self.destination
+        If obj is not involved in this RelationshipAssociation, or if the peer object is not locatable, returns None.
+        """
+        if obj == self.get_source():
+            return self.get_destination()
+        elif obj == self.get_destination():
+            return self.get_source()
 
-        elif obj == self.destination:
-            return self.source
+        return None
 
     def clean(self):
 
@@ -562,7 +600,11 @@ def clean(self):
             ).exists():
                 raise ValidationError(
                     {
-                        "__all__": f"A {self.relationship} association already exists between {self.source} and {self.destination}"
+                        "__all__": (
+                            f"A {self.relationship} association already exists between "
+                            f"{self.get_source() or self.source_id} and "
+                            f"{self.get_destination() or self.destination_id}"
+                        )
                     }
                 )
 
@@ -579,7 +621,10 @@ def clean(self):
             ).exists():
                 raise ValidationError(
                     {
-                        "destination": f"Unable to create more than one {self.relationship} association to {self.destination} (destination)"
+                        "destination": (
+                            f"Unable to create more than one {self.relationship} association to "
+                            f"{self.get_destination() or self.destination_id} (destination)"
+                        )
                     }
                 )
 
@@ -595,7 +640,10 @@ def clean(self):
                 ).exists():
                     raise ValidationError(
                         {
-                            "source": f"Unable to create more than one {self.relationship} association to {self.source} (source)"
+                            "source": (
+                                f"Unable to create more than one {self.relationship} association from "
+                                f"{self.get_source() or self.source_id} (source)"
+                            )
                         }
                     )
 
@@ -608,7 +656,10 @@ def clean(self):
                 ).exists():
                     raise ValidationError(
                         {
-                            "source": f"Unable to create more than one {self.relationship} association involving {self.source} (peer)"
+                            "source": (
+                                f"Unable to create more than one {self.relationship} association involving "
+                                f"{self.get_source() or self.source_id} (peer)"
+                            )
                         }
                     )
                 if RelationshipAssociation.objects.filter(
@@ -617,6 +668,9 @@ def clean(self):
                 ).exists():
                     raise ValidationError(
                         {
-                            "destination": f"Unable to create more than one {self.relationship} association involving {self.destination} (peer)"
+                            "destination": (
+                                f"Unable to create more than one {self.relationship} association involving "
+                                f"{self.get_destination() or self.destination_id} (peer)"
+                            )
                         }
                     )
diff --git a/nautobot/extras/tables.py b/nautobot/extras/tables.py
--- a/nautobot/extras/tables.py
+++ b/nautobot/extras/tables.py
@@ -671,10 +671,10 @@ class RelationshipAssociationTable(BaseTable):
     actions = ButtonsColumn(RelationshipAssociation, buttons=("delete",))
 
     source_type = tables.Column()
-    source = tables.Column(linkify=True, orderable=False)
+    source = tables.Column(linkify=True, orderable=False, accessor="get_source")
 
     destination_type = tables.Column()
-    destination = tables.Column(linkify=True, orderable=False)
+    destination = tables.Column(linkify=True, orderable=False, accessor="get_destination")
 
     class Meta(BaseTable.Meta):
         model = RelationshipAssociation


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
@@ -1,5 +1,9 @@
+import logging
+import uuid
+
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.urls import reverse
 
 from nautobot.dcim.models import Site, Rack
 from nautobot.ipam.models import VLAN
@@ -111,6 +115,36 @@ def setUp(self):
         )
         self.m2ms_1.validated_save()
 
+        # Relationships involving a content type that doesn't actually have a backing model.
+        # This can occur in practice if, for example, a relationship is defined for a plugin-defined model,
+        # then the plugin is subsequently uninstalled or deactivated.
+        self.invalid_ct = ContentType.objects.create(app_label="nonexistent", model="nosuchmodel")
+
+        # Don't use validated_save() on these as it will fail due to the invalid content-type
+        self.invalid_relationships = [
+            Relationship.objects.create(
+                name="Invalid Relationship 1",
+                slug="invalid-relationship-1",
+                source_type=self.site_ct,
+                destination_type=self.invalid_ct,
+                type=RelationshipTypeChoices.TYPE_ONE_TO_ONE,
+            ),
+            Relationship.objects.create(
+                name="Invalid Relationship 2",
+                slug="invalid-relationship-2",
+                source_type=self.invalid_ct,
+                destination_type=self.site_ct,
+                type=RelationshipTypeChoices.TYPE_ONE_TO_MANY,
+            ),
+            Relationship.objects.create(
+                name="Invalid Relationship 3",
+                slug="invalid-relationship-3",
+                source_type=self.invalid_ct,
+                destination_type=self.invalid_ct,
+                type=RelationshipTypeChoices.TYPE_MANY_TO_MANY_SYMMETRIC,
+            ),
+        ]
+
 
 class RelationshipTest(RelationshipBaseTest):
     def test_clean_filter_not_dict(self):
@@ -318,6 +352,38 @@ def test_to_form_field_o2o(self):
 
 
 class RelationshipAssociationTest(RelationshipBaseTest):
+    def setUp(self):
+        super().setUp()
+
+        self.invalid_object_pks = [
+            uuid.uuid4(),
+            uuid.uuid4(),
+        ]
+
+        self.invalid_relationship_associations = [
+            RelationshipAssociation(
+                relationship=self.invalid_relationships[0],
+                source=self.sites[1],
+                destination_type=self.invalid_ct,
+                destination_id=self.invalid_object_pks[1],
+            ),
+            RelationshipAssociation(
+                relationship=self.invalid_relationships[1],
+                source_type=self.invalid_ct,
+                source_id=self.invalid_object_pks[0],
+                destination=self.sites[1],
+            ),
+            RelationshipAssociation(
+                relationship=self.invalid_relationships[2],
+                source_type=self.invalid_ct,
+                source_id=self.invalid_object_pks[0],
+                destination_type=self.invalid_ct,
+                destination_id=self.invalid_object_pks[1],
+            ),
+        ]
+        for cra in self.invalid_relationship_associations:
+            cra.validated_save()
+
     def test_clean_wrong_type(self):
         # Create with the wrong source Type
         with self.assertRaises(ValidationError) as handler:
@@ -350,7 +416,7 @@ def test_clean_check_quantity_o2o(self):
             cra.clean()
 
         expected_errors = {
-            "source": ["Unable to create more than one Primary Rack per Site association to Rack A (source)"]
+            "source": ["Unable to create more than one Primary Rack per Site association from Rack A (source)"]
         }
         self.assertEqual(handler.exception.message_dict, expected_errors)
 
@@ -365,7 +431,7 @@ def test_clean_check_quantity_o2o(self):
         with self.assertRaises(ValidationError) as handler:
             cra = RelationshipAssociation(relationship=self.o2os_1, source=self.racks[0], destination=self.racks[2])
             cra.clean()
-        expected_errors = {"source": ["Unable to create more than one Redundant Rack association to Rack A (source)"]}
+        expected_errors = {"source": ["Unable to create more than one Redundant Rack association from Rack A (source)"]}
         self.assertEqual(handler.exception.message_dict, expected_errors)
 
         # Slightly tricky case - a symmetric one-to-one relationship where the proposed *source* is already in use
@@ -460,8 +526,137 @@ def test_get_peer(self):
         self.assertEqual(cra.get_peer(self.vlans[0]), self.racks[0])
         self.assertEqual(cra.get_peer(self.vlans[1]), None)
 
+    def test_get_peer_invalid(self):
+        """Validate that get_peer() handles lookup errors gracefully."""
+        self.assertEqual(
+            self.invalid_relationship_associations[0].get_peer(self.invalid_relationship_associations[0].source), None
+        )
+        self.assertEqual(
+            self.invalid_relationship_associations[1].get_peer(self.invalid_relationship_associations[1].destination),
+            None,
+        )
+        self.assertEqual(self.invalid_relationship_associations[2].get_peer(None), None)
+
+    def test_str(self):
+        """Validate that the str() method works correctly."""
+        associations = [
+            RelationshipAssociation(relationship=self.o2o_1, source=self.racks[0], destination=self.sites[1]),
+            RelationshipAssociation(relationship=self.o2os_1, source=self.racks[0], destination=self.racks[1]),
+        ]
+        for association in associations:
+            association.validated_save()
+
+        self.assertEqual(str(associations[0]), f"{self.racks[0]} -> {self.sites[1]} - {self.o2o_1}")
+        self.assertEqual(str(associations[1]), f"{self.racks[0]} <-> {self.racks[1]} - {self.o2os_1}")
+        self.assertEqual(
+            str(self.invalid_relationship_associations[0]),
+            f"{self.sites[1]} -> unknown - {self.invalid_relationships[0]}",
+        )
+        self.assertEqual(
+            str(self.invalid_relationship_associations[1]),
+            f"unknown -> {self.sites[1]} - {self.invalid_relationships[1]}",
+        )
+        self.assertEqual(
+            str(self.invalid_relationship_associations[2]),
+            f"unknown <-> unknown - {self.invalid_relationships[2]}",
+        )
+
+    def test_get_relationships_data(self):
+        # In addition to the invalid associations for sites[1] defined in self.setUp(), add some valid ones
+        associations = [
+            RelationshipAssociation(relationship=self.o2m_1, source=self.sites[1], destination=self.vlans[0]),
+            RelationshipAssociation(relationship=self.o2o_1, source=self.racks[0], destination=self.sites[1]),
+            RelationshipAssociation(relationship=self.o2o_2, source=self.sites[0], destination=self.sites[1]),
+        ]
+        for association in associations:
+            association.validated_save()
+
+        with self.assertLogs(logger=logging.getLogger("nautobot.extras.models.relationships"), level="ERROR"):
+            data = self.sites[1].get_relationships_data()
+        self.maxDiff = None
+        # assertEqual doesn't work well on the entire data at once because it includes things like queryset objects
+        self.assertEqual(sorted(data.keys()), ["destination", "peer", "source"])
+        self.assertEqual(set(data["destination"].keys()), {self.o2o_1, self.o2o_2, self.invalid_relationships[1]})
+        self.assertEqual(
+            data["destination"][self.o2o_1],
+            {
+                "has_many": False,
+                "label": "Primary Rack",
+                "peer_type": self.rack_ct,
+                "url": reverse("dcim:rack", kwargs={"pk": self.racks[0].pk}),
+                "value": self.racks[0],
+            },
+        )
+        self.assertEqual(
+            data["destination"][self.o2o_2],
+            {
+                "has_many": False,
+                "label": "Alphabetically Subsequent",
+                "peer_type": self.site_ct,
+                "url": reverse("dcim:site", kwargs={"slug": self.sites[0].slug}),
+                "value": self.sites[0],
+            },
+        )
+        self.assertEqual(
+            data["destination"][self.invalid_relationships[1]],
+            {
+                "has_many": False,
+                "label": "Invalid Relationship 2",
+                "peer_type": self.invalid_ct,
+                "url": None,
+                "value": None,
+            },
+        )
+        self.assertEqual(set(data["peer"].keys()), {self.m2ms_1})
+        # Peer queryset is complex, but evaluates to an empty list in this case
+        self.assertEqual(list(data["peer"][self.m2ms_1]["queryset"]), [])
+        del data["peer"][self.m2ms_1]["queryset"]
+        self.assertEqual(
+            data["peer"][self.m2ms_1],
+            {
+                "has_many": True,
+                "label": "sites",
+                "peer_type": self.site_ct,
+                "value": None,
+            },
+        )
+        self.assertEqual(set(data["source"].keys()), {self.o2m_1, self.o2o_2, self.invalid_relationships[0]})
+        self.assertEqual(list(data["source"][self.o2m_1]["queryset"]), [associations[0]])
+        del data["source"][self.o2m_1]["queryset"]
+        self.assertEqual(
+            data["source"][self.o2m_1],
+            {
+                "has_many": True,
+                "label": "VLANs",
+                "peer_type": self.vlan_ct,
+                "value": None,
+            },
+        )
+        self.assertEqual(
+            data["source"][self.o2o_2],
+            {
+                "has_many": False,
+                "label": "Alphabetically Prior",
+                "peer_type": self.site_ct,
+                "url": None,
+                "value": None,
+            },
+        )
+        self.assertEqual(
+            data["source"][self.invalid_relationships[0]],
+            {
+                "has_many": False,
+                "label": "Invalid Relationship 1",
+                "peer_type": self.invalid_ct,
+                "url": None,
+                # value is None because the related object can't actually be found
+                "value": None,
+            },
+        )
+
     def test_delete_cascade(self):
         """Verify that a RelationshipAssociation is deleted if either of the associated records is deleted."""
+        initial_count = RelationshipAssociation.objects.count()
         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]),
@@ -475,28 +670,28 @@ def test_delete_cascade(self):
         # Create a self-referential association as well; validated_save() would correctly reject this one as invalid
         RelationshipAssociation.objects.create(relationship=self.o2o_2, source=self.sites[4], destination=self.sites[4])
 
-        self.assertEqual(6, RelationshipAssociation.objects.count())
+        self.assertEqual(6 + initial_count, 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, as should the site relations
-        self.assertEqual(4, RelationshipAssociation.objects.count())
+        self.assertEqual(4 + initial_count, RelationshipAssociation.objects.count())
 
         # Test automatic deletion of RelationshipAssociations when their 'destination' object is deleted
         self.vlans[0].delete()
 
         # Site relation remains
-        self.assertEqual(3, RelationshipAssociation.objects.count())
+        self.assertEqual(3 + initial_count, RelationshipAssociation.objects.count())
 
         # Test automatic deletion of RelationshipAssociations when there's a loop of source/destination references
         self.sites[3].delete()
-        self.assertEqual(1, RelationshipAssociation.objects.count())
+        self.assertEqual(1 + initial_count, RelationshipAssociation.objects.count())
 
         # Test automatic deletion of RelationshipAssociations when the same object is both source and destination
         self.sites[4].delete()
-        self.assertEqual(0, RelationshipAssociation.objects.count())
+        self.assertEqual(initial_count, RelationshipAssociation.objects.count())
 
     def test_generic_relation(self):
         """Verify that the GenericRelations on the involved models work correctly."""