Repository: nautobot/nautobot

Issue:
IntegrityError when setting DeviceType software images to null via bulk edit
<!--
    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.9
* 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 SoftwareImageFile objects
2. Assign SoftwareImageFile objects to DeviceType objects (even 1 image file to 1 device type is enough)
3. Use the bulk edit option to set all software image files for any number of device types to null

<!-- What did you expect to happen? -->
### Expected Behavior
SoftwareImageFile associations with DeviceTypes should be removed


<!-- What happened instead? -->
### Observed Behavior

```
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py", line 69, in execute
    return super().execute(*args, **kwargs)

The above exception (null value in column "changed_object_id" of relation "extras_objectchange" violates not-null constraint
DETAIL:  Failing row contains (b9e39eff-bfdb-4bd5-bfa8-8b9abf508af4, 2024-05-13 19:50:07.282459+00, , b76aee62-d04c-461b-b60c-56b93ad7975c, delete, null, null, WS-C3560CG-8PC-S - cisco_ios - 15.9(3)M6 - VG3X0-UNIVERSALK9-M, {"created": "2024-05-13T19:49:47.446Z", "device_type": "f5721ed5..., 82, null, 9a40bcbd-62d2-458d-93ea-36b5f420b7a7, {"id": null, "url": null, "created": "2024-05-13T19:49:47.446860..., web, dcim:devicetype_bulk_edit).
) was the direct cause of the following exception:
  File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.11/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.11/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/nautobot/core/views/mixins.py", line 166, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/nautobot/core/views/generic.py", line 992, in post
    with deferred_change_logging_for_bulk_operation():
  File "/usr/local/lib/python3.11/contextlib.py", line 144, in __exit__
    next(self.gen)
  File "/usr/local/lib/python3.11/site-packages/nautobot/extras/context_managers.py", line 218, in deferred_change_logging_for_bulk_operation
    change_context.flush_deferred_object_changes()
  File "/usr/local/lib/python3.11/site-packages/nautobot/extras/context_managers.py", line 85, in flush_deferred_object_changes
    self.create_object_changes(batch_size=batch_size)
  File "/usr/local/lib/python3.11/site-packages/nautobot/extras/context_managers.py", line 99, in create_object_changes
    ObjectChange.objects.bulk_create(create_object_changes, batch_size=batch_size)
  File "/usr/local/lib/python3.11/site-packages/django/db/models/manager.py", line 85, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 502, in bulk_create
    returned_columns = self._batched_insert(
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1287, in _batched_insert
    inserted_rows.extend(self._insert(
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1270, in _insert
    return query.get_compiler(using=using).execute_sql(returning_fields)
  File "/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1416, in execute_sql
    cursor.execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/debug_toolbar/panels/sql/tracking.py", line 235, in execute
    return self._record(super().execute, sql, params)
  File "/usr/local/lib/python3.11/site-packages/debug_toolbar/panels/sql/tracking.py", line 160, in _record
    return method(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 98, in execute
    return super().execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 66, in execute
    return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 75, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 79, in _execute
    with self.db.wrap_database_errors:
  File "/usr/local/lib/python3.11/site-packages/django/db/utils.py", line 90, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py", line 69, in execute
    return super().execute(*args, **kwargs)

Exception Type: IntegrityError at /dcim/device-types/edit/
Exception Value: null value in column "changed_object_id" of relation "extras_objectchange" violates not-null constraint
DETAIL:  Failing row contains (b9e39eff-bfdb-4bd5-bfa8-8b9abf508af4, 2024-05-13 19:50:07.282459+00, , b76aee62-d04c-461b-b60c-56b93ad7975c, delete, null, null, WS-C3560CG-8PC-S - cisco_ios - 15.9(3)M6 - VG3X0-UNIVERSALK9-M, {"created": "2024-05-13T19:49:47.446Z", "device_type": "f5721ed5..., 82, null, 9a40bcbd-62d2-458d-93ea-36b5f420b7a7, {"id": null, "url": null, "created": "2024-05-13T19:49:47.446860..., web, dcim:devicetype_bulk_edit).
```
IntegrityError when setting DeviceType software images to null via bulk edit
<!--
    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.9
