Repository: nautobot/nautobot

Issue:
Allow Jobs to be marked as read-only
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for proposing specific new features or enhancements.
    If you have a general idea or question, please start a discussion instead:
    https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot.
-->
### Environment
* Python version: Python 3.8.5
* Nautobot version: 1.0.0b2

<!--
    Describe in detail the new functionality you are proposing. Include any
    specific changes to work flows, data models, or the user interface.
-->
### Proposed Functionality

It would be helpful to be able to mark a Job as being read-only, i.e. not modifying the database. This would then remove "Commit message" checkbox from the Job form, and would stop "Database changes..." log messages from showing up in the Job results.

<!--
    Convey an example use case for your proposed feature. Write from the
    perspective of a Nautobot user who would benefit from the proposed
    functionality and describe how.
--->
### Use Case

As an end user running Jobs that are read-only (formerly known as Reports) I might not understand why I'm seeing the "Commit changes" checkbox and why "Database changes..." logs show up in Job results.

As a programmer writing a Job I want to be able to specify that given Job will not modify database objects.

<!--
    Note any changes to the database schema necessary to support the new
    feature. For example, does the proposal require adding a new model or
    field? (Not all new features require database changes.)
--->
### Database Changes

None.
<!--
    List any new dependencies on external libraries or services that this new
    feature would introduce. For example, does the proposal require the
    installation of a new Python package? (Not all new features introduce new
    dependencies.)
-->
### External Dependencies

None.


Discussion:
Thanks @progala for submitting this. :)

Resolving patch:
diff --git a/nautobot/extras/jobs.py b/nautobot/extras/jobs.py
--- a/nautobot/extras/jobs.py
+++ b/nautobot/extras/jobs.py
@@ -81,6 +81,7 @@ class Meta:
         - description (str)
         - commit_default (bool)
         - field_order (list)
+        - read_only (bool)
         """
 
         pass
@@ -175,6 +176,10 @@ def name(cls):
     def description(cls):
         return getattr(cls.Meta, "description", "")
 
+    @classproperty
+    def read_only(cls):
+        return getattr(cls.Meta, "read_only", False)
+
     @classmethod
     def _get_vars(cls):
         vars = OrderedDict()
@@ -263,8 +268,13 @@ def as_form(self, data=None, files=None, initial=None):
 
         form = FormClass(data, files, initial=initial)
 
-        # Set initial "commit" checkbox state based on the Meta parameter
-        form.fields["_commit"].initial = getattr(self.Meta, "commit_default", True)
+        if self.read_only:
+            # Hide the commit field for read only jobs
+            form.fields["_commit"].widget = forms.HiddenInput()
+            form.fields["_commit"].initial = False
+        else:
+            # Set initial "commit" checkbox state based on the Meta parameter
+            form.fields["_commit"].initial = getattr(self.Meta, "commit_default", True)
 
         field_order = getattr(self.Meta, "field_order", None)
 
@@ -801,6 +811,10 @@ def run_job(data, request, job_result, commit=True, *args, **kwargs):
     job = job_class()
     job.job_result = job_result
 
+    if job.read_only:
+        # Force commit to false for read only jobs.
+        commit = False
+
     # TODO: validate that all args required by this job are set in the data or else log helpful errors?
 
     job.logger.info(f"Running job (commit={commit})")
@@ -823,6 +837,9 @@ def _run_job():
 
         We capture this within a subfunction to allow for conditionally wrapping it with the change_logging
         context manager (which is only relevant if commit == True).
+
+        If the job is marked as read_only == True, then commit is forced to False and no log messages will be
+        emitted related to reverting database changes.
         """
         job.results["output"] = ""
         try:
@@ -851,12 +868,14 @@ def _run_job():
                     raise AbortTransaction()
 
         except AbortTransaction:
-            job.log_info(message="Database changes have been reverted automatically.")
+            if not job.read_only:
+                job.log_info(message="Database changes have been reverted automatically.")
 
         except Exception as exc:
             stacktrace = traceback.format_exc()
             job.log_failure(message=f"An exception occurred: `{type(exc).__name__}: {exc}`\n```\n{stacktrace}\n```")
-            job.log_info(message="Database changes have been reverted due to error.")
+            if not job.read_only:
+                job.log_info(message="Database changes have been reverted due to error.")
             job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
 
         finally:


Test patch:
diff --git a/nautobot/extras/tests/dummy_jobs/test_read_only_fail.py b/nautobot/extras/tests/dummy_jobs/test_read_only_fail.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/tests/dummy_jobs/test_read_only_fail.py
@@ -0,0 +1,23 @@
+from nautobot.dcim.models import Site
+from nautobot.extras.jobs import Job
+
+
+class TestReadOnlyFail(Job):
+    """
+    Read only Job with fail result.
+    """
+
+    description = "Validate job import"
+
+    class Meta:
+        read_only = True
+
+    def run(self, data, commit):
+        """
+        Job function.
+        """
+
+        site = Site.objects.create(name="Site", slug="site")
+
+        self.log_success(obj=site)
+        raise Exception("Test failure")
diff --git a/nautobot/extras/tests/dummy_jobs/test_read_only_no_commit_field.py b/nautobot/extras/tests/dummy_jobs/test_read_only_no_commit_field.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/tests/dummy_jobs/test_read_only_no_commit_field.py
@@ -0,0 +1,10 @@
+from nautobot.extras.jobs import Job, StringVar
+
+
+class TestReadOnlyNoCommitField(Job):
+    """My job demo."""
+
+    var = StringVar(description="Hello")
+
+    class Meta:
+        read_only = True
diff --git a/nautobot/extras/tests/dummy_jobs/test_read_only_pass.py b/nautobot/extras/tests/dummy_jobs/test_read_only_pass.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/tests/dummy_jobs/test_read_only_pass.py
@@ -0,0 +1,22 @@
+from nautobot.dcim.models import Site
+from nautobot.extras.jobs import Job
+
+
+class TestReadOnlyPass(Job):
+    """
+    Ready only Job with pass result.
+    """
+
+    description = "Validate job import"
+
+    class Meta:
+        read_only = True
+
+    def run(self, data, commit):
+        """
+        Job function.
+        """
+
+        site = Site.objects.create(name="Site", slug="site")
+
+        self.log_success(obj=site)
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
@@ -4,6 +4,7 @@
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 
+from nautobot.dcim.models import Site
 from nautobot.extras.choices import JobResultStatusChoices
 from nautobot.extras.jobs import get_job, run_job
 from nautobot.extras.models import JobResult
@@ -106,3 +107,68 @@ def test_no_field_order(self):
 <input checked id="id__commit" name="_commit" placeholder="Commit changes" type="checkbox">
 <br><span class="helptext">Commit changes to the database (uncheck for a dry-run)</span></td></tr>""",
             )
+
+    def test_ready_only_job_pass(self):
+        """
+        Job read only test with pass result.
+        """
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+
+            module = "test_read_only_pass"
+            name = "TestReadOnlyPass"
+            job_class = get_job(f"local/{module}/{name}")
+            job_content_type = ContentType.objects.get(app_label="extras", model="job")
+
+            job_result = JobResult.objects.create(
+                name=job_class.class_path,
+                obj_type=job_content_type,
+                user=None,
+                job_id=uuid.uuid4(),
+            )
+
+            run_job(data={}, request=None, commit=False, job_result=job_result)
+            self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_COMPLETED)
+            self.assertEqual(Site.objects.count(), 0)  # Ensure DB transaction was aborted
+
+    def test_read_only_job_fail(self):
+        """
+        Job read only test with fail result.
+        """
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+
+            module = "test_read_only_fail"
+            name = "TestReadOnlyFail"
+            job_class = get_job(f"local/{module}/{name}")
+            job_content_type = ContentType.objects.get(app_label="extras", model="job")
+            job_result = JobResult.objects.create(
+                name=job_class.class_path,
+                obj_type=job_content_type,
+                user=None,
+                job_id=uuid.uuid4(),
+            )
+            run_job(data={}, request=None, commit=False, job_result=job_result)
+            self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_ERRORED)
+            self.assertEqual(Site.objects.count(), 0)  # Ensure DB transaction was aborted
+            # Also ensure the standard log message about aborting the transaction is *not* present
+            self.assertNotEqual(
+                job_result.data["run"]["log"][-1][-1], "Database changes have been reverted due to error."
+            )
+
+    def test_read_only_no_commit_field(self):
+        """
+        Job read only test commit field is not shown.
+        """
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+
+            module = "test_read_only_no_commit_field"
+            name = "TestReadOnlyNoCommitField"
+            job_class = get_job(f"local/{module}/{name}")
+
+            form = job_class().as_form()
+
+            self.assertHTMLEqual(
+                form.as_table(),
+                """<tr><th><label for="id_var">Var:</label></th><td>
+<input class="form-control form-control" id="id_var" name="var" placeholder="None" required type="text">
+<br><span class="helptext">Hello</span><input id="id__commit" name="_commit" type="hidden" value="False"></td></tr>""",
+            )