about summary refs log tree commit diff
path: root/uploader
diff options
context:
space:
mode:
Diffstat (limited to 'uploader')
-rw-r--r--uploader/background_jobs.py130
-rw-r--r--uploader/errors.py3
-rw-r--r--uploader/flask_extensions.py40
-rw-r--r--uploader/phenotypes/views.py9
-rw-r--r--uploader/samples/views.py140
-rw-r--r--uploader/session.py19
-rw-r--r--uploader/static/css/layout-common.css18
-rw-r--r--uploader/static/css/layout-large.css10
-rw-r--r--uploader/static/css/layout-medium.css4
-rw-r--r--uploader/static/css/theme.css13
-rw-r--r--uploader/templates/background-jobs/base.html10
-rw-r--r--uploader/templates/background-jobs/default-success-page.html17
-rw-r--r--uploader/templates/background-jobs/delete-job.html61
-rw-r--r--uploader/templates/background-jobs/job-status.html41
-rw-r--r--uploader/templates/background-jobs/job-summary.html71
-rw-r--r--uploader/templates/background-jobs/list-jobs.html79
-rw-r--r--uploader/templates/background-jobs/macro-display-job-details.html29
-rw-r--r--uploader/templates/background-jobs/stop-job.html61
-rw-r--r--uploader/templates/base.html24
-rw-r--r--uploader/ui.py2
20 files changed, 602 insertions, 179 deletions
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/flask_extensions.py b/uploader/flask_extensions.py
index 30fbad7..83d25aa 100644
--- a/uploader/flask_extensions.py
+++ b/uploader/flask_extensions.py
@@ -2,19 +2,16 @@
 import logging
 from typing import Any, Optional
 
-from flask import (request, current_app as app, url_for as flask_url_for)
+from flask import (
+    request,
+    current_app as app,
+    url_for as flask_url_for,
+    render_template as flask_render_template)
 
 logger = logging.getLogger(__name__)
 
 
-def url_for(
-        endpoint: str,
-        _anchor: Optional[str] = None,
-        _method: Optional[str] = None,
-        _scheme: Optional[str] = None,
-        _external: Optional[bool] = None,
-        **values: Any) -> str:
-    """Extension to flask's `url_for` function."""
+def __fetch_flags__():
     flags = {}
     for flag in app.config["FEATURE_FLAGS_HTTP"]:
         flag_value = (request.args.get(flag) or request.form.get(flag) or "").strip()
@@ -22,12 +19,33 @@ def url_for(
             flags[flag] = flag_value
             continue
         continue
+    logger.debug("HTTP FEATURE FLAGS: %s", flags)
+    return flags
 
-    logger.debug("HTTP FEATURE FLAGS: %s, other variables: %s", flags, values)
+
+def url_for(
+        endpoint: str,
+        _anchor: Optional[str] = None,
+        _method: Optional[str] = None,
+        _scheme: Optional[str] = None,
+        _external: Optional[bool] = None,
+        **values: Any) -> str:
+    """Extension to flask's `url_for` function."""
+    logger.debug("other variables: %s", values)
     return flask_url_for(endpoint=endpoint,
                          _anchor=_anchor,
                          _method=_method,
                          _scheme=_scheme,
                          _external=_external,
                          **values,
-                         **flags)
+                         **__fetch_flags__())
+
+
+def render_template(template_name_or_list, **context: Any) -> str:
+    """Extend flask's `render_template` function"""
+    return flask_render_template(
+        template_name_or_list,
+        **{
+            **context,
+            **__fetch_flags__() # override any flag values
+        })
diff --git a/uploader/phenotypes/views.py b/uploader/phenotypes/views.py
index c4c6170..60d5775 100644
--- a/uploader/phenotypes/views.py
+++ b/uploader/phenotypes/views.py
@@ -679,7 +679,8 @@ def load_data_to_database(
                     "success_handler": (
                         "uploader.phenotypes.views"
                         ".load_phenotypes_success_handler")
-                })
+                },
+                external_id=session.logged_in_user_id())
         ).then(
             lambda job: gnlibs_jobs.launch_job(
                 job,
@@ -1059,7 +1060,8 @@ def recompute_means(# pylint: disable=[unused-argument]
                     "success_handler": (
                         "uploader.phenotypes.views."
                         "recompute_phenotype_means_success_handler")
-            }),
+                },
+                external_id=session.logged_in_user_id()),
             _jobs_db,
             Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"),
             worker_manager="gn_libs.jobs.launcher",
