Repository: nautobot/nautobot

Issue:
Bulk delete on devices results in error when a job hook is enabled
<!--
    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): v2.2.2, v2.2.3, and v2.2.3b1
* Python version:  3.8.19
* Database platform, version: Postgres 13
* 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 job hook that is called on `Delete` operations for object type `dcim | device`
2.  Perform bulk delete operation on any device

<!-- What did you expect to happen? -->
### Expected Behavior
Device or devices are deleted without any errors and the page loads successfully.

<!-- What happened instead? -->
### Observed Behavior
Devices are deleted but an error is observed by the job hook operation which prevents the page from loading successfully.  This error does not occur when doing a single delete operation from the device's page.  Additionally, the associated job gets stuck in a pending state:

![image](https://github.com/nautobot/nautobot/assets/141754521/dfe090a5-3bb5-4227-b79e-557e809a47ae)
****
**Exception value** 

`'NoneType' object has no attribute 'id'`

**Exception raised from this line**
https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/models/jobs.py#L654

*Code snippet*
https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/models/jobs.py#L651-L656

**Exception invoked by this line** https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/jobs.py#L1173


Bulk delete on devices results in error when a job hook is enabled
<!--
    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): v2.2.2, v2.2.3, and v2.2.3b1
* Python version:  3.8.19
* Database platform, version: Postgres 13
* 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 job hook that is called on `Delete` operations for object type `dcim | device`
2.  Perform bulk delete operation on any device

<!-- What did you expect to happen? -->
### Expected Behavior
Device or devices are deleted without any errors and the page loads successfully.

<!-- What happened instead? -->
### Observed Behavior
Devices are deleted but an error is observed by the job hook operation which prevents the page from loading successfully.  This error does not occur when doing a single delete operation from the device's page.  Additionally, the associated job gets stuck in a pending state:

![image](https://github.com/nautobot/nautobot/assets/141754521/dfe090a5-3bb5-4227-b79e-557e809a47ae)
****
**Exception value** 

`'NoneType' object has no attribute 'id'`

**Exception raised from this line**
https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/models/jobs.py#L654

*Code snippet*
https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/models/jobs.py#L651-L656

**Exception invoked by this line** https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/jobs.py#L1173




Discussion:
It appears that #5572 caused this.  In addition to the problem mentioned above, it also results in "bulk delete" change logs that do not have a user associated with them.  

![image](https://github.com/nautobot/nautobot/assets/141754521/976d4f09-027d-4821-bc00-aec68b9aa17c)

I have this working with a change to the following: https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/utils.py#L640

Changes to -
```python
#change_context.user is empty and user_name is not getting set at all in this new method
oc.user = oc.user_name = change_context.get_user()
```
It appears that #5572 caused this.  In addition to the problem mentioned above, it also results in "bulk delete" change logs that do not have a user associated with them.  

![image](https://github.com/nautobot/nautobot/assets/141754521/976d4f09-027d-4821-bc00-aec68b9aa17c)

I have this working with a change to the following: https://github.com/nautobot/nautobot/blob/9b7fbbbd3e5bb0b5a1086f48d19cb58ff4e853b1/nautobot/extras/utils.py#L640

Changes to -
```python
#change_context.user is empty and user_name is not getting set at all in this new method
oc.user = oc.user_name = change_context.get_user()
```

Resolving patch:
diff --git a/nautobot/extras/utils.py b/nautobot/extras/utils.py
--- a/nautobot/extras/utils.py
+++ b/nautobot/extras/utils.py
@@ -637,7 +637,8 @@ def bulk_delete_with_bulk_change_logging(qs, batch_size=1000):
                     ObjectChange.objects.bulk_create(queued_object_changes)
                     queued_object_changes = []
                 oc = obj.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
-                oc.user = change_context.user
+                oc.user = change_context.get_user()
+                oc.user_name = oc.user.username
                 oc.request_id = change_context.change_id
                 oc.change_context = change_context.context
                 oc.change_context_detail = change_context.context_detail[:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL]


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
@@ -12,6 +12,7 @@
     web_request_context,
 )
 from nautobot.extras.models import Status, Webhook
+from nautobot.extras.utils import bulk_delete_with_bulk_change_logging
 
 # Use the proper swappable User model
 User = get_user_model()
@@ -231,6 +232,23 @@ def test_delete(self):
                     location.save()
                     location.delete()
 
+    def test_bulk_delete_has_user_in_change_log(self):
+        """Test that the bulk delete operation adds the user to the change log"""
+        location_type = LocationType.objects.get(name="Campus")
+        location_status = Status.objects.get_for_model(Location).first()
+        with web_request_context(self.user):
+            location = Location(name="Test Location 1", location_type=location_type, status=location_status)
+            location.save()
+            location_pk = location.pk
+            location_qs = Location.objects.filter(pk=location_pk)
+            bulk_delete_with_bulk_change_logging(location_qs)
+
+        oc_list = get_changes_for_model(location)
+        self.assertEqual(len(oc_list), 2)
+        self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertEqual(oc_list[0].user, self.user)
+        self.assertEqual(oc_list[0].user_name, self.user.username)
+
     def test_create_then_update(self):
         """Test that a create followed by an update is logged as a single create"""
         location_type = LocationType.objects.get(name="Campus")