Repository: nautobot/nautobot

Issue:
Exception thrown when deleting Software Version or Image from list views
<!--
    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.2.3
* Python version: 3.11
* Database platform, version: PostgreSQL
* Middleware(s):

<!--
    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 Software Version object
2. Create a Software Image File object and associate it with the Software Version object.
3. Associate a device with the new software and image objects
4. Delete either of the new objects using bulk delete from their respective list views.

<!-- What did you expect to happen? -->
### Expected Behavior
An warning should be displayed because of the protected relationships.

<!-- What happened instead? -->
### Observed Behavior
AttributeError and traceback



Discussion:
The error is raised in `handle_protectederror` when trying to get the `absolute_url` of a model that do not have a view I.e `DeviceTypeToSoftwareImageFile`

```python
def handle_protectederror(obj_list, request, e):
    ...

    err_message += format_html_join(
        ", ",
        '<a href="{}">{}</a>',
        ((dependent.get_absolute_url(), dependent) for dependent in protected_objects[:50]),
    )
    ...
```

A view for this model could be added or we skip attaching href to models without to an `absolute_url`

Resolving patch:
diff --git a/nautobot/core/views/utils.py b/nautobot/core/views/utils.py
--- a/nautobot/core/views/utils.py
+++ b/nautobot/core/views/utils.py
@@ -215,11 +215,28 @@ def handle_protectederror(obj_list, request, e):
         protected_count,
     )
 
+    # Format objects based on whether they have a detail view/absolute url
+    objects_with_absolute_url = []
+    objects_without_absolute_url = []
     # Append dependent objects to error message
+    for dependent in protected_objects[:50]:
+        try:
+            dependent.get_absolute_url()
+            objects_with_absolute_url.append(dependent)
+        except AttributeError:
+            objects_without_absolute_url.append(dependent)
+
     err_message += format_html_join(
         ", ",
         '<a href="{}">{}</a>',
-        ((dependent.get_absolute_url(), dependent) for dependent in protected_objects[:50]),
+        ((dependent.get_absolute_url(), dependent) for dependent in objects_with_absolute_url),
+    )
+    if objects_with_absolute_url and objects_without_absolute_url:
+        err_message += format_html(", ")
+    err_message += format_html_join(
+        ", ",
+        "<span>{}</span>",
+        ((dependent,) for dependent in objects_without_absolute_url),
     )
 
     messages.error(request, err_message)


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
@@ -3398,6 +3398,48 @@ def setUpTestData(cls):
             "download_url": "https://example.com/software_image_file_test_case.bin",
         }
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_correct_handling_for_model_protected_error(self):
+        platform = Platform.objects.first()
+        software_version_status = Status.objects.get_for_model(SoftwareVersion).first()
+        software_image_file_status = Status.objects.get_for_model(SoftwareImageFile).first()
+        software_version = SoftwareVersion.objects.create(
+            platform=platform, version="Test version 1.0.0", status=software_version_status
+        )
+        software_image_file = SoftwareImageFile.objects.create(
+            software_version=software_version,
+            image_file_name="software_image_file_qs_test_1.bin",
+            status=software_image_file_status,
+        )
+        device_type = DeviceType.objects.first()
+        device_role = Role.objects.get_for_model(Device).first()
+        device_status = Status.objects.get_for_model(Device).first()
+        location = Location.objects.filter(location_type__name="Campus").first()
+        Device.objects.create(
+            device_type=device_type,
+            role=device_role,
+            name="Device 1",
+            location=location,
+            status=device_status,
+            software_version=software_version,
+        )
+        device_type_to_software_image_file = DeviceTypeToSoftwareImageFile.objects.create(
+            device_type=device_type, software_image_file=software_image_file
+        )
+
+        self.add_permissions("dcim.delete_softwareimagefile")
+        pk_list = [software_image_file.pk]
+        data = {
+            "pk": pk_list,
+            "confirm": True,
+            "_confirm": True,  # Form button
+        }
+        response = self.client.post(self._get_url("bulk_delete"), data, follow=True)
+        self.assertHttpStatus(response, 200)
+        response_body = response.content.decode(response.charset)
+        # Assert protected error message included in the response body
+        self.assertInHTML(f"<span>{device_type_to_software_image_file}</span>", response_body)
+
 
 class SoftwareVersionTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = SoftwareVersion
@@ -3436,6 +3478,48 @@ def setUpTestData(cls):
             "pre_release": True,
         }
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_correct_handling_for_model_protected_error(self):
+        platform = Platform.objects.first()
+        software_version_status = Status.objects.get_for_model(SoftwareVersion).first()
+        software_image_file_status = Status.objects.get_for_model(SoftwareImageFile).first()
+        software_version = SoftwareVersion.objects.create(
+            platform=platform, version="Test version 1.0.0", status=software_version_status
+        )
+        software_image_file = SoftwareImageFile.objects.create(
+            software_version=software_version,
+            image_file_name="software_image_file_qs_test_1.bin",
+            status=software_image_file_status,
+        )
+        device_type = DeviceType.objects.first()
+        device_role = Role.objects.get_for_model(Device).first()
+        device_status = Status.objects.get_for_model(Device).first()
+        location = Location.objects.filter(location_type__name="Campus").first()
+        Device.objects.create(
+            device_type=device_type,
+            role=device_role,
+            name="Device 1",
+            location=location,
+            status=device_status,
+            software_version=software_version,
+        )
+        device_type_to_software_image_file = DeviceTypeToSoftwareImageFile.objects.create(
+            device_type=device_type, software_image_file=software_image_file
+        )
+
+        self.add_permissions("dcim.delete_softwareversion")
+        pk_list = [software_version.pk]
+        data = {
+            "pk": pk_list,
+            "confirm": True,
+            "_confirm": True,  # Form button
+        }
+        response = self.client.post(self._get_url("bulk_delete"), data, follow=True)
+        self.assertHttpStatus(response, 200)
+        response_body = response.content.decode(response.charset)
+        # Assert protected error message included in the response body
+        self.assertInHTML(f"<span>{device_type_to_software_image_file}</span>", response_body)
+
 
 class ControllerTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Controller