Repository: nautobot/nautobot

Issue:
The Jobs REST API feature should auto-retrieve objects defined as ObjectVar fields
<!--
    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: 3.8
* Nautobot version: 1.0.2

<!--
    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 a Job with a field defined as ObjectVar
2. Take necessary steps to have job available to Web and Worker instances
3. Use the REST API to start a job (It is unclear how the ObjectVar field should be represented, but I would expect a dict with key/value pairs matching the Related Object's field/value pairs, such that calling the ORM's `RelatedObjectModel.objects.get()` with the data would return the single matching record (or else raise an exception).

<!-- What did you expect to happen? -->
### Expected Behavior
I expected the field to automatically look up the Record in the ORM as defined in the payload, and the variable in the job to be a ModelBase instance.

<!-- What happened instead? -->
### Observed Behavior
The field is not looked up and is whatever data is sent via the payload. For instance, if I pass a string, say the UUID of the Related Object, it will be the string passed in via the payload. If is a dict, say something like {"id": uuid_of_related_object}, it is a dictionary with the key value pair(s) passed in via the payload. This results in later code calling attributes on the expected ModelBase instance to raise errors, since these attributes are not defined for str or dict objects.


Discussion:
This is related to #651 but is a distinct side effect of that behavior, and fixing that behavior will need to address how to validate ObjectVar fields, as these cannot be passed via REST.
For anyone looking for a workaround, you can use try/except or isinstance in the job to catch this before executing code, and normalize the field data in the job before executing any code using the field.

```
from nautobot.dcim.models.sites import Region
from nautobot.extras.jobs import Job, ObjectVar


class CustomJob(Job):
    region = ObjectVar(
        model=Region
    )

    def run(self, data, commit):
        region = data["region"]
        if isinstance(region, dict):
            region = Region.objects.get(**region)
        elif isinstance(region, str):
            region = Region.objects.get(id=region)
```
Thanks for the report!

Resolving patch:
diff --git a/nautobot/extras/jobs.py b/nautobot/extras/jobs.py
--- a/nautobot/extras/jobs.py
+++ b/nautobot/extras/jobs.py
@@ -364,7 +364,10 @@ def deserialize_data(cls, data):
                 return_data[field_name] = var.field_attrs["queryset"].filter(pk__in=value)
 
             elif isinstance(var, ObjectVar):
-                return_data[field_name] = var.field_attrs["queryset"].get(pk=value)
+                if type(value) is dict:
+                    return_data[field_name] = var.field_attrs["queryset"].get(**value)
+                else:
+                    return_data[field_name] = var.field_attrs["queryset"].get(pk=value)
             elif isinstance(var, FileVar):
                 return_data[field_name] = cls.load_file(value)
             # IPAddressVar is a netaddr.IPAddress object


Test patch:
diff --git a/nautobot/extras/tests/dummy_jobs/test_object_vars.py b/nautobot/extras/tests/dummy_jobs/test_object_vars.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/tests/dummy_jobs/test_object_vars.py
@@ -0,0 +1,36 @@
+import json
+
+from nautobot.dcim.models import DeviceRole
+from nautobot.extras.jobs import *
+
+
+name = "Object Vars"
+
+
+class TestObjectVars(Job):
+    class Meta:
+        description = "Validate Objects"
+
+    role = ObjectVar(model=DeviceRole)
+    roles = MultiObjectVar(model=DeviceRole)
+
+    def run(self, data, commit):
+        role = data["role"]
+        roles = data["roles"]
+
+        # Log the data as JSON so we can pull it back out for testing.
+        self.log_info(
+            obj=json.dumps(
+                {
+                    "role": str(role.pk),
+                    "roles": [str(r) for r in roles.values_list("pk", flat=True)],
+                }
+            )
+        )
+
+        self.log_warning(f"Role: {role}")
+        self.log_warning(f"Roles: {roles}")
+
+        self.log_success(message="Job didn't crash!")
+
+        return "Nice Roles, bro."
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
@@ -42,7 +42,7 @@
     Tag,
     Webhook,
 )
