From c17166c92a7493e18bbb43ba8a5692ef4a5728c3 Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 25 Sep 2023 20:04:46 -0400 Subject: [PATCH 01/29] copy init-jobs changes fom #224 with rebase to next-2.0 branch --- changes/224.added | 3 + nautobot_chatops/workers/nautobot.py | 105 ++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 changes/224.added diff --git a/changes/224.added b/changes/224.added new file mode 100644 index 00000000..ac354b51 --- /dev/null +++ b/changes/224.added @@ -0,0 +1,3 @@ +Add init_job Nautobot subcommand, which starts a Nautobot job by job name. +Add get_jobs Nautobot subcommand, which gets all Nautobot jobs. +Add filter_jobs Nautobot subcommand, which gets filtered set of Nautobot jobs. \ No newline at end of file diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 819fb2be..5ac0442e 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1,5 +1,9 @@ """Worker functions for interacting with Nautobot.""" +import uuid + + +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db.models import Count from django.contrib.contenttypes.models import ContentType @@ -10,7 +14,10 @@ from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Manufacturer, Rack, Cable from nautobot.ipam.models import VLAN, Prefix, VLANGroup from nautobot.tenancy.models import Tenant -from nautobot.extras.models import Role, Status +from nautobot.extras.context_managers import web_request_context +from nautobot.extras.jobs import run_job +from nautobot.extras.models import Job, JobResult, Role, Status +from nautobot.extras.utils import get_job_content_type from nautobot_chatops.choices import CommandStatusChoices from nautobot_chatops.workers import subcommand_of, handle_subcommands @@ -1045,6 +1052,102 @@ def get_circuit_providers(dispatcher, *args): return CommandStatusChoices.STATUS_SUCCEEDED +@subcommand_of("nautobot") +def filter_jobs( + dispatcher, job_filters: str = "" +): # We can use a Literal["enabled", "installed", "runnable"] here instead + """Get a filtered list of jobs from Nautobot. + Args: + job_filters (str): Filter job results by literals in a comma-separated string. + Available filters are: enabled, installed or runnable. + """ + # Check for filters in user supplied input + job_filters_list = [item.strip() for item in job_filters.split(",")] if isinstance(job_filters, str) else "" + filters = ["enabled", "installed", "runnable"] + if any([key in job_filters for key in filters]): + filter_args = {key: True for key in filters if key in job_filters_list} + jobs = Job.objects.restrict(dispatch.user, "view").filter( + **filter_args + ) # enabled=True, installed=True, runnable=True + else: + jobs = Job.objects.restrict(dispatch.user, "view").all() + + header = ["Name", "ID"] + rows = [ + ( + str(job.name), + str(job.id), + ) + for job in jobs + ] + + dispatcher.send_large_table(header, rows) + + return CommandStatusChoices.STATUS_SUCCEEDED + + +@subcommand_of("nautobot") +def get_jobs(dispatcher): + """Get all jobs from Nautobot.""" + jobs = Job.objects.restrict(dispatch.user, "view").all() + + header = ["Name", "ID"] + rows = [ + ( + str(job.name), + str(job.id), + ) + for job in jobs + ] + + dispatcher.send_large_table(header, rows) + + return CommandStatusChoices.STATUS_SUCCEEDED + + +@subcommand_of("nautobot") +def init_job(dispatcher, job_name): + """Initiate a job in Nautobot by job name.""" + # Get instance of the user who will run the job + user = get_user_model() + try: + user_instance = user.objects.get(username=dispatch.user) + except user.DoesNotExist: # Unsure if we need to check this case? + dispatcher.send_error(f"User {dispatch.user} not found") + return (CommandStatusChoices.STATUS_FAILED, f'User "{dispatch.user}" not found') + + # Get the job model instance using job name + try: + job_model = Job.objects.restrict(dispatch.user, "view").get(name=job_name) + except Job.DoesNotExist: + dispatcher.send_error(f"Job {job_name} not found") + return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" not found') + + job_class_path = job_model.class_path + + # Create an instance of job result + job_result = JobResult.objects.create( + name=job_model.class_path, + job_kwargs={"data": {}, "commit": True, "profile": False}, + obj_type=get_job_content_type(), + user=user_instance, + job_model=job_model, + job_id=uuid.uuid4(), + ) + + # Emulate HTTP context for the request as the user + with web_request_context(user=user_instance) as request: + run_job(data={}, request=request, commit=True, job_result_pk=job_result.pk) + + blocks = [ + dispatcher.markdown_block(f"The requested job {job_class_path} was initiated!"), + ] + + dispatcher.send_blocks(blocks) + + return CommandStatusChoices.STATUS_SUCCEEDED + + @subcommand_of("nautobot") def about(dispatcher, *args): """Provide link for more information on Nautobot Apps.""" From 67d545de1e5af100017d2e4a52533d4ad15ac392 Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 02:02:02 -0400 Subject: [PATCH 02/29] Rebased PR for next-2.0 branch, had some trouble with fork Update init_jobs for new pattern, tested and working Add kwargs to init_jobs, untested but looks good Add get_jobs which returns all Nautobot jobs viewable to user Add filter_jobs which returns filtered set of Nautobot jobs viewable to user --- nautobot_chatops/workers/nautobot.py | 72 +++++++++++++++++----------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 5ac0442e..981a40f0 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1,6 +1,7 @@ """Worker functions for interacting with Nautobot.""" import uuid +import json from django.contrib.auth import get_user_model @@ -15,9 +16,7 @@ from nautobot.ipam.models import VLAN, Prefix, VLANGroup from nautobot.tenancy.models import Tenant from nautobot.extras.context_managers import web_request_context -from nautobot.extras.jobs import run_job from nautobot.extras.models import Job, JobResult, Role, Status -from nautobot.extras.utils import get_job_content_type from nautobot_chatops.choices import CommandStatusChoices from nautobot_chatops.workers import subcommand_of, handle_subcommands @@ -1056,7 +1055,7 @@ def get_circuit_providers(dispatcher, *args): def filter_jobs( dispatcher, job_filters: str = "" ): # We can use a Literal["enabled", "installed", "runnable"] here instead - """Get a filtered list of jobs from Nautobot. + """Get a filtered list of jobs from Nautobot that the request user have view permissions for. Args: job_filters (str): Filter job results by literals in a comma-separated string. Available filters are: enabled, installed or runnable. @@ -1066,17 +1065,18 @@ def filter_jobs( filters = ["enabled", "installed", "runnable"] if any([key in job_filters for key in filters]): filter_args = {key: True for key in filters if key in job_filters_list} - jobs = Job.objects.restrict(dispatch.user, "view").filter( + jobs = Job.objects.restrict(dispatcher.user, "view").filter( **filter_args ) # enabled=True, installed=True, runnable=True else: - jobs = Job.objects.restrict(dispatch.user, "view").all() + jobs = Job.objects.restrict(dispatcher.user, "view").all() - header = ["Name", "ID"] + header = ["Name", "ID", "Enabled"] rows = [ ( str(job.name), str(job.id), + str(job.enabled), ) for job in jobs ] @@ -1088,14 +1088,15 @@ def filter_jobs( @subcommand_of("nautobot") def get_jobs(dispatcher): - """Get all jobs from Nautobot.""" - jobs = Job.objects.restrict(dispatch.user, "view").all() + """Get all jobs from Nautobot that the requesting user have view permissions for.""" + jobs = Job.objects.restrict(dispatcher.user, "view").all() - header = ["Name", "ID"] + header = ["Name", "ID", "Enabled"] rows = [ ( str(job.name), str(job.id), + str(job.enabled), ) for job in jobs ] @@ -1106,39 +1107,56 @@ def get_jobs(dispatcher): @subcommand_of("nautobot") -def init_job(dispatcher, job_name): - """Initiate a job in Nautobot by job name.""" +def init_job(dispatcher, job_name: str, kwargs: str = ""): + """Initiate a job in Nautobot by job name. + + Args: + job_name (str): Name of Nautobot job to run. + kwargs (str): JSON-string dictionary for input keyword arguments for job run. + #profile (str): Whether to profile the job execution. + """ + # Confirm kwargs is valid JSON + json_args = {} + try: + if kwargs: + json_args = json.loads(kwargs) + except json.JSONDecodeError as exc: + dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") + return (CommandStatusChoices.STATUS_FAILED, f'Invalid JSON-string, cannot decode: {kwargs}') + + profile = False + if json_args.get("profile") and json_args["profile"] == True: + profile = True + # Get instance of the user who will run the job user = get_user_model() try: - user_instance = user.objects.get(username=dispatch.user) + user_instance = user.objects.get(username=dispatcher.user) except user.DoesNotExist: # Unsure if we need to check this case? - dispatcher.send_error(f"User {dispatch.user} not found") - return (CommandStatusChoices.STATUS_FAILED, f'User "{dispatch.user}" not found') + dispatcher.send_error(f"User {dispatcher.user} not found") + return (CommandStatusChoices.STATUS_FAILED, f'User "{dispatcher.user}" was not found') # Get the job model instance using job name try: - job_model = Job.objects.restrict(dispatch.user, "view").get(name=job_name) + job_model = Job.objects.restrict(dispatcher.user, "view").get(name=job_name) except Job.DoesNotExist: dispatcher.send_error(f"Job {job_name} not found") - return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" not found') + return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" was not found') + + if not job_model.enabled: + dispatcher.send_error(f"The requested job {job_name} is not enabled") + return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" is not enabled') job_class_path = job_model.class_path - # Create an instance of job result - job_result = JobResult.objects.create( - name=job_model.class_path, - job_kwargs={"data": {}, "commit": True, "profile": False}, - obj_type=get_job_content_type(), - user=user_instance, + # TODO: Check if json_args keys are valid for this job model + job_result = JobResult.execute_job( job_model=job_model, - job_id=uuid.uuid4(), + user=user_instance, + profile=profile, + **json_args, ) - # Emulate HTTP context for the request as the user - with web_request_context(user=user_instance) as request: - run_job(data={}, request=request, commit=True, job_result_pk=job_result.pk) - blocks = [ dispatcher.markdown_block(f"The requested job {job_class_path} was initiated!"), ] From e01dbe6d5e8423ff3450c0b089d874c8d04aad51 Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 02:27:05 -0400 Subject: [PATCH 03/29] Correct change fragment, #270 will replace #224, and closes #223 Run black formatter --- changes/224.added | 3 --- changes/270.added | 4 ++++ nautobot_chatops/workers/nautobot.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 changes/224.added create mode 100644 changes/270.added diff --git a/changes/224.added b/changes/224.added deleted file mode 100644 index ac354b51..00000000 --- a/changes/224.added +++ /dev/null @@ -1,3 +0,0 @@ -Add init_job Nautobot subcommand, which starts a Nautobot job by job name. -Add get_jobs Nautobot subcommand, which gets all Nautobot jobs. -Add filter_jobs Nautobot subcommand, which gets filtered set of Nautobot jobs. \ No newline at end of file diff --git a/changes/270.added b/changes/270.added new file mode 100644 index 00000000..2a43fc7f --- /dev/null +++ b/changes/270.added @@ -0,0 +1,4 @@ +Add init_jobs Nautobot subcommand. +Add kwargs input to init_jobs as JSON-string to init_jobs. +Add get_jobs Nautobot subcommand, which returns all Nautobot jobs viewable to user. +Add filter_jobs Nautobot subcommand, which returns filtered set of Nautobot jobs viewable to user. diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 981a40f0..e3ed68b2 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1122,12 +1122,12 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): json_args = json.loads(kwargs) except json.JSONDecodeError as exc: dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") - return (CommandStatusChoices.STATUS_FAILED, f'Invalid JSON-string, cannot decode: {kwargs}') - + return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}") + profile = False if json_args.get("profile") and json_args["profile"] == True: profile = True - + # Get instance of the user who will run the job user = get_user_model() try: From 99e45120b3fd3e15b39cba9f77038ed1e2f0276f Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 03:05:28 -0400 Subject: [PATCH 04/29] Add kwargs for get_jobs() that allows user-specified header items to be exported for jobs. --- nautobot_chatops/workers/nautobot.py | 36 +++++++++++++++++++--------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index e3ed68b2..503b3720 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1087,19 +1087,33 @@ def filter_jobs( @subcommand_of("nautobot") -def get_jobs(dispatcher): - """Get all jobs from Nautobot that the requesting user have view permissions for.""" +def get_jobs(dispatcher, kwargs: str = ""): + """Get all jobs from Nautobot that the requesting user have view permissions for. + + Args: + kwargs (str): JSON-string array of header items to be exported. + """ + # Confirm kwargs is valid JSON + json_args = ["Name", "Id", "Enabled"] + try: + if kwargs: + json_args = json.loads(kwargs) + except json.JSONDecodeError as exc: + dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") + return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}") + jobs = Job.objects.restrict(dispatcher.user, "view").all() - header = ["Name", "ID", "Enabled"] - rows = [ - ( - str(job.name), - str(job.id), - str(job.enabled), - ) - for job in jobs - ] + # Check if all items in json_args are valid keys (assuming all keys of job object are valid) + valid_keys = [attr for attr in dir(Job) if not callable(getattr(Job, attr)) and not attr.startswith("_")] + for item in json_args: + if item not in valid_keys: + dispatcher.send_error(f"Invalid item provided: {item}") + return (CommandStatusChoices.STATUS_FAILED, f"Invalid item provided: {item}") + + # TODO: Check json_args are all valid keys + header = [item.capitalize() for item in json_args] + rows = [(tuple(str(getattr(job, item, "")) for item in json_args)) for job in jobs] dispatcher.send_large_table(header, rows) From 0d623efd10f63dcb992a9fd792d67599b42c63fe Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 03:08:31 -0400 Subject: [PATCH 05/29] Minor update --- nautobot_chatops/workers/nautobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 503b3720..45169a77 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1058,11 +1058,11 @@ def filter_jobs( """Get a filtered list of jobs from Nautobot that the request user have view permissions for. Args: job_filters (str): Filter job results by literals in a comma-separated string. - Available filters are: enabled, installed or runnable. + Available filters are: enabled, installed. """ # Check for filters in user supplied input job_filters_list = [item.strip() for item in job_filters.split(",")] if isinstance(job_filters, str) else "" - filters = ["enabled", "installed", "runnable"] + filters = ["enabled", "installed"] # Runnable is not valid if any([key in job_filters for key in filters]): filter_args = {key: True for key in filters if key in job_filters_list} jobs = Job.objects.restrict(dispatcher.user, "view").filter( From 0f28144637fd8077ee76f352da6cc06eb561df8a Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 03:34:15 -0400 Subject: [PATCH 06/29] Catch job_result.result == "FAILURE" Update markdown to URL --- nautobot_chatops/workers/nautobot.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 45169a77..6161b12c 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1170,9 +1170,14 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): profile=profile, **json_args, ) + + if job_result and job_result.status == "FAILURE": + dispatcher.send_error(f"The requested job {job_name} failed to initiate. Result: {job_result.result}") + return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to initiate. Result: {job_result.result}') + # TODO: need base-domain, this yields: /extras/job-results// blocks = [ - dispatcher.markdown_block(f"The requested job {job_class_path} was initiated!"), + dispatcher.markdown_block(f"The requested job {job_class_path} was initiated! [`click here`]({job_result.get_absolute_url()}) to open the job."), ] dispatcher.send_blocks(blocks) From 9d99ca7d34d76d9ad37b834ab3fade145f6dc4f0 Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 03:35:36 -0400 Subject: [PATCH 07/29] run black --- nautobot_chatops/workers/nautobot.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 6161b12c..45ccfaaa 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1170,14 +1170,16 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): profile=profile, **json_args, ) - + if job_result and job_result.status == "FAILURE": dispatcher.send_error(f"The requested job {job_name} failed to initiate. Result: {job_result.result}") return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to initiate. Result: {job_result.result}') # TODO: need base-domain, this yields: /extras/job-results// blocks = [ - dispatcher.markdown_block(f"The requested job {job_class_path} was initiated! [`click here`]({job_result.get_absolute_url()}) to open the job."), + dispatcher.markdown_block( + f"The requested job {job_class_path} was initiated! [`click here`]({job_result.get_absolute_url()}) to open the job." + ), ] dispatcher.send_blocks(blocks) From 4d0d877845bc1aae1e3f2d1fdc099516ca9ff7a3 Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 03:39:22 -0400 Subject: [PATCH 08/29] pydocstyle, flake8 fixes --- nautobot_chatops/workers/nautobot.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 45ccfaaa..07ec2043 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1056,6 +1056,7 @@ def filter_jobs( dispatcher, job_filters: str = "" ): # We can use a Literal["enabled", "installed", "runnable"] here instead """Get a filtered list of jobs from Nautobot that the request user have view permissions for. + Args: job_filters (str): Filter job results by literals in a comma-separated string. Available filters are: enabled, installed. @@ -1098,7 +1099,7 @@ def get_jobs(dispatcher, kwargs: str = ""): try: if kwargs: json_args = json.loads(kwargs) - except json.JSONDecodeError as exc: + except json.JSONDecodeError: dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}") @@ -1134,12 +1135,12 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): try: if kwargs: json_args = json.loads(kwargs) - except json.JSONDecodeError as exc: + except json.JSONDecodeError: dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}") profile = False - if json_args.get("profile") and json_args["profile"] == True: + if json_args.get("profile") and json_args["profile"] is True: profile = True # Get instance of the user who will run the job From fe07fc3a73bdddccf11d1bf0f15ec15bf0fd7afe Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 03:39:49 -0400 Subject: [PATCH 09/29] black formatter --- nautobot_chatops/workers/nautobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 07ec2043..3fec0247 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1056,7 +1056,7 @@ def filter_jobs( dispatcher, job_filters: str = "" ): # We can use a Literal["enabled", "installed", "runnable"] here instead """Get a filtered list of jobs from Nautobot that the request user have view permissions for. - + Args: job_filters (str): Filter job results by literals in a comma-separated string. Available filters are: enabled, installed. From 1c8da1a94d8993c0fa685f27d95e619e03dffc12 Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 03:53:39 -0400 Subject: [PATCH 10/29] pylint --- nautobot_chatops/workers/nautobot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 3fec0247..2558a177 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1,6 +1,5 @@ """Worker functions for interacting with Nautobot.""" -import uuid import json @@ -15,7 +14,6 @@ from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Manufacturer, Rack, Cable from nautobot.ipam.models import VLAN, Prefix, VLANGroup from nautobot.tenancy.models import Tenant -from nautobot.extras.context_managers import web_request_context from nautobot.extras.models import Job, JobResult, Role, Status from nautobot_chatops.choices import CommandStatusChoices @@ -1177,9 +1175,10 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to initiate. Result: {job_result.result}') # TODO: need base-domain, this yields: /extras/job-results// + job_url = job_result.get_absolute_url() blocks = [ dispatcher.markdown_block( - f"The requested job {job_class_path} was initiated! [`click here`]({job_result.get_absolute_url()}) to open the job." + f"The requested job {job_class_path} was initiated! [`click here`]({job_url}) to open the job." ), ] From 3fc453aeb04de23940b275bcc4b2ec0141ac49f5 Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 26 Sep 2023 04:17:47 -0400 Subject: [PATCH 11/29] remove runnable references --- nautobot_chatops/workers/nautobot.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 2558a177..8c0f7b6c 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1050,9 +1050,7 @@ def get_circuit_providers(dispatcher, *args): @subcommand_of("nautobot") -def filter_jobs( - dispatcher, job_filters: str = "" -): # We can use a Literal["enabled", "installed", "runnable"] here instead +def filter_jobs(dispatcher, job_filters: str = ""): # We can use a Literal["enabled", "installed"] here instead """Get a filtered list of jobs from Nautobot that the request user have view permissions for. Args: @@ -1061,12 +1059,10 @@ def filter_jobs( """ # Check for filters in user supplied input job_filters_list = [item.strip() for item in job_filters.split(",")] if isinstance(job_filters, str) else "" - filters = ["enabled", "installed"] # Runnable is not valid + filters = ["enabled", "installed"] if any([key in job_filters for key in filters]): filter_args = {key: True for key in filters if key in job_filters_list} - jobs = Job.objects.restrict(dispatcher.user, "view").filter( - **filter_args - ) # enabled=True, installed=True, runnable=True + jobs = Job.objects.restrict(dispatcher.user, "view").filter(**filter_args) # enabled=True, installed=True else: jobs = Job.objects.restrict(dispatcher.user, "view").all() From d7e4ccc6f92787e65b01643b97aa2e2825325f83 Mon Sep 17 00:00:00 2001 From: meganerd Date: Wed, 27 Sep 2023 00:46:14 -0400 Subject: [PATCH 12/29] reuse existing user instance (dispatch.user) --- nautobot_chatops/workers/nautobot.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 8c0f7b6c..323775ef 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -3,7 +3,6 @@ import json -from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db.models import Count from django.contrib.contenttypes.models import ContentType @@ -1137,14 +1136,6 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): if json_args.get("profile") and json_args["profile"] is True: profile = True - # Get instance of the user who will run the job - user = get_user_model() - try: - user_instance = user.objects.get(username=dispatcher.user) - except user.DoesNotExist: # Unsure if we need to check this case? - dispatcher.send_error(f"User {dispatcher.user} not found") - return (CommandStatusChoices.STATUS_FAILED, f'User "{dispatcher.user}" was not found') - # Get the job model instance using job name try: job_model = Job.objects.restrict(dispatcher.user, "view").get(name=job_name) @@ -1161,7 +1152,7 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): # TODO: Check if json_args keys are valid for this job model job_result = JobResult.execute_job( job_model=job_model, - user=user_instance, + user=dispatch.user, profile=profile, **json_args, ) From 1423b8c926333dbc6b4eff48cda655846cb3943b Mon Sep 17 00:00:00 2001 From: meganerd Date: Wed, 27 Sep 2023 00:49:11 -0400 Subject: [PATCH 13/29] typo correction for dispatcher.user --- nautobot_chatops/workers/nautobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 323775ef..8fc5705c 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1152,7 +1152,7 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): # TODO: Check if json_args keys are valid for this job model job_result = JobResult.execute_job( job_model=job_model, - user=dispatch.user, + user=dispatcher.user, profile=profile, **json_args, ) From 50725bbc134a4cb43b9df426d2b7069d4d0a0891 Mon Sep 17 00:00:00 2001 From: Stephen Kiely Date: Fri, 29 Sep 2023 08:58:43 -0500 Subject: [PATCH 14/29] Replace list comp with generator --- nautobot_chatops/workers/nautobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 8fc5705c..ccafad93 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1059,7 +1059,7 @@ def filter_jobs(dispatcher, job_filters: str = ""): # We can use a Literal["ena # Check for filters in user supplied input job_filters_list = [item.strip() for item in job_filters.split(",")] if isinstance(job_filters, str) else "" filters = ["enabled", "installed"] - if any([key in job_filters for key in filters]): + if any(key in job_filters for key in filters): filter_args = {key: True for key in filters if key in job_filters_list} jobs = Job.objects.restrict(dispatcher.user, "view").filter(**filter_args) # enabled=True, installed=True else: From 42e193151d7e3d6a301dc126a89b6a1e5885b7f0 Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 30 Oct 2023 23:17:48 -0400 Subject: [PATCH 15/29] Add init-job-form nautobot subcommand Bug: job forms with only 1-item --- nautobot_chatops/workers/nautobot.py | 260 +++++++++++++++++++++++++-- 1 file changed, 246 insertions(+), 14 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index ccafad93..00b2d192 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -14,6 +14,7 @@ from nautobot.ipam.models import VLAN, Prefix, VLANGroup from nautobot.tenancy.models import Tenant from nautobot.extras.models import Job, JobResult, Role, Status +from nautobot.extras.jobs import get_job from nautobot_chatops.choices import CommandStatusChoices from nautobot_chatops.workers import subcommand_of, handle_subcommands @@ -1085,10 +1086,10 @@ def get_jobs(dispatcher, kwargs: str = ""): """Get all jobs from Nautobot that the requesting user have view permissions for. Args: - kwargs (str): JSON-string array of header items to be exported. + kwargs (str): JSON-string array of header items to be exported. (Optional, default export is: name, id, enabled) """ # Confirm kwargs is valid JSON - json_args = ["Name", "Id", "Enabled"] + json_args = ["name", "id", "enabled"] try: if kwargs: json_args = json.loads(kwargs) @@ -1096,14 +1097,19 @@ def get_jobs(dispatcher, kwargs: str = ""): dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}") + # confirm `name` is always present in export + name_key = json_args.get("name") or json_args.get("Name") + if not name_key: + json_args.append("name") + jobs = Job.objects.restrict(dispatcher.user, "view").all() # Check if all items in json_args are valid keys (assuming all keys of job object are valid) valid_keys = [attr for attr in dir(Job) if not callable(getattr(Job, attr)) and not attr.startswith("_")] for item in json_args: - if item not in valid_keys: - dispatcher.send_error(f"Invalid item provided: {item}") - return (CommandStatusChoices.STATUS_FAILED, f"Invalid item provided: {item}") + if item.lower() not in valid_keys: + dispatcher.send_error(f"Invalid item provided: {item.lower()}") + return (CommandStatusChoices.STATUS_FAILED, f"Invalid item provided: {item.lower()}") # TODO: Check json_args are all valid keys header = [item.capitalize() for item in json_args] @@ -1115,22 +1121,26 @@ def get_jobs(dispatcher, kwargs: str = ""): @subcommand_of("nautobot") -def init_job(dispatcher, job_name: str, kwargs: str = ""): +def init_job(dispatcher, job_name: str, json_string_kwargs: str = "", *args): """Initiate a job in Nautobot by job name. Args: job_name (str): Name of Nautobot job to run. - kwargs (str): JSON-string dictionary for input keyword arguments for job run. + json_string_kwargs (str): JSON-string dictionary for input keyword arguments for job run. + *args (tuple): Dispatcher form will pass job args as tuple. #profile (str): Whether to profile the job execution. """ + if args: + json_string_kwargs = "{}" + # Confirm kwargs is valid JSON json_args = {} try: - if kwargs: - json_args = json.loads(kwargs) + if json_string_kwargs: + json_args = json.loads(json_string_kwargs) except json.JSONDecodeError: - dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") - return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}") + dispatcher.send_error(f"Invalid JSON-string, cannot decode: {json_string_kwargs}") + return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {json_string_kwargs}") profile = False if json_args.get("profile") and json_args["profile"] is True: @@ -1148,20 +1158,61 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" is not enabled') job_class_path = job_model.class_path + job_class = get_job(job_model.class_path) + form_class = job_class.as_form() + + # Parse base form fields from job class + form_fields = [] + for field_name, field in form_class.base_fields.items(): + if field_name.startswith("_"): + continue + form_fields.append(f"{field_name}") + + # Basic logic check with what we know, we should expect init-job-form vs init-job to parse the same base fields + if not len(form_fields) == len(args): + dispatcher.send_error( + "The form class fields and the passed init-jobs args do no match. Something went wrong parsing the base field items." + ) + return ( + CommandStatusChoices.STATUS_FAILED, + "The form class fields and the passed init-jobs args do no match. Something went wrong parsing the base field items.", + ) + + # Convert positional args to kwargs + # TODO: we might just pass the job the positional args we already have + # ideal I would prefer something similar to multi_input_dialog that passes kwargs back + # but ultimately we follow the same logic used to get them in both subcommands, so it is the same ordered result at runtime + form_item_kwargs = {} + for index, value in enumerate(form_fields): + # Check if json dictionary as string. We could probably check the input types and know instead of checking if valid json string + if args[index][0] == "{": + try: + json_arg = json.loads(args[index]) + if not json_arg.get("id"): + dispatcher.send_error("Form field arg is JSON dictionary, and has no `id` key.") + return ( + CommandStatusChoices.STATUS_FAILED, + "Form field arg is JSON dictionary, and has no `id` key.", + ) + form_item_kwargs[form_fields[index]] = json_arg.get("id") + continue + except json.JSONDecodeError: + form_item_kwargs[form_fields[index]] = args[index] + continue + form_item_kwargs[form_fields[index]] = args[index] - # TODO: Check if json_args keys are valid for this job model job_result = JobResult.execute_job( job_model=job_model, user=dispatcher.user, profile=profile, - **json_args, + **form_item_kwargs, ) if job_result and job_result.status == "FAILURE": dispatcher.send_error(f"The requested job {job_name} failed to initiate. Result: {job_result.result}") return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to initiate. Result: {job_result.result}') - # TODO: need base-domain, this yields: /extras/job-results// + # TODO: Need base-domain, this yields: /extras/job-results// job_url = job_result.get_absolute_url() blocks = [ dispatcher.markdown_block( @@ -1174,6 +1225,187 @@ def init_job(dispatcher, job_name: str, kwargs: str = ""): return CommandStatusChoices.STATUS_SUCCEEDED +@subcommand_of("nautobot") +def init_job_form(dispatcher, job_name: str = ""): + """Send job form as a multi-input dialog. On form submit it initiates the job with the form arguments. + + Args: + job_name (str): Name of Nautobot job to run. + """ + # Prompt the user to pick a job if they did not specify one + if not job_name: + return prompt_for_job(dispatcher, "nautobot init-job-form") + + # Get jobs available to user + try: + job = Job.objects.restrict(dispatcher.user, "view").get(name=job_name) + except Job.DoesNotExist: + blocks = [ + dispatcher.markdown_block( + f"Job {job_name} does not exist or requesting user {dispatcher.user} does not have permissions to run job." + ), + ] + dispatcher.send_blocks(blocks) + return CommandStatusChoices.STATUS_SUCCEEDED + + except Job.MultipleObjectsReturned: + blocks = [ + dispatcher.markdown_block(f"Multiple jobs found by name {job_name}."), + ] + dispatcher.send_blocks(blocks) + return CommandStatusChoices.STATUS_SUCCEEDED + + if not job.enabled: + blocks = [ + dispatcher.markdown_block(f"Job {job_name} is not enabled. The job must be enabled to be ran."), + ] + dispatcher.send_blocks(blocks) + return CommandStatusChoices.STATUS_SUCCEEDED + + job_class = get_job(job.class_path) + form_class = job_class.as_form() + + # Parse base form fields from job class + form_items = {} + for field_name, field in form_class.base_fields.items(): + if field_name.startswith("_"): + continue + form_items[field_name] = field + + form_item_dialogs = [] + for field_name, field in form_items.items(): + try: + field_type = field.widget.input_type + except: + # Some widgets (eg: textarea) do have the `input_type` attribute + field_type = field.widget.template_name.split("/")[-1].split(".")[0] + + if field_type == "select": + if not hasattr(field, "choices"): + blocks = [ + dispatcher.markdown_block(f"Job {job_name} field {field} has no attribute `choices`."), + ] + dispatcher.send_blocks(blocks) + return CommandStatusChoices.STATUS_SUCCEEDED + + query_result_items = [] + for choice, value in field.choices: + query_result_items.append( + (value, f'{{"field_name": "{field_name}", "value": "{value}", "id": "{str(choice)}"}}') + ) + + if len(query_result_items) == 0 and field.required: + blocks = [ + dispatcher.markdown_block( + f"Job {job_name} for {field_name} is required, however no choices populated for dialog choices." + ), + ] + dispatcher.send_blocks(blocks) + return CommandStatusChoices.STATUS_SUCCEEDED + + # TODO: If results are large we need to paginate by some means? + elif len(query_result_items) > 30: + pass + + form_item_dialogs.append( + { + "type": field_type, + "label": f"{field_name}: {field.help_text}", + "choices": query_result_items, + "default": query_result_items[0] if query_result_items else ("", ""), + "confirm": False, + } + ) + + elif field_type == "text": + default_value = field.initial + form_item_dialogs.append( + { + "type": field_type, + "label": f"{field_name}: {field.help_text}", + "default": default_value, + "confirm": False, + } + ) + + elif field_type == "number": + # TODO: Can we enforce numeric-character mask for widget input? + default_value = field.initial + form_item_dialogs.append( + { + "type": "text", + "label": f"{field_name}: {field.help_text} *integer values only*", + "default": default_value, + "confirm": False, + } + ) + + elif field_type == "checkbox": + # TODO: Is there a checkbox widget? + default_value = ("False", "false") + if field.initial: + default_value = ("True", "true") + form_item_dialogs.append( + { + "type": "select", + "label": f"{field_name}: {field.help_text}", + "choices": [("True", "true"), ("False", "false")], + "default": default_value, + "confirm": False, + } + ) + + elif field_type == "textarea": + # TODO: Is there a multi-line text input widget + default_value = field.initial + form_item_dialogs.append( + { + "type": "text", + "label": f"{field_name}: {field.help_text}", + "default": default_value, + "confirm": False, + } + ) + + # BUG: any job with a single form item is failing? its not calling multi_input_dialog at all. But no exception either. tested multiple form types. + # It seems to be a bug with multi_input_dialog somehow? This one had me stumped for a bit + dispatcher.multi_input_dialog( + command="nautobot", + sub_command=f"init-job {job_name} {{}}", + dialog_title=f"job {job_name} form input", + dialog_list=form_item_dialogs, + ) + + # Testing + # blocks = [ + # *dispatcher.command_response_header( + # "nautobot", + # "init-job", + # [ + # ("Job Name", job_name), + # ("Job Kwargs", "{}") + # ], + # "test..", + # nautobot_logo(dispatcher), + # ), + # ] + + blocks = [ + dispatcher.markdown_block("demo ran, fin"), + ] + + dispatcher.send_blocks(blocks) + + return CommandStatusChoices.STATUS_SUCCEEDED + + +def prompt_for_job(dispatcher, command): + """Prompt the user to select a Nautobot Job.""" + jobs = Job.objects.restrict(dispatcher.user, "view").all() + dispatcher.prompt_from_menu(command, "Select a Nautobot Job", [(job.name, job.name) for job in jobs]) + return False + + @subcommand_of("nautobot") def about(dispatcher, *args): """Provide link for more information on Nautobot Apps.""" From f074f9262534b29a86886705bd265d3dc05697dc Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 30 Oct 2023 23:27:53 -0400 Subject: [PATCH 16/29] flake8 fix, catch explicit exception --- nautobot_chatops/workers/nautobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 00b2d192..bfdb199f 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1276,7 +1276,7 @@ def init_job_form(dispatcher, job_name: str = ""): for field_name, field in form_items.items(): try: field_type = field.widget.input_type - except: + except AttributeError: # Some widgets (eg: textarea) do have the `input_type` attribute field_type = field.widget.template_name.split("/")[-1].split(".")[0] From 6910196015f7a3a63ebdd2aab5447fce6d4753d8 Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 30 Oct 2023 23:29:47 -0400 Subject: [PATCH 17/29] Remove block response, leaving testing commented --- nautobot_chatops/workers/nautobot.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index bfdb199f..73f6b973 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1390,11 +1390,7 @@ def init_job_form(dispatcher, job_name: str = ""): # ), # ] - blocks = [ - dispatcher.markdown_block("demo ran, fin"), - ] - - dispatcher.send_blocks(blocks) + # dispatcher.send_blocks(blocks) return CommandStatusChoices.STATUS_SUCCEEDED From 98c2db3cba231ce12bf96eddcd4197bb97374609 Mon Sep 17 00:00:00 2001 From: meganerd Date: Tue, 31 Oct 2023 00:54:38 -0400 Subject: [PATCH 18/29] pylint8 fixes prompt_for_job added to init-job subcommand removed testing code blocks todo: bug: single job forms wont submit properly with multi_input_dialog notes: merge init-job and init-job-form seems best --- nautobot_chatops/workers/nautobot.py | 35 +++++++--------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 73f6b973..29e8eea0 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1121,7 +1121,7 @@ def get_jobs(dispatcher, kwargs: str = ""): @subcommand_of("nautobot") -def init_job(dispatcher, job_name: str, json_string_kwargs: str = "", *args): +def init_job(dispatcher, job_name: str = "", json_string_kwargs: str = "", *args): """Initiate a job in Nautobot by job name. Args: @@ -1130,6 +1130,10 @@ def init_job(dispatcher, job_name: str, json_string_kwargs: str = "", *args): *args (tuple): Dispatcher form will pass job args as tuple. #profile (str): Whether to profile the job execution. """ + # Prompt the user to pick a job if they did not specify one + if not job_name: + return prompt_for_job(dispatcher, "nautobot init-job") + if args: json_string_kwargs = "{}" @@ -1163,13 +1167,13 @@ def init_job(dispatcher, job_name: str, json_string_kwargs: str = "", *args): # Parse base form fields from job class form_fields = [] - for field_name, field in form_class.base_fields.items(): + for field_name, _ in form_class.base_fields.items(): # pylint: disable=unused-variable if field_name.startswith("_"): continue form_fields.append(f"{field_name}") # Basic logic check with what we know, we should expect init-job-form vs init-job to parse the same base fields - if not len(form_fields) == len(args): + if len(form_fields) != len(args): dispatcher.send_error( "The form class fields and the passed init-jobs args do no match. Something went wrong parsing the base field items." ) @@ -1183,7 +1187,7 @@ def init_job(dispatcher, job_name: str, json_string_kwargs: str = "", *args): # ideal I would prefer something similar to multi_input_dialog that passes kwargs back # but ultimately we follow the same logic used to get them in both subcommands, so it is the same ordered result at runtime form_item_kwargs = {} - for index, value in enumerate(form_fields): + for index, _ in enumerate(form_fields): # pylint: disable=unused-variable # Check if json dictionary as string. We could probably check the input types and know instead of checking if valid json string if args[index][0] == "{": try: @@ -1303,10 +1307,6 @@ def init_job_form(dispatcher, job_name: str = ""): dispatcher.send_blocks(blocks) return CommandStatusChoices.STATUS_SUCCEEDED - # TODO: If results are large we need to paginate by some means? - elif len(query_result_items) > 30: - pass - form_item_dialogs.append( { "type": field_type, @@ -1367,8 +1367,7 @@ def init_job_form(dispatcher, job_name: str = ""): } ) - # BUG: any job with a single form item is failing? its not calling multi_input_dialog at all. But no exception either. tested multiple form types. - # It seems to be a bug with multi_input_dialog somehow? This one had me stumped for a bit + # TODO: BUG: Single inputs will present but not submit properly with multi_input_dialog dispatcher.multi_input_dialog( command="nautobot", sub_command=f"init-job {job_name} {{}}", @@ -1376,22 +1375,6 @@ def init_job_form(dispatcher, job_name: str = ""): dialog_list=form_item_dialogs, ) - # Testing - # blocks = [ - # *dispatcher.command_response_header( - # "nautobot", - # "init-job", - # [ - # ("Job Name", job_name), - # ("Job Kwargs", "{}") - # ], - # "test..", - # nautobot_logo(dispatcher), - # ), - # ] - - # dispatcher.send_blocks(blocks) - return CommandStatusChoices.STATUS_SUCCEEDED From 9a259a5af66d884ea2f437041ce8af030eab754d Mon Sep 17 00:00:00 2001 From: meganerddev Date: Mon, 5 Feb 2024 18:06:07 -0500 Subject: [PATCH 19/29] CI history expired, re-running checks --- nautobot_chatops/workers/nautobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 29e8eea0..be197e27 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1329,7 +1329,7 @@ def init_job_form(dispatcher, job_name: str = ""): ) elif field_type == "number": - # TODO: Can we enforce numeric-character mask for widget input? + # TODO: Can we enforce numeric-character mask for widget input without JavaScript? default_value = field.initial form_item_dialogs.append( { From 8dd56fed62e8397f6ef2e806352d1f21a6f49663 Mon Sep 17 00:00:00 2001 From: meganerddev Date: Mon, 5 Feb 2024 18:38:28 -0500 Subject: [PATCH 20/29] pylint CI fixes: W1113, R0914, C0301 --- nautobot_chatops/workers/nautobot.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index be197e27..05d9810d 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1097,7 +1097,7 @@ def get_jobs(dispatcher, kwargs: str = ""): dispatcher.send_error(f"Invalid JSON-string, cannot decode: {kwargs}") return (CommandStatusChoices.STATUS_FAILED, f"Invalid JSON-string, cannot decode: {kwargs}") - # confirm `name` is always present in export + # Confirm `name` is always present in export name_key = json_args.get("name") or json_args.get("Name") if not name_key: json_args.append("name") @@ -1121,14 +1121,13 @@ def get_jobs(dispatcher, kwargs: str = ""): @subcommand_of("nautobot") -def init_job(dispatcher, job_name: str = "", json_string_kwargs: str = "", *args): +def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = ""): """Initiate a job in Nautobot by job name. Args: job_name (str): Name of Nautobot job to run. json_string_kwargs (str): JSON-string dictionary for input keyword arguments for job run. *args (tuple): Dispatcher form will pass job args as tuple. - #profile (str): Whether to profile the job execution. """ # Prompt the user to pick a job if they did not specify one if not job_name: @@ -1161,9 +1160,7 @@ def init_job(dispatcher, job_name: str = "", json_string_kwargs: str = "", *args dispatcher.send_error(f"The requested job {job_name} is not enabled") return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" is not enabled') - job_class_path = job_model.class_path - job_class = get_job(job_model.class_path) - form_class = job_class.as_form() + form_class = get_job(job_model.class_path).as_form() # Parse base form fields from job class form_fields = [] @@ -1175,20 +1172,16 @@ def init_job(dispatcher, job_name: str = "", json_string_kwargs: str = "", *args # Basic logic check with what we know, we should expect init-job-form vs init-job to parse the same base fields if len(form_fields) != len(args): dispatcher.send_error( - "The form class fields and the passed init-jobs args do no match. Something went wrong parsing the base field items." + "The form class fields and the passed init-jobs args do not match." ) return ( CommandStatusChoices.STATUS_FAILED, - "The form class fields and the passed init-jobs args do no match. Something went wrong parsing the base field items.", + "The form class fields and the passed init-jobs args do not match.", ) - # Convert positional args to kwargs - # TODO: we might just pass the job the positional args we already have - # ideal I would prefer something similar to multi_input_dialog that passes kwargs back - # but ultimately we follow the same logic used to get them in both subcommands, so it is the same ordered result at runtime form_item_kwargs = {} for index, _ in enumerate(form_fields): # pylint: disable=unused-variable - # Check if json dictionary as string. We could probably check the input types and know instead of checking if valid json string + # Check request input (string-type) is also valid JSON if args[index][0] == "{": try: json_arg = json.loads(args[index]) @@ -1220,7 +1213,7 @@ def init_job(dispatcher, job_name: str = "", json_string_kwargs: str = "", *args job_url = job_result.get_absolute_url() blocks = [ dispatcher.markdown_block( - f"The requested job {job_class_path} was initiated! [`click here`]({job_url}) to open the job." + f"The requested job {job_model.class_path} was initiated! [`click here`]({job_url}) to open the job." ), ] @@ -1246,7 +1239,7 @@ def init_job_form(dispatcher, job_name: str = ""): except Job.DoesNotExist: blocks = [ dispatcher.markdown_block( - f"Job {job_name} does not exist or requesting user {dispatcher.user} does not have permissions to run job." + f"Job {job_name} does not exist or {dispatcher.user} does not have permissions to run job." # pylint: disable=line-too-long ), ] dispatcher.send_blocks(blocks) @@ -1266,8 +1259,7 @@ def init_job_form(dispatcher, job_name: str = ""): dispatcher.send_blocks(blocks) return CommandStatusChoices.STATUS_SUCCEEDED - job_class = get_job(job.class_path) - form_class = job_class.as_form() + form_class = get_job(job.class_path).as_form() # Parse base form fields from job class form_items = {} From 195c888a78f16979a2148fa188768623355a4ae5 Mon Sep 17 00:00:00 2001 From: meganerddev Date: Mon, 5 Feb 2024 19:18:35 -0500 Subject: [PATCH 21/29] Black code formatter --- nautobot_chatops/workers/nautobot.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 05d9810d..dce0ebcb 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1171,9 +1171,7 @@ def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "" # Basic logic check with what we know, we should expect init-job-form vs init-job to parse the same base fields if len(form_fields) != len(args): - dispatcher.send_error( - "The form class fields and the passed init-jobs args do not match." - ) + dispatcher.send_error("The form class fields and the passed init-jobs args do not match.") return ( CommandStatusChoices.STATUS_FAILED, "The form class fields and the passed init-jobs args do not match.", @@ -1239,7 +1237,7 @@ def init_job_form(dispatcher, job_name: str = ""): except Job.DoesNotExist: blocks = [ dispatcher.markdown_block( - f"Job {job_name} does not exist or {dispatcher.user} does not have permissions to run job." # pylint: disable=line-too-long + f"Job {job_name} does not exist or {dispatcher.user} does not have permissions to run job." # pylint: disable=line-too-long ), ] dispatcher.send_blocks(blocks) From 28c1d95b93f65b6980324057c2232c2b753754be Mon Sep 17 00:00:00 2001 From: meganerddev Date: Mon, 5 Feb 2024 19:31:44 -0500 Subject: [PATCH 22/29] pylint ignore too-many-locals for init_job --- nautobot_chatops/workers/nautobot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index dce0ebcb..acdb3851 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1120,6 +1120,7 @@ def get_jobs(dispatcher, kwargs: str = ""): return CommandStatusChoices.STATUS_SUCCEEDED +# pylint: disable=too-many-locals @subcommand_of("nautobot") def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = ""): """Initiate a job in Nautobot by job name. From cb26441ffb3edcc2963c00e31f7a0933d3404738 Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 5 Feb 2024 19:43:20 -0500 Subject: [PATCH 23/29] Replace execute_job with enqueue_job, which is correct Nautobot 2.x pattern Added wait for job to initiate using refresh_from_db Corrected the initiated job url hyperlink message --- nautobot_chatops/workers/nautobot.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index acdb3851..82af0815 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1,6 +1,7 @@ """Worker functions for interacting with Nautobot.""" import json +import time from django.core.exceptions import ValidationError @@ -13,6 +14,7 @@ from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Manufacturer, Rack, Cable from nautobot.ipam.models import VLAN, Prefix, VLANGroup from nautobot.tenancy.models import Tenant +from nautobot.extras.choices import JobResultStatusChoices from nautobot.extras.models import Job, JobResult, Role, Status from nautobot.extras.jobs import get_job @@ -1197,22 +1199,28 @@ def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "" continue form_item_kwargs[form_fields[index]] = args[index] - job_result = JobResult.execute_job( + job_result = JobResult.enqueue_job( job_model=job_model, user=dispatcher.user, profile=profile, **form_item_kwargs, ) + # Wait on the job to finish + while job_result.status not in JobResultStatusChoices.READY_STATES: + time.sleep(1) + job_result.refresh_from_db() + if job_result and job_result.status == "FAILURE": dispatcher.send_error(f"The requested job {job_name} failed to initiate. Result: {job_result.result}") return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to initiate. Result: {job_result.result}') - # TODO: Need base-domain, this yields: /extras/job-results// - job_url = job_result.get_absolute_url() + job_url = ( + f"{dispatcher.context['request_scheme']}://{dispatcher.context['request_host']}{job_result.get_absolute_url()}" + ) blocks = [ dispatcher.markdown_block( - f"The requested job {job_model.class_path} was initiated! [`click here`]({job_url}) to open the job." + f"The requested job {job_class_path} was initiated! [`click here`]({job_url}) to open the job." ), ] From 64ed9366d1a992d44d4422f977b46820f79d6a87 Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 5 Feb 2024 19:58:50 -0500 Subject: [PATCH 24/29] Fix missing variable --- nautobot_chatops/workers/nautobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 82af0815..9f20ec47 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1220,7 +1220,7 @@ def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "" ) blocks = [ dispatcher.markdown_block( - f"The requested job {job_class_path} was initiated! [`click here`]({job_url}) to open the job." + f"The requested job {job_model.class_path} was initiated! [`click here`]({job_url}) to open the job." ), ] From 00eb275cdbbca2441426dd24c04aba27f2a4a08c Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 5 Feb 2024 20:55:23 -0500 Subject: [PATCH 25/29] Update breadcrumb changes Follow existing pylint ignore pattern on repo --- changes/270.added | 4 ++-- nautobot_chatops/workers/nautobot.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/changes/270.added b/changes/270.added index 2a43fc7f..c1c87383 100644 --- a/changes/270.added +++ b/changes/270.added @@ -1,4 +1,4 @@ -Add init_jobs Nautobot subcommand. -Add kwargs input to init_jobs as JSON-string to init_jobs. +Add init_job Nautobot subcommand, which initiates a job with kwargs or a job requiring no manual form input. +Add init_job_form Nautobot subcommand, which presents job's form widgets to the user. Add get_jobs Nautobot subcommand, which returns all Nautobot jobs viewable to user. Add filter_jobs Nautobot subcommand, which returns filtered set of Nautobot jobs viewable to user. diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 9f20ec47..b1ab606a 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1122,15 +1122,14 @@ def get_jobs(dispatcher, kwargs: str = ""): return CommandStatusChoices.STATUS_SUCCEEDED -# pylint: disable=too-many-locals @subcommand_of("nautobot") -def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = ""): +def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = ""): # pylint: disable=too-many-locals """Initiate a job in Nautobot by job name. Args: + *args (tuple): Dispatcher form will pass job args as tuple. job_name (str): Name of Nautobot job to run. json_string_kwargs (str): JSON-string dictionary for input keyword arguments for job run. - *args (tuple): Dispatcher form will pass job args as tuple. """ # Prompt the user to pick a job if they did not specify one if not job_name: From 7470ec861458a86e523e452ede9a7ef5e13af6cd Mon Sep 17 00:00:00 2001 From: meganerd Date: Mon, 5 Feb 2024 21:10:36 -0500 Subject: [PATCH 26/29] Add max iterations for waiting on job to enter ready state in the database --- nautobot_chatops/workers/nautobot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index b1ab606a..c0fc1049 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1206,7 +1206,12 @@ def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "" ) # Wait on the job to finish + max_wait_iterations = 60 while job_result.status not in JobResultStatusChoices.READY_STATES: + max_wait_iterations -= 1 + if not max_wait_iterations: + dispatcher.send_error(f"The requested job {job_name} failed to reach ready state.") + return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to reach ready state.') time.sleep(1) job_result.refresh_from_db() From 0ab7e78c83ef788326a7be1dca4a70053c597d6e Mon Sep 17 00:00:00 2001 From: meganerddev Date: Tue, 6 Feb 2024 16:03:29 -0500 Subject: [PATCH 27/29] Rename `init_job` subcommand to `run_job` Rename `init_job_form`subcommand to `run_job_form` --- changes/270.added | 4 ++-- nautobot_chatops/workers/nautobot.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/changes/270.added b/changes/270.added index c1c87383..a5f14277 100644 --- a/changes/270.added +++ b/changes/270.added @@ -1,4 +1,4 @@ -Add init_job Nautobot subcommand, which initiates a job with kwargs or a job requiring no manual form input. -Add init_job_form Nautobot subcommand, which presents job's form widgets to the user. +Add run_job Nautobot subcommand, which initiates a job with kwargs or a job requiring no manual form input. +Add run_job_form Nautobot subcommand, which presents job's form widgets to the user. Add get_jobs Nautobot subcommand, which returns all Nautobot jobs viewable to user. Add filter_jobs Nautobot subcommand, which returns filtered set of Nautobot jobs viewable to user. diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index c0fc1049..45f4555d 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1123,7 +1123,7 @@ def get_jobs(dispatcher, kwargs: str = ""): @subcommand_of("nautobot") -def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = ""): # pylint: disable=too-many-locals +def run_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = ""): # pylint: disable=too-many-locals """Initiate a job in Nautobot by job name. Args: @@ -1133,7 +1133,7 @@ def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "" """ # Prompt the user to pick a job if they did not specify one if not job_name: - return prompt_for_job(dispatcher, "nautobot init-job") + return prompt_for_job(dispatcher, "nautobot run-job") if args: json_string_kwargs = "{}" @@ -1171,12 +1171,12 @@ def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "" continue form_fields.append(f"{field_name}") - # Basic logic check with what we know, we should expect init-job-form vs init-job to parse the same base fields + # Basic logic check with what we know, we should expect run-job-form vs run-job to parse the same base fields if len(form_fields) != len(args): - dispatcher.send_error("The form class fields and the passed init-jobs args do not match.") + dispatcher.send_error("The form class fields and the passed run-job args do not match.") return ( CommandStatusChoices.STATUS_FAILED, - "The form class fields and the passed init-jobs args do not match.", + "The form class fields and the passed run-job args do not match.", ) form_item_kwargs = {} @@ -1234,7 +1234,7 @@ def init_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "" @subcommand_of("nautobot") -def init_job_form(dispatcher, job_name: str = ""): +def run_job_form(dispatcher, job_name: str = ""): """Send job form as a multi-input dialog. On form submit it initiates the job with the form arguments. Args: @@ -1242,7 +1242,7 @@ def init_job_form(dispatcher, job_name: str = ""): """ # Prompt the user to pick a job if they did not specify one if not job_name: - return prompt_for_job(dispatcher, "nautobot init-job-form") + return prompt_for_job(dispatcher, "nautobot run-job-form") # Get jobs available to user try: @@ -1373,7 +1373,7 @@ def init_job_form(dispatcher, job_name: str = ""): # TODO: BUG: Single inputs will present but not submit properly with multi_input_dialog dispatcher.multi_input_dialog( command="nautobot", - sub_command=f"init-job {job_name} {{}}", + sub_command=f"run-job {job_name} {{}}", dialog_title=f"job {job_name} form input", dialog_list=form_item_dialogs, ) From 875ca649bdd0cde11e6ac7a0d668ee729560f463 Mon Sep 17 00:00:00 2001 From: meganerddev Date: Wed, 7 Feb 2024 02:22:49 -0500 Subject: [PATCH 28/29] Update error for initiated job status failure state --- nautobot_chatops/workers/nautobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 45f4555d..37b1f757 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1216,8 +1216,8 @@ def run_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "") job_result.refresh_from_db() if job_result and job_result.status == "FAILURE": - dispatcher.send_error(f"The requested job {job_name} failed to initiate. Result: {job_result.result}") - return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" failed to initiate. Result: {job_result.result}') + dispatcher.send_error(f"The requested job {job_name} was initiated but failed. Result: {job_result.result}") + return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" was initiated but failed. Result: {job_result.result}') # pylint: disable=line-too-long job_url = ( f"{dispatcher.context['request_scheme']}://{dispatcher.context['request_host']}{job_result.get_absolute_url()}" From e6c14c6ee278c1757f36fe1c24ff69b9a26d26ba Mon Sep 17 00:00:00 2001 From: meganerddev Date: Wed, 7 Feb 2024 02:23:48 -0500 Subject: [PATCH 29/29] Rub Black formatter --- nautobot_chatops/workers/nautobot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nautobot_chatops/workers/nautobot.py b/nautobot_chatops/workers/nautobot.py index 37b1f757..11fc74c1 100644 --- a/nautobot_chatops/workers/nautobot.py +++ b/nautobot_chatops/workers/nautobot.py @@ -1217,7 +1217,10 @@ def run_job(dispatcher, *args, job_name: str = "", json_string_kwargs: str = "") if job_result and job_result.status == "FAILURE": dispatcher.send_error(f"The requested job {job_name} was initiated but failed. Result: {job_result.result}") - return (CommandStatusChoices.STATUS_FAILED, f'Job "{job_name}" was initiated but failed. Result: {job_result.result}') # pylint: disable=line-too-long + return ( + CommandStatusChoices.STATUS_FAILED, + f'Job "{job_name}" was initiated but failed. Result: {job_result.result}', + ) # pylint: disable=line-too-long job_url = ( f"{dispatcher.context['request_scheme']}://{dispatcher.context['request_host']}{job_result.get_absolute_url()}"