Repository: nautobot/nautobot

Issue:
AttributeError when running database-changing Job from `nautobot-server runjob --commit`

### Environment
* Python version:
* Nautobot version: 1.1.3

### Steps to Reproduce
1. Use the `nautobot-server runjob --commit <job>` command to enqueue a Job that will make any change to the database when run
2. When the Job calls any model's `save()` method, an AttributeError is thrown:
```
File "/usr/local/lib/python3.7/site-packages/django/db/models/base.py", line 754, in save
    force_update=force_update, update_fields=update_fields)
  File "/usr/local/lib/python3.7/site-packages/django/db/models/base.py", line 803, in save_base
    update_fields=update_fields, raw=raw, using=using,
  File "/usr/local/lib/python3.7/site-packages/django/dispatch/dispatcher.py", line 179, in send
    for receiver in self._live_receivers(sender)
  File "/usr/local/lib/python3.7/site-packages/django/dispatch/dispatcher.py", line 179, in 
    for receiver in self._live_receivers(sender)
  File "/usr/local/lib/python3.7/site-packages/nautobot/utilities/utils.py", line 311, in _curried
    return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs})
  File "/usr/local/lib/python3.7/site-packages/nautobot/extras/signals.py", line 62, in _handle_changed_object
    objectchange.user = _get_user_if_authenticated(request, objectchange)
  File "/usr/local/lib/python3.7/site-packages/nautobot/extras/signals.py", line 38, in _get_user_if_authenticated
    if request.user.is_authenticated:
AttributeError: 'NoneType' object has no attribute 'user'
```

### Expected Behavior

Job to run successfully to completion just as it would if invoked via the UI or REST API

### Observed Behavior

Job aborts and is cleaned up.

### Initial Analysis

The `runjob` command calls:

```python
        job_result = JobResult.enqueue_job(
            run_job,
            job_class.class_path,
            job_content_type,
            None,
            data={},
            request=None,  #  <-------<< important
            commit=options["commit"],
        )
```

`JobResult.enqueue_job` passes the provided `request` (in this case `None`) as a parameter to the enqueued `run_job` function.

When `run_job` runs with `commit=True`, it calls `with change_logging(request):` which enables change-logging for all DB changes triggered by the job via the `_handle_changed_object` signal.

`_handle_changed_object` expects a valid `request` object that it can retrieve the calling user and request-id from, but since it's `None`, an `AttributeError` is thrown.

### Possible solutions:

1. In `runjob`, create a mock Request object via `web_request_context` and pass that instead of None
2. Enhance `_handle_changed_object` to gracefully handle a null Request.

IMO option 1 is probably better.


Discussion:

Resolving patch:
diff --git a/nautobot/extras/management/commands/runjob.py b/nautobot/extras/management/commands/runjob.py
--- a/nautobot/extras/management/commands/runjob.py
+++ b/nautobot/extras/management/commands/runjob.py
@@ -1,12 +1,16 @@
 import time
+import uuid
 
+from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand, CommandError
+from django.test.client import RequestFactory
 from django.utils import timezone
 
 from nautobot.extras.choices import JobResultStatusChoices
 from nautobot.extras.models import JobResult
 from nautobot.extras.jobs import get_job, run_job
+from nautobot.utilities.utils import copy_safe_request
 
 
 class Command(BaseCommand):
@@ -17,7 +21,12 @@ def add_arguments(self, parser):
         parser.add_argument(
             "--commit",
             action="store_true",
-            help="Commit changes to DB (defaults to dry-run if unset)",
+            help="Commit changes to DB (defaults to dry-run if unset). --username is mandatory if using this argument",
+        )
+        parser.add_argument(
+            "-u",
+            "--username",
+            help="User account to impersonate as the requester of this job",
         )
 
     def handle(self, *args, **options):
@@ -27,6 +36,23 @@ def handle(self, *args, **options):
         if not job_class:
             raise CommandError('Job "%s" not found' % options["job"])
 
+        user = None
+        request = None
+        if options["commit"] and not options["username"]:
+            # Job execution with commit=True uses change_logging(), which requires a user as the author of any changes
+            raise CommandError("--username is mandatory when --commit is used")
+
+        if options["username"]:
+            User = get_user_model()
+            try:
+                user = User.objects.get(username=options["username"])
+            except User.DoesNotExist as exc:
+                raise CommandError("No such user") from exc
+
+            request = RequestFactory().request(SERVER_NAME="nautobot_server_runjob")
+            request.id = uuid.uuid4()
+            request.user = user
+
         job_content_type = ContentType.objects.get(app_label="extras", model="job")
 
         # Run the job and create a new JobResult
@@ -36,9 +62,9 @@ def handle(self, *args, **options):
             run_job,
             job_class.class_path,
             job_content_type,
-            None,
+            user,
             data={},  # TODO: parsing CLI args into a data dictionary is not currently implemented
-            request=None,
+            request=copy_safe_request(request) if request else None,
             commit=options["commit"],
         )
 
diff --git a/tasks.py b/tasks.py
--- a/tasks.py
+++ b/tasks.py
@@ -272,11 +272,14 @@ def restart(context, service=None):
     docker_compose(context, "restart", service=service)
 
 
