diff options
| -rw-r--r-- | scripts/insert_samples.py | 16 | ||||
| -rw-r--r-- | uploader/background_jobs.py | 130 | ||||
| -rw-r--r-- | uploader/errors.py | 3 | ||||
| -rw-r--r-- | uploader/phenotypes/views.py | 8 | ||||
| -rw-r--r-- | uploader/samples/views.py | 140 | ||||
| -rw-r--r-- | uploader/session.py | 19 | ||||
| -rw-r--r-- | uploader/static/css/layout-common.css | 18 | ||||
| -rw-r--r-- | uploader/static/css/layout-large.css | 4 | ||||
| -rw-r--r-- | uploader/static/css/layout-medium.css | 4 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/base.html | 10 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/default-success-page.html | 17 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/delete-job.html | 61 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/job-status.html | 41 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/job-summary.html | 71 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/list-jobs.html | 79 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/macro-display-job-details.html | 29 | ||||
| -rw-r--r-- | uploader/templates/background-jobs/stop-job.html | 61 | ||||
| -rw-r--r-- | uploader/templates/base.html | 4 | ||||
| -rw-r--r-- | uploader/ui.py | 2 |
19 files changed, 540 insertions, 177 deletions
diff --git a/scripts/insert_samples.py b/scripts/insert_samples.py index fc029f9..96ae8e2 100644 --- a/scripts/insert_samples.py +++ b/scripts/insert_samples.py @@ -6,10 +6,10 @@ import argparse import traceback import MySQLdb as mdb -from redis import Redis + from gn_libs.mysqldb import database_connection -from uploader.check_connections import check_db, check_redis +from uploader.check_connections import check_db from uploader.species.models import species_by_id from uploader.population.models import population_by_id from uploader.samples.models import ( @@ -35,7 +35,6 @@ class SeparatorAction(argparse.Action): setattr(namespace, self.dest, (chr(9) if values == "\\t" else values)) def insert_samples(conn: mdb.Connection,# pylint: disable=[too-many-arguments, too-many-positional-arguments] - rconn: Redis,# pylint: disable=[unused-argument] speciesid: int, populationid: int, samplesfile: pathlib.Path, @@ -119,11 +118,6 @@ if __name__ == "__main__": help=("The character used to delimit (surround?) the value in " "each column.")) - # == Script-specific extras == - parser.add_argument("--redisuri", - help="URL to initialise connection to redis", - default="redis:///") - args = parser.parse_args() return args @@ -132,17 +126,13 @@ if __name__ == "__main__": status_code = 1 # Exit with an Exception args = cli_args() check_db(args.databaseuri) - check_redis(args.redisuri) if not args.samplesfile.exists(): logging.error("File not found: '%s'.", args.samplesfile) return 2 - with (Redis.from_url(args.redisuri, decode_responses=True) as rconn, - database_connection(args.databaseuri) as dbconn): - + with database_connection(args.databaseuri) as dbconn: try: status_code = insert_samples(dbconn, - rconn, args.speciesid, args.populationid, args.samplesfile, diff --git a/uploader/background_jobs.py b/uploader/background_jobs.py index 2c55272..a71dd44 100644 --- a/uploader/background_jobs.py +++ b/uploader/background_jobs.py @@ -1,35 +1,51 @@ """Generic views and utilities to handle background jobs.""" import uuid +import datetime import importlib from typing import Callable from functools import partial from werkzeug.wrappers.response import Response from flask import ( + flash, + request, redirect, Blueprint, - render_template, current_app as app) from gn_libs import jobs from gn_libs import sqlite3 from gn_libs.jobs.jobs import JobNotFound -from uploader.flask_extensions import url_for +from uploader import session from uploader.authorisation import require_login +from uploader.flask_extensions import url_for, render_template background_jobs_bp = Blueprint("background-jobs", __name__) HandlerType = Callable[[dict], Response] -def __default_error_handler__(job: dict) -> Response: - return redirect(url_for("background-jobs.job_error", job_id=job["job_id"])) +def make_datetime_formatter(dtformat: str = "%A, %d %B %Y at %H:%M %Z") -> Callable[[str], str]: + """Make a datetime formatter with the provided `dtformat`""" + def __formatter__(val: str) -> str: + dt = datetime.datetime.fromisoformat(val) + return dt.strftime(dtformat.strip()) + + return __formatter__ + +__default_datetime_formatter__ = make_datetime_formatter() + + +def __default_handler__(_job): + return render_template("background-jobs/job-summary.html", + job=_job, + display_datetime=__default_datetime_formatter__) def register_handlers( job_type: str, success_handler: HandlerType, # pylint: disable=[redefined-outer-name] - error_handler: HandlerType = __default_error_handler__ + error_handler: HandlerType = __default_handler__ # pylint: disable=[redefined-outer-name] ) -> str: """Register success and error handlers for each job type.""" @@ -61,7 +77,7 @@ def register_job_handlers(job: dict): try: _error_handler = __load_handler__(metadata["error_handler"]) except Exception as _exc:# pylint: disable=[broad-exception-caught] - _error_handler = __default_error_handler__ + _error_handler = __default_handler__ register_handlers( metadata["job-type"], _success_handler, _error_handler) @@ -77,11 +93,7 @@ def handler(job: dict, handler_type: str) -> HandlerType: if bool(_handler): return _handler(job) - def __default_success_handler__(_job): - return render_template( - "background-jobs/default-success-page.html", job=_job) - - return __default_success_handler__ + return __default_handler__(job) error_handler = partial(handler, handler_type="error") @@ -98,13 +110,15 @@ def job_status(job_id: uuid.UUID): status = job["metadata"]["status"] register_job_handlers(job) - if status == "error": + if status in ("error", "stopped"): return error_handler(job) if status == "completed": return success_handler(job) - return render_template("jobs/job-status.html", job=job) + return render_template("background-jobs/job-status.html", + job=job, + display_datetime=__default_datetime_formatter__) except JobNotFound as _jnf: return render_template("jobs/job-not-found.html", job_id=job_id) @@ -119,3 +133,93 @@ def job_error(job_id: uuid.UUID): return render_template("jobs/job-error.html", job=job) except JobNotFound as _jnf: return render_template("jobs/job-not-found.html", job_id=job_id) + + +@background_jobs_bp.route("/list") +@require_login +def list_jobs(): + """List background jobs.""" + with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn: + return render_template( + "background-jobs/list-jobs.html", + jobs=jobs.jobs_by_external_id( + conn, session.user_details()["user_id"]), + display_datetime=__default_datetime_formatter__) + + +@background_jobs_bp.route("/summary/<uuid:job_id>") +@require_login +def job_summary(job_id: uuid.UUID): + """Provide a summary for completed jobs.""" + with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn: + try: + job = jobs.job(conn, job_id, fulldetails=True) + status = job["metadata"]["status"] + + if status in ("completed", "error", "stopped"): + return render_template("background-jobs/job-summary.html", + job=job, + display_datetime=__default_datetime_formatter__) + return redirect(url_for( + "background-jobs.job_status", job_id=job["job_id"])) + except JobNotFound as _jnf: + return render_template("jobs/job-not-found.html", job_id=job_id) + + +@background_jobs_bp.route("/delete/<uuid:job_id>", methods=["GET", "POST"]) +@require_login +def delete_single(job_id: uuid.UUID): + """Delete a single job.""" + with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn: + try: + job = jobs.job(conn, job_id, fulldetails=True) + status = job["metadata"]["status"] + if status not in ("completed", "error", "stopped"): + flash("We cannot delete a running job.", "alert alert-danger") + return redirect(url_for( + "background-jobs.job_summary", job_id=job_id)) + + if request.method == "GET": + return render_template("background-jobs/delete-job.html", + job=job, + display_datetime=__default_datetime_formatter__) + + if request.form["btn-confirm-delete"] == "delete": + jobs.delete_job(conn, job_id) + flash("Job was deleted successfully.", "alert alert-success") + return redirect(url_for("background-jobs.list_jobs")) + flash("Delete cancelled.", "alert alert-info") + return redirect(url_for( + "background-jobs.job_summary", job_id=job_id)) + except JobNotFound as _jnf: + return render_template("jobs/job-not-found.html", job_id=job_id) + + +@background_jobs_bp.route("/stop/<uuid:job_id>", methods=["GET", "POST"]) +@require_login +def stop_job(job_id: uuid.UUID): + """Stop a running job.""" + with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn: + try: + job = jobs.job(conn, job_id, fulldetails=True) + status = job["metadata"]["status"] + if status != "running": + flash("Cannot stop a job that is not running.", "alert alert-danger") + return redirect(url_for( + "background-jobs.job_summary", job_id=job_id)) + + if request.method == "GET": + return render_template("background-jobs/stop-job.html", + job=job, + display_datetime=__default_datetime_formatter__) + + if request.form["btn-confirm-stop"] == "stop": + jobs.kill_job(conn, job_id) + flash("Job was stopped successfully.", "alert alert-success") + return redirect(url_for( + "background-jobs.job_summary", job_id=job_id)) + flash("Stop cancelled.", "alert alert-info") + return redirect(url_for( + "background-jobs.job_summary", job_id=job_id)) + except JobNotFound as _jnf: + return render_template("jobs/job-not-found.html", job_id=job_id) diff --git a/uploader/errors.py b/uploader/errors.py index 3e7c893..2ac48b8 100644 --- a/uploader/errors.py +++ b/uploader/errors.py @@ -3,7 +3,8 @@ import traceback from werkzeug.exceptions import HTTPException import MySQLdb as mdb -from flask import Flask, request, render_template, current_app as app +from flask import Flask, request, current_app as app +from uploader.flask_extensions import render_template def handle_general_exception(exc: Exception): """Handle generic exceptions.""" diff --git a/uploader/phenotypes/views.py b/uploader/phenotypes/views.py index f636fba..60d5775 100644 --- a/uploader/phenotypes/views.py +++ b/uploader/phenotypes/views.py @@ -680,7 +680,7 @@ def load_data_to_database( "uploader.phenotypes.views" ".load_phenotypes_success_handler") }, - external_id=str(gnlibs_jobs.logged_in_user_id())) + external_id=session.logged_in_user_id()) ).then( lambda job: gnlibs_jobs.launch_job( job, @@ -1060,8 +1060,8 @@ def recompute_means(# pylint: disable=[unused-argument] "success_handler": ( "uploader.phenotypes.views." "recompute_phenotype_means_success_handler") - }, - external_id=str(gnlibs_jobs.logged_in_user_id())), + }, + external_id=session.logged_in_user_id()), _jobs_db, Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"), worker_manager="gn_libs.jobs.launcher", @@ -1141,7 +1141,7 @@ def rerun_qtlreaper(# pylint: disable=[unused-argument] "uploader.phenotypes.views." "rerun_qtlreaper_success_handler") }, - external_id=str(gnlibs_jobs.logged_in_user_id())), + external_id=session.logged_in_user_id()), _jobs_db, Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"), worker_manager="gn_libs.jobs.launcher", diff --git a/uploader/samples/views.py b/uploader/samples/views.py index f8baf7e..f318bf0 100644 --- a/uploader/samples/views.py +++ b/uploader/samples/views.py @@ -2,16 +2,19 @@ import os import sys import uuid +import logging from pathlib import Path -from redis import Redis from flask import (flash, request, redirect, Blueprint, current_app as app) -from uploader import jobs +from gn_libs import sqlite3 +from gn_libs import jobs as jobs + +from uploader import session from uploader.files import save_file from uploader.flask_extensions import url_for from uploader.ui import make_template_renderer @@ -96,22 +99,6 @@ def list_samples(species: dict, population: dict, **kwargs):# pylint: disable=[u activelink="list-samples") -def build_sample_upload_job(# pylint: disable=[too-many-arguments, too-many-positional-arguments] - speciesid: int, - populationid: int, - samplesfile: Path, - separator: str, - firstlineheading: bool, - quotechar: str): - """Define the async command to run the actual samples data upload.""" - return [ - sys.executable, "-m", "scripts.insert_samples", app.config["SQL_URI"], - str(speciesid), str(populationid), str(samplesfile.absolute()), - separator, f"--redisuri={app.config['REDIS_URL']}", - f"--quotechar={quotechar}" - ] + (["--firstlineheading"] if firstlineheading else []) - - @samplesbp.route("<int:species_id>/populations/<int:population_id>/upload-samples", methods=["GET", "POST"]) @require_login @@ -170,102 +157,29 @@ def upload_samples(species_id: int, population_id: int):#pylint: disable=[too-ma quotechar = (request.form.get("field_delimiter", '"') or '"') - redisuri = app.config["REDIS_URL"] - with Redis.from_url(redisuri, decode_responses=True) as rconn: - #T0DO: Add a QC step here — what do we check? - # 1. Does any sample in the uploaded file exist within the database? - # If yes, what is/are its/their species and population? - # 2. If yes 1. above, provide error with notes on which species and - # populations already own the samples. - the_job = jobs.launch_job( + _jobs_db = app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"] + with sqlite3.connection(_jobs_db) as conn: + job = jobs.launch_job( jobs.initialise_job( - rconn, - jobs.jobsnamespace(), + conn, str(uuid.uuid4()), - build_sample_upload_job( - species["SpeciesId"], - population["InbredSetId"], - samples_file, + [ + sys.executable, "-m", "scripts.insert_samples", + app.config["SQL_URI"], + str(species["SpeciesId"]), + str(population["InbredSetId"]), + str(samples_file.absolute()), separator, - firstlineheading, - quotechar), + f"--quotechar={quotechar}" + ] + (["--firstlineheading"] if firstlineheading else []), "samples_upload", - app.config["JOBS_TTL_SECONDS"], - {"job_name": f"Samples Upload: {samples_file.name}"}), - redisuri, - f"{app.config['UPLOAD_FOLDER']}/job_errors") - return redirect(url_for( - "species.populations.samples.upload_status", - species_id=species_id, - population_id=population_id, - job_id=the_job["jobid"])) - - -@samplesbp.route("<int:species_id>/populations/<int:population_id>/" - "upload-samples/status/<uuid:job_id>", - methods=["GET"]) -@require_login -@with_population(species_redirect_uri="species.populations.samples.index", - redirect_uri="species.populations.samples.select_population") -def upload_status(species: dict, population: dict, job_id: uuid.UUID, **kwargs):# pylint: disable=[unused-argument] - """Check on the status of a samples upload job.""" - job = with_redis_connection(lambda rconn: jobs.job( - rconn, jobs.jobsnamespace(), job_id)) - if job: - status = job["status"] - if status == "success": - return render_template("samples/upload-success.html", - job=job, - species=species, - population=population,) - - if status == "error": - return redirect(url_for( - "species.populations.samples.upload_failure", - species_id=species["SpeciesId"], - population_id=population["Id"], - job_id=job_id)) - - error_filename = Path(jobs.error_filename( - job_id, f"{app.config['UPLOAD_FOLDER']}/job_errors")) - if error_filename.exists(): - stat = os.stat(error_filename) - if stat.st_size > 0: - return redirect(url_for( - "samples.upload_failure", job_id=job_id)) - - return render_template("samples/upload-progress.html", - species=species, - population=population, - job=job) # maybe also handle this? - - return render_template("no_such_job.html", - job_id=job_id, - species=species, - population=population), 400 - - -@samplesbp.route("<int:species_id>/populations/<int:population_id>/" - "upload-samples/failure/<uuid:job_id>", - methods=["GET"]) -@require_login -@with_population(species_redirect_uri="species.populations.samples.index", - redirect_uri="species.populations.samples.select_population") -def upload_failure(species: dict, population: dict, job_id: uuid.UUID, **kwargs):# pylint: disable=[unused-argument] - """Display the errors of the samples upload failure.""" - job = with_redis_connection(lambda rconn: jobs.job( - rconn, jobs.jobsnamespace(), job_id)) - if not bool(job): - return render_template("no_such_job.html", job_id=job_id), 400 - - error_filename = Path(jobs.error_filename( - job_id, f"{app.config['UPLOAD_FOLDER']}/job_errors")) - if error_filename.exists(): - stat = os.stat(error_filename) - if stat.st_size > 0: - return render_template("worker_failure.html", job_id=job_id) - - return render_template("samples/upload-failure.html", - species=species, - population=population, - job=job) + extra_meta={ + "job_name": f"Samples Upload: {samples_file.name}" + }, + external_id=session.logged_in_user_id()), + _jobs_db, + Path(f"{app.config['UPLOAD_FOLDER']}/job_errors").absolute(), + loglevel=logging.getLevelName( + app.logger.getEffectiveLevel()).lower()) + return redirect( + url_for("background-jobs.job_status", job_id=job["job_id"])) diff --git a/uploader/session.py b/uploader/session.py index 5af5827..1dcf8ac 100644 --- a/uploader/session.py +++ b/uploader/session.py @@ -1,4 +1,5 @@ """Deal with user sessions""" +import logging from uuid import UUID, uuid4 from datetime import datetime from typing import Any, Optional, TypedDict @@ -7,6 +8,8 @@ from authlib.jose import KeySet from flask import request, session from pymonad.either import Left, Right, Either +logger = logging.getLogger(__name__) + class UserDetails(TypedDict): """Session information relating specifically to the user.""" @@ -22,8 +25,6 @@ class SessionInfo(TypedDict): session_id: UUID user: UserDetails anon_id: UUID - user_agent: str - ip_addr: str masquerade: Optional[UserDetails] auth_server_jwks: Optional[dict[str, Any]] @@ -66,9 +67,6 @@ def session_info() -> SessionInfo: "logged_in": False }, "anon_id": anon_id, - "user_agent": request.headers.get("User-Agent"), - "ip_addr": request.environ.get("HTTP_X_FORWARDED_FOR", - request.remote_addr), "masquerading": None })) @@ -91,6 +89,17 @@ def user_details() -> UserDetails: """Retrieve user details.""" return session_info()["user"] + +def logged_in_user_id() -> Optional[UUID]: + """Get user id for logged in user. If user has not logged in, return None.""" + return user_token().then( + lambda _tok: user_details() + ).then( + lambda _user: Either(_user["user_id"], + (None, _user["email"] != "anon@ymous.user")) + ).either(lambda _err: None, lambda uid: uid) + + def user_token() -> Either: """Retrieve the user token.""" return session_info()["user"]["token"] diff --git a/uploader/static/css/layout-common.css b/uploader/static/css/layout-common.css index 36a5735..88e580c 100644 --- a/uploader/static/css/layout-common.css +++ b/uploader/static/css/layout-common.css @@ -1,3 +1,21 @@ * { box-sizing: border-box; } + + body { + display: grid; + grid-gap: 1em; + } + + #header { + margin: -0.7em; /* Fill entire length of screen */ + /* Define layout for the children elements */ + display: grid; + } + + #header #header-nav { + /* Place it in the parent element */ + grid-column-start: 1; + grid-column-end: 2; + display: flex; + } diff --git a/uploader/static/css/layout-large.css b/uploader/static/css/layout-large.css index c40a130..2d53627 100644 --- a/uploader/static/css/layout-large.css +++ b/uploader/static/css/layout-large.css @@ -1,13 +1,9 @@ @media screen and (min-width: 20.1in) { body { - display: grid; grid-template-columns: 7fr 3fr; - grid-gap: 1em; } #header { - margin: -0.7em; /* Fill entire length of screen */ - /* Place it in the parent element */ grid-column-start: 1; grid-column-end: 3; diff --git a/uploader/static/css/layout-medium.css b/uploader/static/css/layout-medium.css index 2cca711..bf10563 100644 --- a/uploader/static/css/layout-medium.css +++ b/uploader/static/css/layout-medium.css @@ -1,8 +1,6 @@ @media screen and (width > 8in) and (max-width: 20in) { body { - display: grid; grid-template-columns: 65fr 35fr; - grid-gap: 1em; } #header { @@ -12,7 +10,7 @@ /* Define layout for the children elements */ display: grid; - grid-template-columns: 8fr 2fr; + grid-template-columns: 2fr 8fr; } #header #header-text { diff --git a/uploader/templates/background-jobs/base.html b/uploader/templates/background-jobs/base.html new file mode 100644 index 0000000..7201207 --- /dev/null +++ b/uploader/templates/background-jobs/base.html @@ -0,0 +1,10 @@ +{%extends "base.html"%} + +{%block breadcrumbs%} +{{super()}} +<li class="breadcrumb-item"> + <a href="{{url_for('background-jobs.list_jobs')}}"> + background jobs + </a> +</li> +{%endblock%} diff --git a/uploader/templates/background-jobs/default-success-page.html b/uploader/templates/background-jobs/default-success-page.html deleted file mode 100644 index 5732456..0000000 --- a/uploader/templates/background-jobs/default-success-page.html +++ /dev/null @@ -1,17 +0,0 @@ -{%extends "phenotypes/base.html"%} -{%from "flash_messages.html" import flash_all_messages%} - -{%block title%}Background Jobs: Success{%endblock%} - -{%block pagetitle%}Background Jobs: Success{%endblock%} - -{%block contents%} -{{flash_all_messages()}} - -<div class="row"> - <p>Job <strong>{{job.job_id}}</strong>, - {%if job.get("metadata", {}).get("job-type")%} - of type '<em>{{job.metadata["job-type"]}}</em> - {%endif%}' completed successfully.</p> -</div> -{%endblock%} diff --git a/uploader/templates/background-jobs/delete-job.html b/uploader/templates/background-jobs/delete-job.html new file mode 100644 index 0000000..242c775 --- /dev/null +++ b/uploader/templates/background-jobs/delete-job.html @@ -0,0 +1,61 @@ +{%extends "background-jobs/base.html"%} +{%from "flash_messages.html" import flash_all_messages%} +{%from "background-jobs/macro-display-job-details.html" import display_job_details%} + +{%block title%}Background Jobs{%endblock%} + +{%block pagetitle%}Background Jobs{%endblock%} + +{%block breadcrumbs%} +{{super()}} +<li class="breadcrumb-item"> + <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}"> + summary + </a> +</li> +{%endblock%} + +{%block contents%} +{{flash_all_messages()}} + +<div class="row"> + <h2 class="heading">background jobs: delete?</h2> + + <p class="text-danger">Are you sure you want to delete the job below?</p> + + {{display_job_details(job, display_datetime)}} +</div> + +<div class="row"> + <form id="frm-delete-job" + method="POST" + action="{{url_for('background-jobs.delete_single', job_id=job.job_id)}}"> + <div class="row"> + <div class="col"> + <input type="submit" + class="btn btn-info" + value="cancel" + name="btn-confirm-delete" /> + </div> + <div class="col"> + <input type="submit" + class="btn btn-danger" + value="delete" + name="btn-confirm-delete" /> + </div> + </div> + </form> +</div> +{%endblock%} + + +{%block sidebarcontents%} +<div class="row"> + <h6 class="subheading">What is this?</h6> +</div> +<div class="row"> + <p>Confirm whether or not you want to delete job + <strong>{{job.job_id}}</strong>.</p> +</div> +{{super()}} +{%endblock%} diff --git a/uploader/templates/background-jobs/job-status.html b/uploader/templates/background-jobs/job-status.html new file mode 100644 index 0000000..50cf6e5 --- /dev/null +++ b/uploader/templates/background-jobs/job-status.html @@ -0,0 +1,41 @@ +{%extends "background-jobs/base.html"%} +{%from "background-jobs/macro-display-job-details.html" import display_job_details%} + +{%from "flash_messages.html" import flash_all_messages%} + +{%block extrameta%} +<meta http-equiv="refresh" content="5" /> +{%endblock%} + +{%block title%}Background Jobs{%endblock%} + +{%block pagetitle%}Background Jobs{%endblock%} + +{%block contents%} +{{flash_all_messages()}} + +<div class="row"> + <h2 class="heading">job status</h2> + + {{display_job_details(job, display_datetime)}} +</div> + +<div class="row"> + <div class="col"> + <a href="{{url_for('background-jobs.stop_job', job_id=job.job_id)}}" + title="Stop/Kill this job." + class="btn btn-danger">stop job</a> + </div> +</div> + +<div class="row"> + <h3 class="subheading">STDOUT</h3> + <pre>{{job["stdout"]}}</pre> +</div> + +<div class="row"> + <h3 class="subheading">STDERR</h3> + <pre>{{job["stderr"]}}</pre> +</div> + +{%endblock%} diff --git a/uploader/templates/background-jobs/job-summary.html b/uploader/templates/background-jobs/job-summary.html new file mode 100644 index 0000000..c2c2d6b --- /dev/null +++ b/uploader/templates/background-jobs/job-summary.html @@ -0,0 +1,71 @@ +{%extends "background-jobs/base.html"%} +{%from "flash_messages.html" import flash_all_messages%} +{%from "background-jobs/macro-display-job-details.html" import display_job_details%} + +{%block title%}Background Jobs{%endblock%} + +{%block pagetitle%}Background Jobs{%endblock%} + +{%block breadcrumbs%} +{{super()}} +<li class="breadcrumb-item"> + <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}"> + summary + </a> +</li> +{%endblock%} + +{%block contents%} +{{flash_all_messages()}} + +<div class="row"> + <h2 class="heading">background jobs: summary</h2> + + {{display_job_details(job, display_datetime)}} +</div> + +<div class="row"> + {%if view_under_construction%} + <div class="col"> + <a href="#" + class="btn btn-info not-implemented" + title="Update the expiry date and time for this job.">update expiry</a> + </div> + + {%if job.metadata.status in ("stopped",)%} + <div class="col"> + <a href="#" + class="btn btn-warning not-implemented" + title="Create a new copy of this job, and run the copy.">Run Copy</a> + </div> + {%endif%} + {%endif%} + + <div class="col"> + <a href="{{url_for('background-jobs.delete_single', job_id=job.job_id)}}" + class="btn btn-danger" + title="Delete this job.">delete</a> + </div> +</div> + +<div class="row"> + <h3 class="subheading">Script Errors and Logging</h3> + <pre>{{job["stderr"]}}</pre> +</div> + +<div class="row"> + <h3 class="subheading">Script Output</h3> + <pre>{{job["stdout"]}}</pre> +</div> +{%endblock%} + + +{%block sidebarcontents%} +<div class="row"> + <h6 class="subheading">What is this?</h6> +</div> +<div class="row"> + <p>This page shows the results of running job '{{job.job_id}}'.</p> +</div> +{{super()}} +{%endblock%} diff --git a/uploader/templates/background-jobs/list-jobs.html b/uploader/templates/background-jobs/list-jobs.html new file mode 100644 index 0000000..c16b850 --- /dev/null +++ b/uploader/templates/background-jobs/list-jobs.html @@ -0,0 +1,79 @@ +{%extends "background-jobs/base.html"%} +{%from "flash_messages.html" import flash_all_messages%} + +{%block title%}Background Jobs{%endblock%} + +{%block pagetitle%}Background Jobs{%endblock%} + +{%block contents%} +{{flash_all_messages()}} + +<div class="row"><h2 class="heading">Background Jobs</h2></div> + +<div class="row"> + <div class="table-responsive"> + <table class="table"> + <thead> + <tr class="table-primary"> + <th>Type</th> + <th>Created</th> + <th title="Date and time past which the job's details will be deleted from the system."> + Expires</th> + <th>Status</th> + <th>Actions</th> + </tr> + </thead> + + <tbody> + {%for job in jobs%} + <tr> + <td>{{job.metadata["job-type"]}}</td> + <td>{{display_datetime(job.created)}}</td> + <td title="Date and time past which the job's details will be deleted from the system."> + {{display_datetime(job.expires)}} + </td> + <td {%if job.metadata.status == "completed"%} + class="fw-bold text-capitalize text-success" + {%elif job.metadata.status == "error"%} + class="fw-bold text-capitalize text-danger" + {%elif job.metadata.status == "stopped"%} + class="fw-bold text-capitalize text-warning" + {%else%} + class="fw-bold text-capitalize text-info" + {%endif%}> + <div> + {{job.metadata.status}} + </div> + </td> + <td> + <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}" + class="btn btn-info" + title="View more detailed information about this job."> + view summary</a> + </td> + </tr> + {%else%} + <tr> + <td colspan="5"> + You do not have any jobs you have run in the background.</td> + </tr> + {%endfor%} + </tbody> + </table> + </div> +</div> +{%endblock%} + + +{%block sidebarcontents%} +<div class="row"> + <h6 class="subheading">What is this?</h6> +</div> +<div class="row"> + <p>The table lists the jobs that are running in the background, that you + started.</p> + <p>You can use the tools provided on this page to manage the jobs, and to view + each job's details.</p> +</div> +{{super()}} +{%endblock%} diff --git a/uploader/templates/background-jobs/macro-display-job-details.html b/uploader/templates/background-jobs/macro-display-job-details.html new file mode 100644 index 0000000..82e33c0 --- /dev/null +++ b/uploader/templates/background-jobs/macro-display-job-details.html @@ -0,0 +1,29 @@ +{%macro display_job_details(job, display_datetime)%} +<table class="table"> + <thead> + </thead> + + <tbody> + <tr> + <th class="table-primary">Job ID</th> + <td>{{job.job_id}}</td> + </tr> + <tr> + <th class="table-primary">Type</th> + <td>{{job.metadata["job-type"]}}</td> + </tr> + <tr> + <th class="table-primary">Created</th> + <td>{{display_datetime(job.created)}}</td> + </tr> + <tr> + <th class="table-primary">Expires</th> + <td>{{display_datetime(job.expires)}}</td> + </tr> + <tr> + <th class="table-primary">Status</th> + <td>{{job.metadata.status}}</td> + </tr> + </tbody> +</table> +{%endmacro%} diff --git a/uploader/templates/background-jobs/stop-job.html b/uploader/templates/background-jobs/stop-job.html new file mode 100644 index 0000000..fc190ac --- /dev/null +++ b/uploader/templates/background-jobs/stop-job.html @@ -0,0 +1,61 @@ +{%extends "background-jobs/base.html"%} +{%from "flash_messages.html" import flash_all_messages%} +{%from "background-jobs/macro-display-job-details.html" import display_job_details%} + +{%block title%}Background Jobs{%endblock%} + +{%block pagetitle%}Background Jobs{%endblock%} + +{%block breadcrumbs%} +{{super()}} +<li class="breadcrumb-item"> + <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}"> + summary + </a> +</li> +{%endblock%} + +{%block contents%} +{{flash_all_messages()}} + +<div class="row"> + <h2 class="heading">background jobs: stop?</h2> + + <p class="text-danger">Are you sure you want to stop the job below?</p> + + {{display_job_details(job, display_datetime)}} +</div> + +<div class="row"> + <form id="frm-stop-job" + method="POST" + action="{{url_for('background-jobs.stop_job', job_id=job.job_id)}}"> + <div class="row"> + <div class="col"> + <input type="submit" + class="btn btn-info" + value="cancel" + name="btn-confirm-stop" /> + </div> + <div class="col"> + <input type="submit" + class="btn btn-danger" + value="stop" + name="btn-confirm-stop" /> + </div> + </div> + </form> +</div> +{%endblock%} + + +{%block sidebarcontents%} +<div class="row"> + <h6 class="subheading">What is this?</h6> +</div> +<div class="row"> + <p>Confirm whether or not you want to stop job + <strong>{{job.job_id}}</strong>.</p> +</div> +{{super()}} +{%endblock%} diff --git a/uploader/templates/base.html b/uploader/templates/base.html index fd345b9..ae4ecef 100644 --- a/uploader/templates/base.html +++ b/uploader/templates/base.html @@ -32,9 +32,8 @@ <nav id="header-nav"> <ul class="nav"> {%if user_logged_in()%} - {%if view_under_construction%} <li> - <a href="#" + <a href="{{url_for('background-jobs.list_jobs')}}" title="User's background jobs."> <!-- https://icons.getbootstrap.com/icons/back/ --> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-back" viewBox="0 0 16 16"> @@ -43,7 +42,6 @@ Background jobs </a> </li> - {%endif%} <li> <a href="{{url_for('oauth2.logout')}}" diff --git a/uploader/ui.py b/uploader/ui.py index 1994056..41791c7 100644 --- a/uploader/ui.py +++ b/uploader/ui.py @@ -1,5 +1,5 @@ """Utilities to handle the UI""" -from flask import render_template as flask_render_template +from uploader.flask_extensions import render_template as flask_render_template def make_template_renderer(default): """Render template for species.""" |