* 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 SoftwareImageFile objects
2. Assign SoftwareImageFile objects to DeviceType objects (even 1 image file to 1 device type is enough)
3. Use the bulk edit option to set all software image files for any number of device types to null

<!-- What did you expect to happen? -->
### Expected Behavior
SoftwareImageFile associations with DeviceTypes should be removed


<!-- What happened instead? -->
### Observed Behavior

```
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py", line 69, in execute
    return super().execute(*args, **kwargs)

The above exception (null value in column "changed_object_id" of relation "extras_objectchange" violates not-null constraint
DETAIL:  Failing row contains (b9e39eff-bfdb-4bd5-bfa8-8b9abf508af4, 2024-05-13 19:50:07.282459+00, , b76aee62-d04c-461b-b60c-56b93ad7975c, delete, null, null, WS-C3560CG-8PC-S - cisco_ios - 15.9(3)M6 - VG3X0-UNIVERSALK9-M, {"created": "2024-05-13T19:49:47.446Z", "device_type": "f5721ed5..., 82, null, 9a40bcbd-62d2-458d-93ea-36b5f420b7a7, {"id": null, "url": null, "created": "2024-05-13T19:49:47.446860..., web, dcim:devicetype_bulk_edit).
) was the direct cause of the following exception:
  File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.11/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.11/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/nautobot/core/views/mixins.py", line 166, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/nautobot/core/views/generic.py", line 992, in post
    with deferred_change_logging_for_bulk_operation():
  File "/usr/local/lib/python3.11/contextlib.py", line 144, in __exit__
    next(self.gen)
  File "/usr/local/lib/python3.11/site-packages/nautobot/extras/context_managers.py", line 218, in deferred_change_logging_for_bulk_operation
    change_context.flush_deferred_object_changes()
  File "/usr/local/lib/python3.11/site-packages/nautobot/extras/context_managers.py", line 85, in flush_deferred_object_changes
    self.create_object_changes(batch_size=batch_size)
  File "/usr/local/lib/python3.11/site-packages/nautobot/extras/context_managers.py", line 99, in create_object_changes
    ObjectChange.objects.bulk_create(create_object_changes, batch_size=batch_size)
  File "/usr/local/lib/python3.11/site-packages/django/db/models/manager.py", line 85, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 502, in bulk_create
    returned_columns = self._batched_insert(
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1287, in _batched_insert
    inserted_rows.extend(self._insert(
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1270, in _insert
    return query.get_compiler(using=using).execute_sql(returning_fields)
  File "/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1416, in execute_sql
    cursor.execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/debug_toolbar/panels/sql/tracking.py", line 235, in execute
    return self._record(super().execute, sql, params)
  File "/usr/local/lib/python3.11/site-packages/debug_toolbar/panels/sql/tracking.py", line 160, in _record
    return method(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 98, in execute
    return super().execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 66, in execute
    return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 75, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 79, in _execute
    with self.db.wrap_database_errors:
  File "/usr/local/lib/python3.11/site-packages/django/db/utils.py", line 90, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/usr/local/lib/python3.11/site-packages/django_prometheus/db/common.py", line 69, in execute
    return super().execute(*args, **kwargs)

Exception Type: IntegrityError at /dcim/device-types/edit/
Exception Value: null value in column "changed_object_id" of relation "extras_objectchange" violates not-null constraint
DETAIL:  Failing row contains (b9e39eff-bfdb-4bd5-bfa8-8b9abf508af4, 2024-05-13 19:50:07.282459+00, , b76aee62-d04c-461b-b60c-56b93ad7975c, delete, null, null, WS-C3560CG-8PC-S - cisco_ios - 15.9(3)M6 - VG3X0-UNIVERSALK9-M, {"created": "2024-05-13T19:49:47.446Z", "device_type": "f5721ed5..., 82, null, 9a40bcbd-62d2-458d-93ea-36b5f420b7a7, {"id": null, "url": null, "created": "2024-05-13T19:49:47.446860..., web, dcim:devicetype_bulk_edit).
```


Discussion:
At first glance, this appears to be linked to the changes introduced in #5572.  
At first glance, this appears to be linked to the changes introduced in #5572.  