-@task
-def stop(context):
+@task(help={"service": "If specified, only affect this service."})
+def stop(context, service=None):
     """Stop Nautobot and its dependencies."""
     print("Stopping Nautobot...")
-    docker_compose(context, "down")
+    if not service:
+        docker_compose(context, "down")
+    else:
+        docker_compose(context, "stop", service=service)
 
 
 @task


Test patch:
diff --git a/nautobot/extras/tests/dummy_jobs/test_modify_db.py b/nautobot/extras/tests/dummy_jobs/test_modify_db.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/tests/dummy_jobs/test_modify_db.py
@@ -0,0 +1,17 @@
+from nautobot.extras.jobs import Job
+from nautobot.extras.models import Status
+
+
+class TestModifyDB(Job):
+    """
+    Job that modifies the database.
+    """
+
+    def test_modify_db(self):
+        """
+        Job function.
+        """
+        Status.objects.create(
+            name="Test Status",
+            slug="test-status",
+        )
diff --git a/nautobot/extras/tests/test_jobs.py b/nautobot/extras/tests/test_jobs.py
--- a/nautobot/extras/tests/test_jobs.py
+++ b/nautobot/extras/tests/test_jobs.py
@@ -1,16 +1,21 @@
 import json
+from io import StringIO
 import os
 import uuid
 
+from django.core.exceptions import ObjectDoesNotExist
 from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.management import call_command
+from django.core.management.base import CommandError
 from django.conf import settings
+from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 
 from nautobot.dcim.models import DeviceRole, Site
 from nautobot.extras.choices import JobResultStatusChoices
 from nautobot.extras.jobs import get_job, run_job
-from nautobot.extras.models import FileAttachment, FileProxy, JobResult
-from nautobot.utilities.testing import TestCase
+from nautobot.extras.models import FileAttachment, FileProxy, JobResult, Status
+from nautobot.utilities.testing import CeleryTestCase, TestCase
 
 
 class JobTest(TestCase):
@@ -346,3 +351,73 @@ def test_run_job_fail(self):
 
             # Assert that FileProxy was cleaned up
             self.assertEqual(FileProxy.objects.count(), 0)
+
+
+class RunJobManagementCommandTest(CeleryTestCase):
+    """Test cases for the `nautobot-server runjob` management command."""
+
+    def run_command(self, *args):
+        out = StringIO()
+        err = StringIO()
+        call_command(
+            "runjob",
+            *args,
+            stdout=out,
+            stderr=err,
+        )
+
+        return (out.getvalue(), err.getvalue())
+
+    def test_runjob_nochange_successful(self):
+        """Basic success-path test for Jobs that don't modify the Nautobot database."""
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+            out, err = self.run_command("local/test_pass/TestPass")
+            self.assertIn("Running local/test_pass/TestPass...", out)
+            self.assertIn("test_pass: 1 success, 1 info, 0 warning, 0 failure", out)
+            self.assertIn("info: Database changes have been reverted automatically.", out)
+            self.assertIn("local/test_pass/TestPass: SUCCESS", out)
+            self.assertEqual("", err)
+
+    def test_runjob_db_change_no_commit(self):
+        """A job that changes the DB, when run with commit=False, doesn't modify the database."""
+        with self.assertRaises(ObjectDoesNotExist):
+            Status.objects.get(slug="test-status")
+
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+            out, err = self.run_command("local/test_modify_db/TestModifyDB")
+            self.assertIn("Running local/test_modify_db/TestModifyDB...", out)
+            self.assertIn("test_modify_db: 0 success, 1 info, 0 warning, 0 failure", out)
+            self.assertIn("info: Database changes have been reverted automatically.", out)
+            self.assertIn("local/test_modify_db/TestModifyDB: SUCCESS", out)
+            self.assertEqual("", err)
+
+        with self.assertRaises(ObjectDoesNotExist):
+            Status.objects.get(slug="test-status")
+
+    def test_runjob_db_change_commit_no_username(self):
+        """A job that changes the DB, when run with commit=True but no username, is rejected."""
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+            with self.assertRaises(CommandError):
+                self.run_command("--commit", "local/test_modify_db/TestModifyDB")
+
+    def test_runjob_db_change_commit_wrong_username(self):
+        """A job that changes the DB, when run with commit=True and a nonexistent username, is rejected."""
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+            with self.assertRaises(CommandError):
+                self.run_command("--commit", "--username", "nosuchuser", "local/test_modify_db/TestModifyDB")
+
+    def test_runjob_db_change_commit_and_username(self):
+        """A job that chagnes the DB, when run with commit=True and a username, successfully updates the DB."""
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+            get_user_model().objects.create(username="dummy_user")
+
+            out, err = self.run_command("--commit", "--username", "dummy_user", "local/test_modify_db/TestModifyDB")
+            self.assertIn("Running local/test_modify_db/TestModifyDB...", out)
+            self.assertIn("test_modify_db: 0 success, 0 info, 0 warning, 0 failure", out)
+            self.assertIn("local/test_modify_db/TestModifyDB: SUCCESS", out)
+            self.assertEqual("", err)
+
+        status = Status.objects.get(slug="test-status")
+        self.assertEqual(status.name, "Test Status")
+
+        status.delete()