Repository: nautobot/nautobot

Issue:
Execute Job API by name
As a Network Automation Engineer, want to execute Nautobot Jobs in the API, referenced by their name, so that I can use the most natural identifier, instead of having to know (or first obtain) the job's UUID, or class path.


I will know this is done when the API and its schema allow accessing and executing a job by reference of the job's name alone. This should now be achievable since Job.name is unique: `/api/extras/jobs/{name}/`


The 2.0 schema those these routes:
[Private Zenhub Image](https://api.zenhub.com/attachedFiles/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdmFzIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0fed3e2b923bb8c5f54a0bcfffb3a3649f42074f/Screenshot%202023-09-18%20at%2011.53.45%20AM.png)

1.6 shows these routes, notably with class path being deprecated:
[Private Zenhub Image](https://api.zenhub.com/attachedFiles/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdnlzIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c7e7256ac58f6099b294eb194af67ca803710999/Screenshot%202023-09-18%20at%2011.54.29%20AM.png)



Discussion:
Hey team! Please [add your planning poker estimate](https://app.zenhub.com/workspaces/Nautobot-Core-Product-64a8783b7a3f4b1225973a0c/issues/nautobot/nautobot/4476?planning-poker) with Zenhub @glennmatthews @gsnider2195 @HanlinMiao @jathanism @timizuoebideri1
For reference: https://www.django-rest-framework.org/api-guide/generic-views/#attributes

Resolving patch:
diff --git a/nautobot/core/settings.py b/nautobot/core/settings.py
--- a/nautobot/core/settings.py
+++ b/nautobot/core/settings.py
@@ -240,6 +240,10 @@
     # use sidecar - locally packaged UI files, not CDN
     "SWAGGER_UI_DIST": "SIDECAR",
     "SWAGGER_UI_FAVICON_HREF": "SIDECAR",
+    "SWAGGER_UI_SETTINGS": {
+        "deepLinking": True,
+        "displayOperationId": True,
+    },
     "REDOC_DIST": "SIDECAR",
     # Do not list all possible enum values in the description of filter fields and the like
     # In addition to being highly verbose, it's inaccurate for filter fields like *__ic and *__re
diff --git a/nautobot/extras/api/urls.py b/nautobot/extras/api/urls.py
--- a/nautobot/extras/api/urls.py
+++ b/nautobot/extras/api/urls.py
@@ -42,6 +42,7 @@
 
 # Jobs
 router.register("jobs", views.JobViewSet)
+router.register("jobs", views.JobByNameViewSet)
 
 # Job Buttons
 router.register("job-buttons", views.JobButtonViewSet)
diff --git a/nautobot/extras/api/views.py b/nautobot/extras/api/views.py
--- a/nautobot/extras/api/views.py
+++ b/nautobot/extras/api/views.py
@@ -6,7 +6,7 @@
 from django.shortcuts import get_object_or_404
 from django.utils import timezone
 from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import extend_schema, OpenApiParameter
+from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
 from graphene_django.views import GraphQLView
 from graphql import GraphQLError
 from rest_framework import status
@@ -24,6 +24,8 @@
     BulkDestroyModelMixin,
     BulkUpdateModelMixin,
     ModelViewSet,
+    ModelViewSetMixin,
+    NautobotAPIVersionMixin,
     ReadOnlyModelViewSet,
 )
 from nautobot.core.exceptions import CeleryWorkerNotRunningException
@@ -85,11 +87,11 @@ class NotesViewSetMixin:
         responses={201: serializers.NoteSerializer(many=False)},
     )
     @action(detail=True, url_path="notes", methods=["get", "post"])
-    def notes(self, request, pk=None):
+    def notes(self, request, *args, **kwargs):
         """
         API methods for returning or creating notes on an object.
         """
-        obj = get_object_or_404(self.queryset, pk=pk)
+        obj = get_object_or_404(self.queryset, **{self.lookup_field: self.kwargs[self.lookup_url_kwarg]})
         if request.method == "POST":
             content_type = ContentType.objects.get_for_model(obj)
             data = request.data
@@ -428,16 +430,14 @@ def _create_schedule(serializer, data, job_model, user, approval_required, task_
     return scheduled_job
 
 
-class JobViewSet(
-    # DRF mixins:
+class JobViewSetBase(
+    NautobotAPIVersionMixin,
     # note no CreateModelMixin
+    mixins.RetrieveModelMixin,
     mixins.UpdateModelMixin,
     mixins.DestroyModelMixin,
-    # Nautobot mixins:
-    BulkUpdateModelMixin,
-    BulkDestroyModelMixin,
-    # Base class
-    ReadOnlyModelViewSet,
+    ModelViewSetMixin,
+    viewsets.GenericViewSet,
     NotesViewSetMixin,
 ):
     queryset = Job.objects.all()
@@ -446,7 +446,7 @@ class JobViewSet(
 
     @extend_schema(responses={"200": serializers.JobVariableSerializer(many=True)})
     @action(detail=True, filterset_class=None)
-    def variables(self, request, pk):
+    def variables(self, request, *args, **kwargs):
         """Get details of the input variables that may/must be specified to run a particular Job."""
         job_model = self.get_object()
         job_class = job_model.job_class
@@ -506,7 +506,7 @@ class JobRunTokenPermissions(TokenPermissions):
         permission_classes=[JobRunTokenPermissions],
         parser_classes=[JSONParser, MultiPartParser],
     )
-    def run(self, request, *args, pk, **kwargs):
+    def run(self, request, *args, **kwargs):
         """Run the specified Job."""
         job_model = self.get_object()
         if not request.user.has_perm("extras.run_job"):
@@ -655,6 +655,43 @@ def run(self, request, *args, pk, **kwargs):
         return Response(data, status=status.HTTP_201_CREATED)
 
 
+class JobViewSet(
+    JobViewSetBase,
+    mixins.ListModelMixin,
+    BulkUpdateModelMixin,
+    BulkDestroyModelMixin,
+):
+    lookup_value_regex = r"[-0-9a-fA-F]+"
+
+
+@extend_schema_view(
+    destroy=extend_schema(operation_id="extras_jobs_destroy_by_name"),
+    partial_update=extend_schema(operation_id="extras_jobs_partial_update_by_name"),
+    notes=extend_schema(methods=["get"], operation_id="extras_jobs_notes_list_by_name"),
+    retrieve=extend_schema(operation_id="extras_jobs_retrieve_by_name"),
+    run=extend_schema(
+        methods=["post"],
+        operation_id="extras_jobs_run_create_by_name",
+        request={
+            "application/json": serializers.JobInputSerializer,
+            "multipart/form-data": serializers.JobMultiPartInputSerializer,
+        },
+        responses={"201": serializers.JobRunResponseSerializer},
+    ),
+    update=extend_schema(operation_id="extras_jobs_update_by_name"),
+    variables=extend_schema(operation_id="extras_jobs_variables_list_by_name"),
+)
+@extend_schema_view(
+    notes=extend_schema(methods=["post"], operation_id="extras_jobs_notes_create_by_name"),
+)
+class JobByNameViewSet(
+    JobViewSetBase,
+):
+    lookup_field = "name"
+    lookup_url_kwarg = "name"
+    lookup_value_regex = r"[^/]+"
+
+
 #
 # Job Hooks
 #


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
@@ -1118,6 +1118,20 @@ def test_get_job_variables(self):
             {"name": "var4", "type": "ObjectVar", "required": True, "model": "extras.role"},
         )
 
+    def test_get_job_variables_by_name(self):
+        """Test the job/<name>/variables API endpoint."""
+        self.add_permissions("extras.view_job")
+        route = get_route_for_model(self.model, "variables", api=True)
+        response = self.client.get(reverse(route, kwargs={"name": self.job_model.name}), **self.header)
+        self.assertEqual(4, len(response.data))  # 4 variables, in order
+        self.assertEqual(response.data[0], {"name": "var1", "type": "StringVar", "required": True})
+        self.assertEqual(response.data[1], {"name": "var2", "type": "IntegerVar", "required": True})
+        self.assertEqual(response.data[2], {"name": "var3", "type": "BooleanVar", "required": False})
+        self.assertEqual(
+            response.data[3],
+            {"name": "var4", "type": "ObjectVar", "required": True, "model": "extras.role"},
+        )
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_update_job_with_sensitive_variables_set_approval_required_to_true(self):
         job_model = Job.objects.get_for_class_path("api_test_job.APITestJob")