Resolving patch:
diff --git a/nautobot/extras/context_managers.py b/nautobot/extras/context_managers.py
--- a/nautobot/extras/context_managers.py
+++ b/nautobot/extras/context_managers.py
@@ -91,9 +91,12 @@ def create_object_changes(self, batch_size=1000):
                 for entry in self.deferred_object_changes[key]:
                     objectchange = entry["instance"].to_objectchange(entry["action"])
                     objectchange.user = entry["user"]
+                    objectchange.user_name = objectchange.user.username
                     objectchange.request_id = self.change_id
                     objectchange.change_context = self.context
                     objectchange.change_context_detail = self.context_detail[:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL]
+                    if not objectchange.changed_object_id:
+                        objectchange.changed_object_id = entry.get("changed_object_id")
                     create_object_changes.append(objectchange)
                 self.deferred_object_changes.pop(key, None)
             ObjectChange.objects.bulk_create(create_object_changes, batch_size=batch_size)
diff --git a/nautobot/extras/signals.py b/nautobot/extras/signals.py
--- a/nautobot/extras/signals.py
+++ b/nautobot/extras/signals.py
@@ -246,7 +246,12 @@ def _handle_deleted_object(sender, instance, **kwargs):
 
         if save_new_objectchange:
             change_context.deferred_object_changes.setdefault(unique_object_change_id, []).append(
-                {"action": ObjectChangeActionChoices.ACTION_DELETE, "instance": instance, "user": user}
+                {
+                    "action": ObjectChangeActionChoices.ACTION_DELETE,
+                    "instance": instance,
+                    "user": user,
+                    "changed_object_id": instance.pk,
+                }
             )
             if not change_context.defer_object_changes:
                 objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)


Test patch:
diff --git a/nautobot/extras/tests/test_context_managers.py b/nautobot/extras/tests/test_context_managers.py
--- a/nautobot/extras/tests/test_context_managers.py
+++ b/nautobot/extras/tests/test_context_managers.py
@@ -5,7 +5,16 @@
 from nautobot.core.celery import app
 from nautobot.core.testing import TransactionTestCase
 from nautobot.core.utils.lookup import get_changes_for_model
-from nautobot.dcim.models import Location, LocationType
+from nautobot.dcim.models import (
+    DeviceType,
+    DeviceTypeToSoftwareImageFile,
+    Location,
+    LocationType,
+    Manufacturer,
+    Platform,
+    SoftwareImageFile,
+    SoftwareVersion,
+)
 from nautobot.extras.choices import ObjectChangeActionChoices, ObjectChangeEventContextChoices
 from nautobot.extras.context_managers import (
     deferred_change_logging_for_bulk_operation,
@@ -294,6 +303,29 @@ def test_bulk_edit(self):
             self.assertIsNone(snapshots["differences"]["removed"])
             self.assertEqual(snapshots["differences"]["added"]["description"], "changed")
 
+    def test_bulk_edit_device_type_software_image_file(self):
+        """Test that bulk edits to null does not cause integrity error"""
+        manufacturer = Manufacturer.objects.create(name="Test")
+        platform = Platform.objects.create(name="Test")
+        software_status = Status.objects.get_for_model(SoftwareVersion).first()
+        software_version = SoftwareVersion.objects.create(version="1.0.0", platform=platform, status=software_status)
+        software_image_file = SoftwareImageFile.objects.create(
+            image_file_name="test.iso", software_version=software_version, status=software_status
+        )
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model="test123")
+        device_type.software_image_files.set([software_image_file])
+        with web_request_context(self.user):
+            with deferred_change_logging_for_bulk_operation():
+                device_type.software_image_files.set([])
+                device_type.save()
+
+        oc_list = get_changes_for_model(DeviceTypeToSoftwareImageFile)
+        self.assertEqual(len(oc_list), 1)
+        self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertIsNotNone(oc_list[0].changed_object_id)
+        self.assertEqual(oc_list[0].user, self.user)
+        self.assertEqual(oc_list[0].user_name, self.user.username)
+
     def test_change_log_context(self):
         location_type = LocationType.objects.get(name="Campus")
         location_status = Status.objects.get_for_model(Location).first()