def retrieve_deprecated(self, request, class_path): """ Get details of a Job as identified by its class-path. This API endpoint is deprecated; it is recommended to use the extras_jobs_read endpoint instead. """ if not request.user.has_perm("extras.view_job"): raise PermissionDenied("This user does not have permission to view jobs.") try: job_model = Job.objects.restrict(request.user, "view").get_for_class_path(class_path) except Job.DoesNotExist: raise Http404 if not job_model.installed or job_model.job_class is None: raise Http404 job_content_type = get_job_content_type() job = job_model.job_class() # TODO: why do we need to instantiate the job_class? job.result = JobResult.objects.filter( obj_type=job_content_type, name=job.class_path, status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES, ).first() serializer = serializers.JobClassDetailSerializer(job, context={"request": request}) return Response(serializer.data)
def list(self, request, *args, **kwargs): """List all known Jobs.""" if request.major_version > 1 or request.minor_version >= 3: # API version 1.3 or later - standard model-based response return super().list(request, *args, **kwargs) # API version 1.2 or earlier - serialize JobClass records if not request.user.has_perm("extras.view_job"): raise PermissionDenied("This user does not have permission to view jobs.") job_content_type = get_job_content_type() results = { r.name: r for r in JobResult.objects.filter( obj_type=job_content_type, status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES, ) .defer("data") .order_by("created") } job_models = Job.objects.restrict(request.user, "view") jobs_list = [ job_model.job_class() # TODO: why do we need to instantiate the job_class? for job_model in job_models if job_model.installed and job_model.job_class is not None ] for job_instance in jobs_list: job_instance.result = results.get(job_instance.class_path, None) serializer = serializers.JobClassSerializer(jobs_list, many=True, context={"request": request}) return Response(serializer.data)
def run_job_for_testing(job, data=None, commit=True, username="******", request=None): """Provide a common interface to run Nautobot jobs as part of unit tests. Args: job (Job): Job model instance (not Job class) to run data (dict): Input data values for any Job variables. commit (bool): Whether to commit changes to the database or rollback when done. username (str): Username of existing or to-be-created User account to own the JobResult. Ignored if `request.user` exists. request (HttpRequest): Existing request (if any) to own the JobResult. Returns: JobResult: representing the executed job """ if data is None: data = {} # If the request has a user, ignore the username argument and use that user. if request and request.user: user_instance = request.user else: User = get_user_model() user_instance, _ = User.objects.get_or_create(username=username, defaults={ "is_superuser": True, "password": "******" }) job_result = JobResult.objects.create( name=job.class_path, obj_type=get_job_content_type(), user=user_instance, job_model=job, job_id=uuid.uuid4(), ) @contextmanager def _web_request_context(user): if request: yield request else: yield web_request_context(user=user) with _web_request_context(user=user_instance) as request: run_job(data=data, request=request, commit=commit, job_result_pk=job_result.pk) return job_result
def related_object(self): """Get the related object, if any, identified by the `obj_type`, `name`, and/or `job_id` fields. If `obj_type` is extras.Job, then the `name` is used to look up an extras.jobs.Job subclass based on the `class_path` of the Job subclass. Note that this is **not** the extras.models.Job model class nor an instance thereof. Else, if the the model class referenced by `obj_type` has a `name` field, our `name` field will be used to look up a corresponding model instance. This is used, for example, to look up a related `GitRepository`; more generally it can be used by any model that 1) has a unique `name` field and 2) needs to have a many-to-one relationship between JobResults and model instances. Else, the `obj_type` and `job_id` will be used together as a quasi-GenericForeignKey to look up a model instance whose PK corresponds to the `job_id`. This behavior is currently unused in the Nautobot core, but may be of use to plugin developers wishing to create JobResults that have a one-to-one relationship to plugin model instances. This method is potentially rather slow as get_job() may need to actually load the Job class from disk; consider carefully whether you actually need to use it. """ from nautobot.extras.jobs import get_job # needed here to avoid a circular import issue if self.obj_type == get_job_content_type(): # Related object is an extras.Job subclass, our `name` matches its `class_path` return get_job(self.name) model_class = self.obj_type.model_class() if model_class is not None: if hasattr(model_class, "name"): # See if we have a many-to-one relationship from JobResult to model_class record, based on `name` try: return model_class.objects.get(name=self.name) except model_class.DoesNotExist: pass # See if we have a one-to-one relationship from JobResult to model_class record based on `job_id` try: return model_class.objects.get(id=self.job_id) except model_class.DoesNotExist: pass return None
def dry_run(self, request, pk): scheduled_job = get_object_or_404(ScheduledJob, pk=pk) job_model = scheduled_job.job_model if job_model is None or not job_model.runnable: raise MethodNotAllowed("This job cannot be dry-run at this time.") if not Job.objects.check_perms(request.user, instance=job_model, action="run"): raise PermissionDenied("You do not have permission to run this job.") # Immediately enqueue the job with commit=False job_content_type = get_job_content_type() job_result = JobResult.enqueue_job( run_job, job_model.class_path, job_content_type, request.user, data=scheduled_job.kwargs.get("data", {}), request=copy_safe_request(request), commit=False, # force a dry-run ) serializer = serializers.JobResultSerializer(job_result, context={"request": request}) return Response(serializer.data)
def _run_job(request, job_model, legacy_response=False): """An internal function providing logic shared between JobModelViewSet.run() and JobViewSet.run().""" if not request.user.has_perm("extras.run_job"): raise PermissionDenied("This user does not have permission to run jobs.") if not job_model.enabled: raise PermissionDenied("This job is not enabled to be run.") if not job_model.installed: raise MethodNotAllowed(request.method, detail="This job is not presently installed and cannot be run") job_class = job_model.job_class if job_class is None: raise MethodNotAllowed(request.method, detail="This job's source code could not be located and cannot be run") job = job_class() input_serializer = serializers.JobInputSerializer(data=request.data) input_serializer.is_valid(raise_exception=True) data = input_serializer.data["data"] or {} commit = input_serializer.data["commit"] if commit is None: commit = job_model.commit_default try: job.validate_data(data) except FormsValidationError as e: # message_dict can only be accessed if ValidationError got a dict # in the constructor (saved as error_dict). Otherwise we get a list # of errors under messages return Response({"errors": e.message_dict if hasattr(e, "error_dict") else e.messages}, status=400) if not get_worker_count(): raise CeleryWorkerNotRunningException() job_content_type = get_job_content_type() schedule_data = input_serializer.data.get("schedule") # Default to a null JobResult. job_result = None # Assert that a job with `approval_required=True` has a schedule that enforces approval and # executes immediately. if schedule_data is None and job_model.approval_required: schedule_data = {"interval": JobExecutionType.TYPE_IMMEDIATELY} # Try to create a ScheduledJob, or... if schedule_data: schedule = _create_schedule(schedule_data, data, commit, job, job_model, request) else: schedule = None # ... If we can't create one, create a JobResult instead. if schedule is None: job_result = JobResult.enqueue_job( run_job, job.class_path, job_content_type, request.user, data=data, request=copy_safe_request(request), commit=commit, ) job.result = job_result if legacy_response: # Old-style JobViewSet response - serialize the Job class in the response for some reason? serializer = serializers.JobClassDetailSerializer(job, context={"request": request}) return Response(serializer.data) else: # New-style JobModelViewSet response - serialize the schedule or job_result as appropriate data = {"schedule": None, "job_result": None} if schedule: data["schedule"] = nested_serializers.NestedScheduledJobSerializer( schedule, context={"request": request} ).data if job_result: data["job_result"] = nested_serializers.NestedJobResultSerializer( job_result, context={"request": request} ).data return Response(data, status=status.HTTP_201_CREATED)
def handle(self, *args, **options): if "/" not in options["job"]: raise CommandError( 'Job must be specified in the form "grouping_name/module_name/JobClassName"' ) job_class = get_job(options["job"]) if not job_class: raise CommandError('Job "%s" not found' % options["job"]) user = None request = None if options["commit"] and not options["username"]: # Job execution with commit=True uses change_logging(), which requires a user as the author of any changes raise CommandError("--username is mandatory when --commit is used") if options["username"]: User = get_user_model() try: user = User.objects.get(username=options["username"]) except User.DoesNotExist as exc: raise CommandError("No such user") from exc request = RequestFactory().request( SERVER_NAME="nautobot_server_runjob") request.id = uuid.uuid4() request.user = user job_content_type = get_job_content_type() # Run the job and create a new JobResult self.stdout.write("[{:%H:%M:%S}] Running {}...".format( timezone.now(), job_class.class_path)) job_result = JobResult.enqueue_job( run_job, job_class.class_path, job_content_type, user, data= {}, # TODO: parsing CLI args into a data dictionary is not currently implemented request=copy_safe_request(request) if request else None, commit=options["commit"], ) # Wait on the job to finish while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES: time.sleep(1) job_result = JobResult.objects.get(pk=job_result.pk) # Report on success/failure groups = set( JobLogEntry.objects.filter(job_result=job_result).values_list( "grouping", flat=True)) for group in sorted(groups): logs = JobLogEntry.objects.filter(job_result__pk=job_result.pk, grouping=group) success_count = logs.filter( log_level=LogLevelChoices.LOG_SUCCESS).count() info_count = logs.filter( log_level=LogLevelChoices.LOG_INFO).count() warning_count = logs.filter( log_level=LogLevelChoices.LOG_WARNING).count() failure_count = logs.filter( log_level=LogLevelChoices.LOG_FAILURE).count() self.stdout.write( "\t{}: {} success, {} info, {} warning, {} failure".format( group, success_count, info_count, warning_count, failure_count, )) for log_entry in logs: status = log_entry.log_level if status == "success": status = self.style.SUCCESS(status) elif status == "info": status = status elif status == "warning": status = self.style.WARNING(status) elif status == "failure": status = self.style.NOTICE(status) if log_entry.log_object: self.stdout.write( f"\t\t{status}: {log_entry.log_object}: {log_entry.message}" ) else: self.stdout.write(f"\t\t{status}: {log_entry.message}") if job_result.data["output"]: self.stdout.write(job_result.data["output"]) if job_result.status == JobResultStatusChoices.STATUS_FAILED: status = self.style.ERROR("FAILED") elif job_result.status == JobResultStatusChoices.STATUS_ERRORED: status = self.style.ERROR("ERRORED") else: status = self.style.SUCCESS("SUCCESS") self.stdout.write("[{:%H:%M:%S}] {}: {}".format( timezone.now(), job_class.class_path, status)) # Wrap things up self.stdout.write("[{:%H:%M:%S}] {}: Duration {}".format( timezone.now(), job_class.class_path, job_result.duration)) self.stdout.write("[{:%H:%M:%S}] Finished".format(timezone.now()))