Repository: nautobot/nautobot

Issue:
Jobs REST API valid input per #837 is rejected as invalid after #828
<!--
    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
* Python version:
* Nautobot version: 1.1.3

<!--
    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. Try to run a Job via the REST API that takes an ObjectVar as input, and specify the ObjectVar value as a set of query parameters as described in #837.
2. Try to run a Job via the REST API that takes an IPAddressVar as input, and specify the IPAddressVar value as a string

<!-- What did you expect to happen? -->
### Expected Behavior

HTTP 200 OK and the job to be properly enqueued.

<!-- What happened instead? -->
### Observed Behavior

In both cases the REST API rejects the input with a 400 Bad Request:

```
{'errors': {
    'var4': ["“{'name': 'role'}” is not a valid UUID."],
    'var5': ['Invalid IPv4/IPv6 address format: 1.1.1.1/32']
}}
```

This appears to be a regression introduced by #828.


Discussion:

Resolving patch:
diff --git a/nautobot/extras/forms.py b/nautobot/extras/forms.py
--- a/nautobot/extras/forms.py
+++ b/nautobot/extras/forms.py
@@ -899,13 +899,13 @@ def clean(self):
         """
         cleaned_data = super().clean()
 
-        if cleaned_data["_schedule_type"] != JobExecutionType.TYPE_IMMEDIATELY:
-            if not cleaned_data["_schedule_name"]:
+        if "_schedule_type" in cleaned_data and cleaned_data.get("_schedule_type") != JobExecutionType.TYPE_IMMEDIATELY:
+            if not cleaned_data.get("_schedule_name"):
                 raise ValidationError({"_schedule_name": "Please provide a name for the job schedule."})
 
             if (
-                not cleaned_data["_schedule_start_time"]
-                or cleaned_data["_schedule_start_time"] < ScheduledJob.earliest_possible_time()
+                not cleaned_data.get("_schedule_start_time")
+                or cleaned_data.get("_schedule_start_time") < ScheduledJob.earliest_possible_time()
             ):
                 raise ValidationError(
                     {
diff --git a/nautobot/extras/jobs.py b/nautobot/extras/jobs.py
--- a/nautobot/extras/jobs.py
+++ b/nautobot/extras/jobs.py
@@ -416,7 +416,7 @@ def validate_data(self, data):
                 raise ValidationError({k: "Job data contained an unknown property"})
 
         # defer validation to the form object
-        f = self.as_form(data=data)
+        f = self.as_form(data=self.deserialize_data(data))
         if not f.is_valid():
             raise ValidationError(f.errors)
 


Test patch:
diff --git a/nautobot/extras/tests/test_api.py b/nautobot/extras/tests/test_api.py
--- a/nautobot/extras/tests/test_api.py
+++ b/nautobot/extras/tests/test_api.py
@@ -1,7 +1,7 @@
 from datetime import datetime, timedelta
 import os.path
 import uuid
-from unittest import skipIf
+from unittest import skipIf, mock
 
 from django.conf import settings
 from django.contrib.auth import get_user_model
@@ -46,7 +46,6 @@
     Webhook,
 )
 from nautobot.extras.jobs import Job, BooleanVar, IntegerVar, StringVar, ObjectVar
-from nautobot.extras.utils import get_worker_count
 from nautobot.utilities.testing import APITestCase, APIViewTestCases
 from nautobot.utilities.testing.utils import disable_warnings
 
@@ -54,9 +53,6 @@
 User = get_user_model()
 
 
-celery_worker_running = bool(get_worker_count())
-
-
 THIS_DIRECTORY = os.path.dirname(__file__)
 
 
@@ -617,10 +613,11 @@ def setUpTestData(cls):
             },
         ]
 
-    @skipIf(celery_worker_running, "Celery worker is running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
-    def test_run_git_sync_no_celery_worker(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_git_sync_no_celery_worker(self, mock_get_worker_count):
         """Git sync cannot be triggered if Celery is not running."""
+        mock_get_worker_count.return_value = 0
         self.add_permissions("extras.add_gitrepository")
         self.add_permissions("extras.change_gitrepository")
         url = reverse("extras-api:gitrepository-sync", kwargs={"pk": self.repos[0].id})
@@ -628,28 +625,31 @@ def test_run_git_sync_no_celery_worker(self):
         self.assertHttpStatus(response, status.HTTP_503_SERVICE_UNAVAILABLE)
         self.assertEqual(response.data["detail"], "Unable to process request: Celery worker process not running.")
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
-    def test_run_git_sync_nonexistent_repo(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_git_sync_nonexistent_repo(self, mock_get_worker_count):
         """Git sync request handles case of a nonexistent repository."""
+        mock_get_worker_count.return_value = 1
         self.add_permissions("extras.add_gitrepository")
         self.add_permissions("extras.change_gitrepository")
         url = reverse("extras-api:gitrepository-sync", kwargs={"pk": "11111111-1111-1111-1111-111111111111"})
         response = self.client.post(url, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
-    def test_run_git_sync_without_permissions(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_git_sync_without_permissions(self, mock_get_worker_count):
         """Git sync request verifies user permissions."""
+        mock_get_worker_count.return_value = 1
         url = reverse("extras-api:gitrepository-sync", kwargs={"pk": self.repos[0].id})
         response = self.client.post(url, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
-    def test_run_git_sync_with_permissions(self):
-        """Git sync request can run successfully."""
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_git_sync_with_permissions(self, mock_get_worker_count):
+        """Git sync request can be submitted successfully."""
+        mock_get_worker_count.return_value = 1
         self.add_permissions("extras.add_gitrepository")
         self.add_permissions("extras.change_gitrepository")
         url = reverse("extras-api:gitrepository-sync", kwargs={"pk": self.repos[0].id})
@@ -941,18 +941,20 @@ def test_get_job_with_permission(self):
         response = self.client.get(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[], JOBS_ROOT=THIS_DIRECTORY)
-    def test_run_job_without_permission(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_job_without_permission(self, mock_get_worker_count):
         """Job run request enforces user permissions."""
+        mock_get_worker_count.return_value = 1
         url = reverse("extras-api:job-run", kwargs={"class_path": "local/test_api/TestJob"})
         with disable_warnings("django.request"):
             response = self.client.post(url, {}, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
 
-    @skipIf(celery_worker_running, "Celery worker is running")
-    def test_run_job_no_worker(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_job_no_worker(self, mock_get_worker_count):
         """Job run cannot be requested if Celery is not running."""
+        mock_get_worker_count.return_value = 0
         self.add_permissions("extras.run_job")
         device_role = DeviceRole.objects.create(name="role", slug="role")
         job_data = {
@@ -972,10 +974,11 @@ def test_run_job_no_worker(self):
         self.assertHttpStatus(response, status.HTTP_503_SERVICE_UNAVAILABLE)
         self.assertEqual(response.data["detail"], "Unable to process request: Celery worker process not running.")
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"], JOBS_ROOT=THIS_DIRECTORY)
-    def test_run_job_object_var(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_job_object_var(self, mock_get_worker_count):
         """Job run requests can reference objects by their primary keys."""
+        mock_get_worker_count.return_value = 1
         self.add_permissions("extras.run_job")
         device_role = DeviceRole.objects.create(name="role", slug="role")
         job_data = {
@@ -1002,11 +1005,11 @@ def test_run_job_object_var(self):
         job = ScheduledJob.objects.last()
         self.assertEqual(job.kwargs["data"]["var4"], str(device_role.pk))
 
-    # TODO: needs to be fixed - see https://github.com/nautobot/nautobot/issues/958
-    @skipIf(True, "Disabled due to issue #958")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"], JOBS_ROOT=THIS_DIRECTORY)
-    def test_run_job_object_var_lookup(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_job_object_var_lookup(self, mock_get_worker_count):
         """Job run requests can reference objects by their attributes."""
+        mock_get_worker_count.return_value = 1
         self.add_permissions("extras.run_job")
         device_role = DeviceRole.objects.create(name="role", slug="role")
         job_data = {
@@ -1016,11 +1019,19 @@ def test_run_job_object_var_lookup(self):
             "var4": {"name": "role"},
         }
 
-        self.assertEqual(self.TestJob.deserialize_data(job_data), {"var4": device_role})
+        self.assertEqual(
+            self.TestJob.deserialize_data(job_data),
+            {"var1": "FooBar", "var2": 123, "var3": False, "var4": device_role},
+        )
+
+        url = reverse("extras-api:job-run", kwargs={"class_path": "local/test_api/TestJob"})
+        response = self.client.post(url, {"data": job_data}, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"], JOBS_ROOT=THIS_DIRECTORY)
-    def test_run_job_future(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_job_future(self, mock_get_worker_count):
+        mock_get_worker_count.return_value = 1
         self.add_permissions("extras.run_job")
         d = DeviceRole.objects.create(name="role", slug="role")
         data = {
@@ -1037,9 +1048,10 @@ def test_run_job_future(self):
         response = self.client.post(url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"], JOBS_ROOT=THIS_DIRECTORY)
-    def test_run_job_future_past(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_job_future_past(self, mock_get_worker_count):
+        mock_get_worker_count.return_value = 1
         self.add_permissions("extras.run_job")
         d = DeviceRole.objects.create(name="role", slug="role")
         data = {
@@ -1056,9 +1068,10 @@ def test_run_job_future_past(self):
         response = self.client.post(url, data, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
-    @skipIf(not celery_worker_running, "Celery worker not running")
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"], JOBS_ROOT=THIS_DIRECTORY)
-    def test_run_job_interval(self):
+    @mock.patch("nautobot.extras.api.views.get_worker_count")
+    def test_run_job_interval(self, mock_get_worker_count):
+        mock_get_worker_count.return_value = 1
         self.add_permissions("extras.run_job")
         d = DeviceRole.objects.create(name="role", slug="role")
         data = {