-from nautobot.extras.jobs import Job, BooleanVar, IntegerVar, StringVar
+from nautobot.extras.jobs import Job, BooleanVar, IntegerVar, StringVar, ObjectVar
 from nautobot.utilities.testing import APITestCase, APIViewTestCases
 from nautobot.utilities.testing.utils import disable_warnings
 
@@ -426,6 +426,7 @@ class Meta:
         var1 = StringVar()
         var2 = IntegerVar()
         var3 = BooleanVar()
+        var4 = ObjectVar(model=DeviceRole)
 
         def run(self, data, commit=True):
             self.log_info(message=data["var1"])
@@ -538,6 +539,48 @@ def test_run_job_with_permission(self):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data["result"]["status"]["value"], "pending")
 
+    @skipIf(not rq_worker_running, "RQ worker not running")
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"], JOBS_ROOT=THIS_DIRECTORY)
+    def test_run_job_object_var(self):
+        self.add_permissions("extras.run_job")
+        d = DeviceRole.objects.create(name="role", slug="role")
+        job_data = {"var4": d.pk}
+
+        data = {
+            "data": job_data,
+            "commit": True,
+        }
+
+        url = reverse("extras-api:job-run", kwargs={"class_path": "local/test_api/TestJob"})
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        url = reverse("extras-api:jobresult-detail", kwargs={"pk": response.data["result"]["id"]})
+        response = self.client.get(url, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data["data"]["vars"]["var4"], d.pk)
+
+    @skipIf(not rq_worker_running, "RQ worker not running")
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"], JOBS_ROOT=THIS_DIRECTORY)
+    def test_run_job_object_var_lookup(self):
+        self.add_permissions("extras.run_job")
+        d = DeviceRole.objects.create(name="role", slug="role")
+        job_data = {"var4": {"name": "role"}}
+
+        data = {
+            "data": job_data,
+            "commit": True,
+        }
+
+        url = reverse("extras-api:job-run", kwargs={"class_path": "local/test_api/TestJob"})
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        url = reverse("extras-api:jobresult-detail", kwargs={"pk": response.data["result"]["id"]})
+        response = self.client.get(url, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data["data"]["vars"]["var4"], d.pk)
+
 
 class JobResultTest(APITestCase):
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
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
@@ -6,7 +6,7 @@
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 
-from nautobot.dcim.models import Site
+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
@@ -226,6 +226,40 @@ def test_ip_address_vars(self):
             self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_COMPLETED)
             self.assertEqual(form_data, job_result_data)
 
+    def test_object_vars(self):
+        """
+        Test that Object variable fields behave as expected.
+        """
+        with self.settings(JOBS_ROOT=os.path.join(settings.BASE_DIR, "extras/tests/dummy_jobs")):
+
+            module = "test_object_vars"
+            name = "TestObjectVars"
+            job_class = get_job(f"local/{module}/{name}")
+
+            d = DeviceRole.objects.create(name="role", slug="role")
+
+            # Prepare the job data
+            job_result = JobResult.objects.create(
+                name=job_class.class_path,
+                obj_type=self.job_content_type,
+                user=None,
+                job_id=uuid.uuid4(),
+            )
+            data = {
+                "role": {"name": "role"},
+                "roles": [d.pk],
+            }
+
+            # Run the job and extract the job payload data
+            run_job(data=data, request=None, commit=False, job_result_pk=job_result.pk)
+            job_result.refresh_from_db()
+            job_payload = job_result.data["run"]["log"][0][2]  # Indexing makes me sad.
+            job_result_data = json.loads(job_payload)
+
+            # Assert stuff
+            self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_COMPLETED)
+            self.assertEqual({"role": str(d.pk), "roles": [str(d.pk)]}, job_result_data)
+
 
 class JobFileUploadTest(TestCase):
     """Test a job that uploads/deletes files."""