@@ -1138,7 +1140,8 @@ def rerun_qtlreaper(# pylint: disable=[unused-argument]
                     "success_handler": (
                         "uploader.phenotypes.views."
                         "rerun_qtlreaper_success_handler")
-            }),
+            },
+            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 4471791..2d53627 100644
--- a/uploader/static/css/layout-large.css
+++ b/uploader/static/css/layout-large.css
@@ -1,20 +1,16 @@
 @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;
 
         /* Define layout for the children elements */
         display: grid;
-        grid-template-columns: 8fr 2fr;
+        grid-template-columns: 1fr 9fr;
     }
 
     #header #header-text {
@@ -30,8 +26,6 @@
         /* Place it in the parent element */
         grid-column-start: 2;
         grid-column-end: 3;
-
-        padding-right: 1em;
     }
 
     #main {
@@ -49,6 +43,8 @@
         grid-column-start: 1;
         grid-column-end: 3;
         padding: 0 3px;
+
+        margin: -0.3em -0.7em 0 -0.7em;
     }
 
     #main #main-content {
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/static/css/theme.css b/uploader/static/css/theme.css
index 8d5ac35..45e5d3d 100644
--- a/uploader/static/css/theme.css
+++ b/uploader/static/css/theme.css
@@ -15,15 +15,20 @@ body {
 #header #header-nav .nav li a {
     /* Content styling */
     color: #FFFFFF;
-    vertical-align: top;
     font-size: 0.7em;
     text-align: center;
     padding: 1px 7px;
+    text-decoration: none;
 }
 
 #main #breadcrumbs {
-    border-radius:3px;
     text-align: center;
+    background-color: #D5D5D5;
+    padding: 0 1em 0 1em;
+}
+
+#main #breadcrumbs .breadcrumb {
+    padding-top: 0.5em;
 }
 
 #main #main-content {
@@ -86,3 +91,7 @@ table.dataTable tbody tr.selected td {
 .breadcrumb-item {
     text-transform: Capitalize;
 }
+
+.breadcrumb-item a {
+    text-decoration: none;
+}
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 0f088b1..ae4ecef 100644
--- a/uploader/templates/base.html
+++ b/uploader/templates/base.html
@@ -30,14 +30,32 @@
     <header id="header">
       <span id="header-text">GeneNetwork</span>
       <nav id="header-nav">
-        <ul class="nav justify-content-end">
+        <ul class="nav">
+          {%if user_logged_in()%}
+          <li>
+            <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">
+                <path d="M0 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2H2a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/>
+              </svg>
+              Background jobs
+            </a>
+          </li>
+
           <li>
-            {%if user_logged_in()%}
             <a href="{{url_for('oauth2.logout')}}"
                title="Log out of the system">
+              <!-- https://icons.getbootstrap.com/icons/file-person/ -->
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-person" viewBox="0 0 16 16">
+                <path d="M12 1a1 1 0 0 1 1 1v10.755S12 11 8 11s-5 1.755-5 1.755V2a1 1 0 0 1 1-1zM4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
+                <path d="M8 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
+              </svg>
               <span class="glyphicon glyphicon-user"></span>
               Sign Out ({{user_email()}})</a>
-            {%else%}
+          </li>
+          {%else%}
+          <li>
             <a href="{{authserver_authorise_uri()}}"
                title="Log in to the system">Sign In</a>
             {%endif%}
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."""