def event_update(request, event_id): ev = get_object_or_404(models.Event, pk=event_id) ev_data = {'id': ev.pk, 'complete': ev.complete, 'last_modified': TimeUtils.display_time_str(ev.last_modified), 'created': TimeUtils.display_time_str(ev.created), 'status': ev.status_slug(), } ev_data['events'] = EventsStatus.multiline_events_info([ev]) return JsonResponse(ev_data)
def num_tests(request): context = {} start = (TimeUtils.get_local_time() - datetime.timedelta(days=180)).replace(hour=0, minute=0) bins = get_bins(start, datetime.timedelta(days=7)) set_passed(start, "week", "Passed tests in last 6 months, by week", context, "month_chart", "%m/%d", bins) start = (TimeUtils.get_local_time() - datetime.timedelta(days=7)).replace(hour=0, minute=0) bins = get_bins(start, datetime.timedelta(days=1)) set_passed(start, "day", "Passed tests in last week, by day", context, "week_chart", "%m/%d", bins) return render(request, 'ci/num_tests.html', context)
def handle(self, *args, **options): dryrun = options["dryrun"] days = options["days"] hours = options["hours"] allowed_fail = options["allowed_fail"] client_runner_user = options["client_runner_user"] if days: d = TimeUtils.get_local_time() - timedelta(days=days) elif hours: d = TimeUtils.get_local_time() - timedelta(hours=hours) jobs = models.Job.objects.filter(active=True, ready=True, status=models.JobStatus.NOT_STARTED, created__lt=d) if client_runner_user: if ":" not in client_runner_user: raise CommandError("Invalid format for username: %s" % client_runner_user) host, username = client_runner_user.split(":") git_server = models.GitServer.objects.get(name=host) git_user = models.GitUser.objects.get(name=username, server=git_server) jobs = jobs.filter( (Q(recipe__client_runner_user=None) & Q(recipe__build_user__build_key=git_user.build_key)) | Q(recipe__client_runner_user__build_key=git_user.build_key)) count = jobs.count() prefix = "" if dryrun: prefix = "DRY RUN: " if allowed_fail: err_msg = "Set to allowed to fail due to civet client not running this job in too long a time" status = models.JobStatus.FAILED_OK msg = "Job allowed to fail" else: err_msg = "Canceled due to civet client not running this job in too long a time" status = models.JobStatus.CANCELED msg = "Job canceled" for job in jobs.all(): self.stdout.write("%s%s: %s: %s: %s" % (prefix, msg, job.pk, job, job.created)) if not dryrun: views.set_job_canceled(job, err_msg, status) UpdateRemoteStatus.job_complete(job) job.event.set_complete_if_done() if count == 0: self.stdout.write("No jobs to cancel")
def pr_update(request, pr_id): pr = get_object_or_404(models.PullRequest, pk=pr_id) closed = 'Open' if pr.closed: closed = 'Closed' pr_data = {'id': pr.pk, 'closed': closed, 'last_modified': TimeUtils.display_time_str(pr.last_modified), 'created': TimeUtils.display_time_str(pr.created), 'status': pr.status_slug(), } pr_data['events'] = EventsStatus.multiline_events_info(pr.events.all(), events_url=True) return JsonResponse(pr_data)
def repo_update(request): """ Get the updates for the repo page. """ if 'last_request' not in request.GET or 'limit' not in request.GET or 'repo_id' not in request.GET: return HttpResponseBadRequest('Missing parameters') this_request = TimeUtils.get_local_timestamp() repo_id = int(request.GET['repo_id']) limit = int(request.GET['limit']) last_request = int(float(request.GET['last_request'])) # in case it has decimals dt = timezone.localtime(timezone.make_aware(datetime.datetime.utcfromtimestamp(last_request))) repo = get_object_or_404(models.Repository, pk=repo_id) repos_status = RepositoryStatus.filter_repos_status([repo.pk], last_modified=dt) event_q = EventsStatus.get_default_events_query() event_q = event_q.filter(base__branch__repository=repo)[:limit] events_info = EventsStatus.multiline_events_info(event_q, last_modified=dt) # we also need to check if a PR closed recently closed = [] for pr in models.PullRequest.objects.filter(repository=repo, closed=True, last_modified__gte=dt).values('id').all(): closed.append({'id': pr['id']}) return JsonResponse({'repo_status': repos_status, 'closed': closed, 'last_request': this_request, 'events': events_info, 'limit': limit, })
def test_set_passed(self): result = utils.create_step_result() result.save() context = {} start = (TimeUtils.get_local_time() - datetime.timedelta(days=1)).replace(hour=0, minute=0) bins = Stats.get_bins(start, datetime.timedelta(days=1)) p = Stats.set_passed(start, "day", "Passed tests in last 6 months, by day", context, "month_chart", "%m/%d", bins) # no models.JobTestStatistics records for j in p[1:]: self.assertEqual(j[1], 0) self.assertIn("month_chart", context) context = {} models.JobTestStatistics.objects.create(job=result.job, passed=20, skipped=30, failed=40) p = Stats.set_passed(start, "day", "Passed tests in last 6 months, by day", context, "month_chart", "%m/%d", bins) self.assertNotEqual(context, {}) self.assertEqual(len(p), 3) self.assertEqual(p[2][1], 20) self.assertIn("month_chart", context)
def clients_info(): """ Gets the information on all the currently active clients. Retruns: list of dicts containing client information """ sclients = sorted_clients(models.Client.objects.exclude(status=models.Client.DOWN)) active_clients = [] # clients that we've seen in <= 60 s inactive_clients = [] # clients that we've seen in > 60 s for c in sclients: d = {'pk': c.pk, "ip": c.ip, "name": c.name, "message": c.status_message, "status": c.status_str(), "lastseen": TimeUtils.human_time_str(c.last_seen), } if c.unseen_seconds() > 2*7*24*60*60: # 2 weeks # do it like this so that last_seen doesn't get updated models.Client.objects.filter(pk=c.pk).update(status=models.Client.DOWN) elif c.unseen_seconds() > 120: d["status_class"] = "client_NotSeen" inactive_clients.append(d) else: d["status_class"] = "client_%s" % c.status_slug() active_clients.append(d) clients = [] # sort these so that active clients (seen in < 60 s) are first for d in active_clients: clients.append(d) for d in inactive_clients: clients.append(d) return clients
def is_team_member(session, api, team, user): """ Checks to see if a user is a team member and caches the results """ teams = session.get("teams", {}) # Check to see if their permissions are still valid if teams and team in teams and TimeUtils.get_local_timestamp( ) < teams[team][1]: return teams[team][0] is_member = api.is_member(team, user) logger.info("User '%s' member status of '%s': %s" % (user, team, is_member)) teams[team] = (is_member, TimeUtils.get_local_timestamp() + settings.COLLABORATOR_CACHE_TIMEOUT) session["teams"] = teams return is_member
def get_bins(start_date, step): bins = [start_date] now = TimeUtils.get_local_time().replace(hour=23, minute=59) prev = start_date while True: new = prev + step if new < now: bins.append(new) prev = new else: break return bins
def num_prs_by_repo(request): context = {} repos_q = models.Repository.objects.filter(active=True).order_by("name").values("id", "name").all() repo_map = { v.get("id"): v.get("name") for v in repos_q } start = (TimeUtils.get_local_time() - datetime.timedelta(days=180)).replace(hour=0, minute=0) bins = get_bins(start, datetime.timedelta(days=7)) set_all_repo_prs(repos_q, start, "week", "Number of new PRs in last 6 months, by week", context, "%m/%d", bins) start = (TimeUtils.get_local_time() - datetime.timedelta(days=7)).replace(hour=0, minute=0) bins = get_bins(start, datetime.timedelta(days=1)) set_all_repo_prs(repos_q, start, "day", "Number of new PRs in last week, by day", context, "%m/%d", bins) sorted_repos_by_name = sorted(list(repo_map.keys()), key=lambda v: repo_map[v].lower()) repo_data = [] for key in sorted_repos_by_name: repo_graphs = context.get("repo_graphs", {}).get(key, []) if repo_graphs: repo_data.append({"id": key, "name": repo_map[key], "graphs": repo_graphs}) context["repos"] = repo_data return render(request, 'ci/num_prs.html', context)
def is_allowed_to_see_clients(session): """ Check to see if the signed in user can see client information. We do this by checking the "authorized_users" "authorized_users" can contain orgs and teams """ val = session.get("allowed_to_see_clients") # Check to see if their permissions are still valid if val and TimeUtils.get_local_timestamp() < val[1]: return val[0] user = None for server in settings.INSTALLED_GITSERVERS: gitserver = models.GitServer.objects.get(host_type=server["type"], name=server["hostname"]) auth = gitserver.auth() user = auth.signed_in_user(gitserver, session) if not user: continue api = user.api() for authed_user in server.get("authorized_users", []): if user.name == authed_user or is_team_member( session, api, authed_user, user): logger.info( "'%s' is a member of '%s' and is allowed to see clients" % (user, authed_user)) session["allowed_to_see_clients"] = ( True, TimeUtils.get_local_timestamp() + settings.COLLABORATOR_CACHE_TIMEOUT) return True logger.info("%s is NOT allowed to see clients on %s" % (user, gitserver)) session["allowed_to_see_clients"] = (False, TimeUtils.get_local_timestamp() + settings.COLLABORATOR_CACHE_TIMEOUT) return False
def is_collaborator(request_session, build_user, repo, user=None): """ Checks to see if the signed in user is a collaborator on a repo. This will cache the value for a time specified by settings.COLLABORATOR_CACHE_TIMEOUT Input: request_session: A session from HttpRequest.session build_user: models.GitUser who has access to check collaborators repo: models.Repository to check against user: models.GitUser: User to check for. If None then the user will be pulled from the request_session Return: (bool, models.GitUser) tuple: bool is whether the user is a collaborator GitUser is the user from the request_session or None if not signed in """ server = repo.server() if not user: user = server.signed_in_user(request_session) if not user: return False auth = server.auth() if auth._collaborators_key in request_session: collab_dict = request_session[auth._collaborators_key] val = collab_dict.get(str(repo)) timestamp = TimeUtils.get_local_timestamp() # Check to see if their permissions are still valid if val and timestamp < val[1]: return val[0] api = build_user.api() collab_dict = request_session.get(auth._collaborators_key, {}) val = api.is_collaborator(user, repo) collab_dict[str(repo)] = (val, TimeUtils.get_local_timestamp() + settings.COLLABORATOR_CACHE_TIMEOUT) request_session[auth._collaborators_key] = collab_dict logger.info("Is collaborator for user '%s' on %s: %s" % (user, repo, val)) return val
def check_event_row(self, ev): event_tds = self.selenium.find_elements_by_xpath( "//tr[@id='event_%s']/td" % ev.pk) sorted_jobs = ev.get_sorted_jobs() if sorted_jobs: num_boxes = len( sorted_jobs ) - 1 # each group will have a continuation box, except the last for group in sorted_jobs: num_boxes += len(group) num_boxes += 1 # this is the event description self.assertEqual(len(event_tds), num_boxes) depends = self.selenium.find_elements_by_xpath( '//td[@class="depends"]') for dep in depends: dep_html = dep.get_attribute('innerHTML') self.assertEqual( dep_html, '<span class="glyphicon glyphicon-arrow-right"></span>') ev_tr = self.selenium.find_element_by_id("event_%s" % ev.pk) self.assertIn(TimeUtils.sortable_time_str(ev.created), ev_tr.get_attribute("data-date")) ev_status = self.check_class("event_status_%s" % ev.pk, "job_status_%s" % ev.status_slug()) ev_html = ev_status.get_attribute('innerHTML') self.assertIn(str(ev.base.branch.repository.name), ev_html) if ev.pull_request: self.assertIn(escape(str(ev.pull_request)), ev_html) else: self.assertIn(str(ev.cause_str), ev_html) for job in ev.jobs.all(): job_elem = self.check_class("job_%s" % job.pk, "job_status_%s" % job.status_slug()) html = job_elem.get_attribute("innerHTML") self.assertIn(job.recipe.display_name, html) if job.invalidated: self.assertIn("Invalidated", html) else: self.assertNotIn("Invalidated", html) if job.failed_step: self.assertIn(job.failed_step, html)
def do_repo_page(request, repo): """ Render the repo page. This has the same layout as the main page but only for single repository. Input: request[django.http.HttpRequest] repo[models.Repository] """ limit = 30 repos_status = RepositoryStatus.filter_repos_status([repo.pk]) events_info = EventsStatus.events_filter_by_repo([repo.pk], limit=limit) params = { 'repo': repo, 'repos_status': repos_status, 'events_info': events_info, 'event_limit': limit, 'last_request': TimeUtils.get_local_timestamp(), 'update_interval': settings.HOME_PAGE_UPDATE_INTERVAL } return render(request, 'ci/repo.html', params)
def main(request): """ Main view. Just shows the status of repos, with open prs, as well as a short list of recent jobs. Input: request: django.http.HttpRequest Return: django.http.HttpResponse based object """ limit = 30 repos, evs_info, default = get_user_repos_info(request, limit=limit) return render(request, 'ci/main.html', {'repos': repos, 'recent_events': evs_info, 'last_request': TimeUtils.get_local_timestamp(), 'event_limit': limit, 'update_interval': settings.HOME_PAGE_UPDATE_INTERVAL, 'default_view': default, })
def user_open_prs(request, username): """ Get the updates for the main page. """ users = models.GitUser.objects.filter(name=username) if users.count() == 0: return HttpResponseBadRequest('Bad username') if 'last_request' not in request.GET: return HttpResponseBadRequest('Missing parameters') this_request = TimeUtils.get_local_timestamp() last_request = int(float(request.GET['last_request'])) # in case it has decimals dt = timezone.localtime(timezone.make_aware(datetime.datetime.utcfromtimestamp(last_request))) repos = RepositoryStatus.get_user_repos_with_open_prs_status(username) repo_ids = [] pr_ids = [] for r in repos: repo_ids.append(r["id"]) for pr in r["prs"]: pr_ids.append(pr["id"]) event_list = EventsStatus.get_single_event_for_open_prs(pr_ids) evs_info = EventsStatus.multiline_events_info(event_list) ev_ids = [] for e in evs_info: ev_ids.append(e["id"]) # Now get the changed ones repos = RepositoryStatus.get_user_repos_with_open_prs_status(username, dt) evs_info = EventsStatus.multiline_events_info(event_list, dt) data = {'repos': repo_ids, 'prs': pr_ids, 'events': ev_ids, 'repo_status': repos, 'closed': [], 'last_request': this_request, 'changed_events': evs_info, } return JsonResponse(data)
def main_update(request): """ Get the updates for the main page. """ if 'last_request' not in request.GET or 'limit' not in request.GET: return HttpResponseBadRequest('Missing parameters') this_request = TimeUtils.get_local_timestamp() limit = int(request.GET['limit']) last_request = int(float(request.GET['last_request'])) # in case it has decimals dt = timezone.localtime(timezone.make_aware(datetime.datetime.utcfromtimestamp(last_request))) repos_data, einfo, default = views.get_user_repos_info(request, limit=limit, last_modified=dt) # we also need to check if a PR closed recently closed = [] for pr in models.PullRequest.objects.filter(closed=True, last_modified__gte=dt).values('id').all(): closed.append({'id': pr['id']}) return JsonResponse({'repo_status': repos_data, 'closed': closed, 'last_request': this_request, 'events': einfo, 'limit': limit, })
def test_sortable_time_str(self): TimeUtils.sortable_time_str(datetime.datetime.now())
def test_cancel_old_jobs(self, mock_post, mock_get): out = StringIO() with self.assertRaises(CommandError): management.call_command("cancel_old_jobs", stdout=out) out = StringIO() self.set_counts() management.call_command("cancel_old_jobs", "--dryrun", "--days", "1", stdout=out) self.compare_counts() self.assertIn("No jobs to cancel", out.getvalue()) j = utils.create_job() created = TimeUtils.get_local_time() - timedelta(days=2) utils.update_job(j, ready=True, active=True, status=models.JobStatus.NOT_STARTED, created=created, complete=False) # Make sure dryrun doesn't change anything out = StringIO() self.set_counts() management.call_command("cancel_old_jobs", "--dryrun", "--days", "1", stdout=out) self.compare_counts() self.assertIn(str(j), out.getvalue()) j.refresh_from_db() self.assertEqual(j.status, models.JobStatus.NOT_STARTED) # Should update the job and event status out = StringIO() self.set_counts() management.call_command("cancel_old_jobs", "--days", "1", stdout=out) self.compare_counts(active_branches=1, canceled=1, events_canceled=1, num_changelog=1, num_events_completed=1, num_jobs_completed=1) self.assertIn(str(j), out.getvalue()) j.refresh_from_db() j.event.refresh_from_db() self.assertTrue(j.complete) self.assertEqual(j.status, models.JobStatus.CANCELED) self.assertEqual(j.event.status, models.JobStatus.CANCELED) self.assertTrue(j.event.complete) # Should not change anything since it isn't old enough utils.update_job(j, status=models.JobStatus.NOT_STARTED, complete=False) out = StringIO() self.set_counts() management.call_command("cancel_old_jobs", "--days", "3", stdout=out) self.compare_counts() self.assertIn("No jobs to cancel", out.getvalue()) self.assertNotIn(str(j), out.getvalue()) j.refresh_from_db() self.assertEqual(j.status, models.JobStatus.NOT_STARTED) # Should update the job and event status created = TimeUtils.get_local_time() - timedelta(hours=2) utils.update_job(j, status=models.JobStatus.NOT_STARTED, complete=False, created=created) out = StringIO() self.set_counts() management.call_command("cancel_old_jobs", "--hours", "1", stdout=out) self.compare_counts(canceled=1, num_changelog=1, num_jobs_completed=1) self.assertIn(str(j), out.getvalue()) j.refresh_from_db() self.assertEqual(j.status, models.JobStatus.CANCELED) # Should not change anything since it isn't old enough utils.update_job(j, status=models.JobStatus.NOT_STARTED, complete=False, created=created) out = StringIO() self.set_counts() management.call_command("cancel_old_jobs", "--hours", "3", stdout=out) self.compare_counts() self.assertIn("No jobs to cancel", out.getvalue()) self.assertNotIn(str(j), out.getvalue()) j.refresh_from_db() self.assertEqual(j.status, models.JobStatus.NOT_STARTED) # Make sure setting allowed to fail works utils.update_job(j, status=models.JobStatus.NOT_STARTED, complete=False, created=created) out = StringIO() self.set_counts() management.call_command("cancel_old_jobs", "--hours", "1", "--allowed-fail", stdout=out) self.compare_counts(events_canceled=-1, num_changelog=1, num_jobs_completed=1) self.assertIn(str(j), out.getvalue()) j.refresh_from_db() self.assertEqual(j.status, models.JobStatus.FAILED_OK) # Check the --client-runner-user option only accepts <host>:<user> syntax utils.update_job(j, status=models.JobStatus.NOT_STARTED, complete=False, created=created) out = StringIO() self.set_counts() with self.assertRaises(CommandError): management.call_command("cancel_old_jobs", "--hours", "1", '--client-runner-user', 'foo', stdout=out) self.compare_counts() # Valid --client-runner-user self.set_counts() management.call_command( "cancel_old_jobs", "--hours", "1", '--client-runner-user', "%s:%s" % (j.recipe.build_user.server.name, j.recipe.build_user.name), stdout=out) self.compare_counts(canceled=1, num_changelog=1, num_jobs_completed=1, events_canceled=1) # --client-runner-user with no jobs utils.update_job(j, status=models.JobStatus.NOT_STARTED, complete=False, created=created) other_user = utils.create_user(name="other_user") self.set_counts() management.call_command("cancel_old_jobs", "--hours", "1", '--client-runner-user', "%s:%s" % (other_user.server.name, other_user.name), stdout=out) self.compare_counts()
def job_results(request): """ Returns the job results and job info in JSON. GET parameters: job_id: The pk of the job last_request: A timestamp of when client last requested this information. If the job hasn't been updated since that time we don't have to send as much information. """ if 'last_request' not in request.GET or 'job_id' not in request.GET: return HttpResponseBadRequest('Missing parameters') this_request = TimeUtils.get_local_timestamp() job_id = int(request.GET['job_id']) last_request = int(float(request.GET['last_request'])) # in case it has decimals dt = timezone.localtime(timezone.make_aware(datetime.datetime.utcfromtimestamp(last_request))) job = get_object_or_404(models.Job.objects.select_related("recipe", "client").prefetch_related("step_results"), pk=job_id) if not Permissions.can_see_results(request.session, job.recipe): return HttpResponseForbidden("Can't see results") job_info = { 'id': job.pk, 'complete': job.complete, 'status': job.status_slug(), 'runtime': str(job.seconds), 'ready': job.ready, 'invalidated': job.invalidated, 'active': job.active, 'last_modified': TimeUtils.display_time_str(job.last_modified), 'created': TimeUtils.display_time_str(job.created), 'client_name': '', 'client_url': '', 'recipe_repo_sha': job.recipe_repo_sha[:6], 'recipe_sha': job.recipe.filename_sha[:6], } if job.last_modified < dt: # always return the basic info since we need to update the # "natural" time return JsonResponse({'job_info': job_info, 'results': [], 'last_request': this_request}) if job.client: can_see_client = Permissions.is_allowed_to_see_clients(request.session) if can_see_client: job_info['client_name'] = job.client.name job_info['client_url'] = reverse('ci:view_client', args=[job.client.pk,]) result_info = [] for result in job.step_results.all(): if dt > result.last_modified: continue exit_status = '' if result.complete: exit_status = result.exit_status info = {'id': result.id, 'name': result.name, 'runtime': str(result.seconds), 'exit_status': exit_status, 'output': result.clean_output(), 'status': result.status_slug(), 'running': result.status != models.JobStatus.NOT_STARTED, 'complete': result.complete, 'output_size': result.output_size(), } result_info.append(info) return JsonResponse({'job_info': job_info, 'results': result_info, 'last_request': this_request})
def test_std_time_str(self): TimeUtils.std_time_str(datetime.datetime.now())
def test_get_local_timestamp(self): TimeUtils.get_local_timestamp()
def test_human_time_str(self): TimeUtils.human_time_str(datetime.datetime.now())
def test_display_time_str(self): TimeUtils.display_time_str(datetime.datetime.now())
def __str__(self): out = "%s - %s" % (self.message, TimeUtils.display_time_str(self.created)) return out
def events_info(events, last_modified=None, events_url=False): """ Creates the information required for displaying events. Input: events: An iterable of models.Event. Usually a query or just a list. last_modified: DateTime: If model.Event.last_modified is before this it won't be included Return: list of event info dicts """ event_info = [] for ev in events: if last_modified and ev.last_modified <= last_modified: continue repo_url = reverse("ci:view_repo", args=[ev.base.branch.repository.pk]) event_url = reverse("ci:view_event", args=[ev.pk]) repo_link = format_html('<a href="{}">{}</a>', repo_url, ev.base.branch.repository.name) pr_url = '' pr_desc = '' if ev.pull_request: pr_url = reverse("ci:view_pr", args=[ev.pull_request.pk]) pr_desc = clean_str_for_format(str(ev.pull_request)) icon_link = format_html('<a href="{}"><i class="{}"></i></a>', ev.pull_request.url, ev.base.server().icon_class()) if events_url: event_desc = format_html('{} {} <a href="{}">{}</a>', icon_link, repo_link, event_url, pr_desc) else: event_desc = format_html('{} {} <a href="{}">{}</a>', icon_link, repo_link, pr_url, pr_desc) else: event_desc = format_html('{} <a href="{}">{}', repo_link, event_url, ev.base.branch.name) if ev.description: event_desc = format_html('{} : {}', mark_safe(event_desc), clean_str_for_format(ev.description)) event_desc += '</a>' info = { 'id': ev.pk, 'status': ev.status_slug(), 'sort_time': TimeUtils.sortable_time_str(ev.created), 'description': format_html(event_desc), 'pr_id': 0, 'pr_title': "", 'pr_status': "", 'pr_number': 0, 'pr_url': "", 'git_pr_url': "", 'pr_username': "", 'pr_name': "", } if ev.pull_request: info["pr_id"] = ev.pull_request.pk info["pr_title"] = ev.pull_request.title info["pr_status"] = ev.pull_request.status_slug() info["pr_number"] = ev.pull_request.number info["git_pr_url"] = ev.pull_request.url info["pr_url"] = pr_url info["pr_username"] = ev.pull_request.username info["pr_name"] = pr_desc job_info = [] for job_group in ev.get_sorted_jobs(): job_group_info = [] for job in job_group: if int(job.seconds.total_seconds()) == 0: job_seconds = "" else: job_seconds = str(job.seconds) jurl = reverse("ci:view_job", args=[job.pk]) jinfo = { 'id': job.pk, 'status': job.status_slug(), } job_desc = format_html('<a href="{}">{}</a>', jurl, format_html(job.unique_name())) if job_seconds: job_desc += format_html('<br />{}', job_seconds) if job.failed_step: job_desc += format_html('<br />{}', job.failed_step) if job.running_step: job_desc += format_html('<br />{}', job.running_step) if job.invalidated: job_desc += '<br />(Invalidated)' jinfo["description"] = job_desc job_group_info.append(jinfo) job_info.append(job_group_info) info['job_groups'] = job_info event_info.append(info) return event_info