about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--qc_app/default_settings.py2
-rw-r--r--scripts/phenotypes/delete_phenotypes.py30
-rw-r--r--scripts/run_qtlreaper.py2
-rw-r--r--tests/conftest.py2
-rw-r--r--tests/test_instance_dir/config.py2
-rw-r--r--tests/uploader/test_parse.py2
-rw-r--r--uploader/expression_data/dbinsert.py6
-rw-r--r--uploader/expression_data/views.py10
-rw-r--r--uploader/flask_extensions.py7
-rw-r--r--uploader/oauth2/client.py15
-rw-r--r--uploader/phenotypes/models.py17
-rw-r--r--uploader/phenotypes/views.py104
-rw-r--r--uploader/population/rqtl2.py6
-rw-r--r--uploader/samples/views.py27
-rw-r--r--uploader/static/css/layout-common.css30
-rw-r--r--uploader/templates/background-jobs/job-status.html8
-rw-r--r--uploader/templates/background-jobs/job-summary.html8
-rw-r--r--uploader/templates/phenotypes/add-phenotypes-base.html8
-rw-r--r--uploader/templates/phenotypes/base.html2
-rw-r--r--uploader/templates/phenotypes/confirm-delete-phenotypes.html30
-rw-r--r--uploader/templates/phenotypes/create-dataset.html5
-rw-r--r--uploader/templates/phenotypes/view-dataset.html2
22 files changed, 241 insertions, 84 deletions
diff --git a/qc_app/default_settings.py b/qc_app/default_settings.py
index 7a9da0f..7bb0bf8 100644
--- a/qc_app/default_settings.py
+++ b/qc_app/default_settings.py
@@ -7,7 +7,7 @@ import os
 
 LOG_LEVEL = os.getenv("LOG_LEVEL", "WARNING")
 SECRET_KEY = b"<Please! Please! Please! Change This!>"
-UPLOAD_FOLDER = "/tmp/qc_app_files"
+UPLOADS_DIRECTORY = "/tmp/qc_app_files"
 REDIS_URL = "redis://"
 JOBS_TTL_SECONDS = 1209600 # 14 days
 GNQC_REDIS_PREFIX="GNQC"
diff --git a/scripts/phenotypes/delete_phenotypes.py b/scripts/phenotypes/delete_phenotypes.py
index 028f061..461f3ec 100644
--- a/scripts/phenotypes/delete_phenotypes.py
+++ b/scripts/phenotypes/delete_phenotypes.py
@@ -24,12 +24,15 @@ def read_xref_ids_file(filepath: Optional[Path]) -> tuple[int, ...]:
     if filepath is None:
         return tuple()
 
+    logger.debug("Using file '%s' to retrieve XREF IDs for deletion.",
+                 filepath.name)
     _ids: tuple[int, ...] = tuple()
     with filepath.open(mode="r") as infile:
-        try:
-            _ids += (int(infile.readline().strip()),)
-        except TypeError:
-            pass
+        for line in infile.readlines():
+            try:
+                _ids += (int(line.strip()),)
+            except TypeError:
+                pass
 
     return _ids
 
@@ -125,16 +128,27 @@ if __name__ == "__main__":
                 assert not (len(xref_ids) > 0 and args.delete_all)
                 xref_ids = (fetch_all_xref_ids(cursor, args.population_id)
                             if args.delete_all else xref_ids)
+                logger.debug("Will delete %s phenotypes and related data",
+                             len(xref_ids))
                 if len(xref_ids) == 0:
                     print("No cross-reference IDs were provided. Aborting.")
                     return 0
 
+                print("Updating authorisations: ", end="")
                 update_auth((args.auth_server_uri, args.auth_token),
                             args.species_id,
                             args.population_id,
                             args.dataset_id,
                             xref_ids)
+                print("OK.")
+                print("Deleting the data: ", end="")
                 delete_phenotypes(cursor, args.population_id, xref_ids=xref_ids)
+                print("OK.")
+                if args.xref_ids_file is not None:
+                    print("Deleting temporary file: ", end="")
+                    args.xref_ids_file.unlink()
+                    print("OK.")
+
                 return 0
             except AssertionError:
                 logger.error(
@@ -143,6 +157,14 @@ if __name__ == "__main__":
                     "and also specify to 'DELETE-ALL' phenotypes in the "
                     "population, we have no way of knowing what it is you want.")
                 return 1
+            except requests.exceptions.HTTPError as _exc:
+                resp = _exc.response
+                resp_data = resp.json()
+                logger.debug("%s: %s",
+                             resp_data["error"],
+                             resp_data["error_description"],
+                             exc_info=True)
+                return 1
             except Exception as _exc:# pylint: disable=[broad-exception-caught]
                 logger.debug("Failed while attempting to delete phenotypes.",
                              exc_info=True)
diff --git a/scripts/run_qtlreaper.py b/scripts/run_qtlreaper.py
index 7d58402..54e5d45 100644
--- a/scripts/run_qtlreaper.py
+++ b/scripts/run_qtlreaper.py
@@ -169,7 +169,7 @@ def dispatch(args: Namespace) -> int:
             logger.info("Successfully computed p values for %s traits.", len(_traitsdata))
             return 0
         except FileNotFoundError as fnf:
-            logger.error(", ".join(fnf.args), exc_info=False)
+            logger.error(", ".join(str(arg) for arg in fnf.args), exc_info=False)
         except AssertionError as aserr:
             logger.error(", ".join(aserr.args), exc_info=False)
         except Exception as _exc:# pylint: disable=[broad-exception-caught]
diff --git a/tests/conftest.py b/tests/conftest.py
index a716c52..2009aab 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -183,7 +183,7 @@ def redis_conn_with_completed_job_some_errors(redis_url, redis_ttl, jobs_prefix,
 def uploads_dir(client): # pylint: disable=[redefined-outer-name]
     """Returns the configured, uploads directory, creating it if it does not
     exist."""
-    the_dir = client.application.config["UPLOAD_FOLDER"]
+    the_dir = client.application.config["UPLOADS_DIRECTORY"]
     if not os.path.exists(the_dir):
         os.mkdir(the_dir)
 
diff --git a/tests/test_instance_dir/config.py b/tests/test_instance_dir/config.py
index 2ee569b..f04b3df 100644
--- a/tests/test_instance_dir/config.py
+++ b/tests/test_instance_dir/config.py
@@ -6,6 +6,6 @@ import os
 
 LOG_LEVEL = os.getenv("LOG_LEVEL", "WARNING")
 SECRET_KEY = b"<Please! Please! Please! Change This!>"
-UPLOAD_FOLDER = "/tmp/qc_app_files"
+UPLOADS_DIRECTORY = "/tmp/qc_app_files"
 REDIS_URL = "redis://"
 JOBS_TTL_SECONDS = 600 # 10 minutes
diff --git a/tests/uploader/test_parse.py b/tests/uploader/test_parse.py
index 20c75b7..56e1b41 100644
--- a/tests/uploader/test_parse.py
+++ b/tests/uploader/test_parse.py
@@ -50,7 +50,7 @@ def test_parse_with_existing_uploaded_file(
     assert the_job["command"] == " ".join([
         sys.executable, "-m", "scripts.validate_file", db_url, redis_url,
         jobs_prefix, job_id, "--redisexpiry", str(redis_ttl), str(speciesid),
-        filetype, f"{client.application.config['UPLOAD_FOLDER']}/{filename}"])
+        filetype, f"{client.application.config['UPLOADS_DIRECTORY']}/{filename}"])
 
 @pytest.mark.parametrize(
     "filename,uri,error_msgs",
diff --git a/uploader/expression_data/dbinsert.py b/uploader/expression_data/dbinsert.py
index 6d8ce80..7040698 100644
--- a/uploader/expression_data/dbinsert.py
+++ b/uploader/expression_data/dbinsert.py
@@ -94,7 +94,7 @@ def select_platform():
         job = jobs.job(rconn, jobs.jobsnamespace(), job_id)
         if job:
             filename = job["filename"]
-            filepath = f"{app.config['UPLOAD_FOLDER']}/{filename}"
+            filepath = f"{app.config['UPLOADS_DIRECTORY']}/{filename}"
             if os.path.exists(filepath):
                 default_species = 1
                 gchips = genechips()
@@ -367,7 +367,7 @@ def insert_data():
         assert form.get("datasetid"), "dataset"
 
         filename = form["filename"]
-        filepath = f"{app.config['UPLOAD_FOLDER']}/{filename}"
+        filepath = f"{app.config['UPLOADS_DIRECTORY']}/{filename}"
         redisurl = app.config["REDIS_URL"]
         if os.path.exists(filepath):
             with Redis.from_url(redisurl, decode_responses=True) as rconn:
@@ -377,7 +377,7 @@ def insert_data():
                         form["species"], form["genechipid"], form["datasetid"],
                         app.config["SQL_URI"], redisurl,
                         app.config["JOBS_TTL_SECONDS"]),
-                    redisurl, f"{app.config['UPLOAD_FOLDER']}/job_errors")
+                    redisurl, f"{app.config['UPLOADS_DIRECTORY']}/job_errors")
 
             return redirect(url_for("dbinsert.insert_status", job_id=job["jobid"]))
         return render_error(f"File '{filename}' no longer exists.")
diff --git a/uploader/expression_data/views.py b/uploader/expression_data/views.py
index 0b318b7..0e9b072 100644
--- a/uploader/expression_data/views.py
+++ b/uploader/expression_data/views.py
@@ -162,7 +162,7 @@ def upload_file(species_id: int, population_id: int):
                                    species=species,
                                    population=population)
 
-        upload_dir = app.config["UPLOAD_FOLDER"]
+        upload_dir = app.config["UPLOADS_DIRECTORY"]
         request_errors = errors(request)
         if request_errors:
             for error in request_errors:
@@ -225,7 +225,7 @@ def parse_file(species_id: int, population_id: int):
         _errors = True
 
     if filename:
-        filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
+        filepath = os.path.join(app.config["UPLOADS_DIRECTORY"], filename)
         if not os.path.exists(filepath):
             flash("Selected file does not exist (any longer)", "alert-danger")
             _errors = True
@@ -241,7 +241,7 @@ def parse_file(species_id: int, population_id: int):
                 species_id, filepath, filetype,# type: ignore[arg-type]
                 app.config["JOBS_TTL_SECONDS"]),
             redisurl,
-            f"{app.config['UPLOAD_FOLDER']}/job_errors")
+            f"{app.config['UPLOADS_DIRECTORY']}/job_errors")
 
     return redirect(url_for("species.populations.expression-data.parse_status",
                             species_id=species_id,
@@ -263,7 +263,7 @@ def parse_status(species_id: int, population_id: int, job_id: str):
             return render_template("no_such_job.html", job_id=job_id), 400
 
     error_filename = jobs.error_filename(
-        job_id, f"{app.config['UPLOAD_FOLDER']}/job_errors")
+        job_id, f"{app.config['UPLOADS_DIRECTORY']}/job_errors")
     if os.path.exists(error_filename):
         stat = os.stat(error_filename)
         if stat.st_size > 0:
@@ -345,7 +345,7 @@ def fail(species_id: int, population_id: int, job_id: str):
 
     if job:
         error_filename = jobs.error_filename(
-            job_id, f"{app.config['UPLOAD_FOLDER']}/job_errors")
+            job_id, f"{app.config['UPLOADS_DIRECTORY']}/job_errors")
         if os.path.exists(error_filename):
             stat = os.stat(error_filename)
             if stat.st_size > 0:
diff --git a/uploader/flask_extensions.py b/uploader/flask_extensions.py
index 83d25aa..0fc774a 100644
--- a/uploader/flask_extensions.py
+++ b/uploader/flask_extensions.py
@@ -11,7 +11,8 @@ from flask import (
 logger = logging.getLogger(__name__)
 
 
-def __fetch_flags__():
+def fetch_flags():
+    """Fetch get arguments that are defined as feature flags."""
     flags = {}
     for flag in app.config["FEATURE_FLAGS_HTTP"]:
         flag_value = (request.args.get(flag) or request.form.get(flag) or "").strip()
@@ -38,7 +39,7 @@ def url_for(
                          _scheme=_scheme,
                          _external=_external,
                          **values,
-                         **__fetch_flags__())
+                         **fetch_flags())
 
 
 def render_template(template_name_or_list, **context: Any) -> str:
@@ -47,5 +48,5 @@ def render_template(template_name_or_list, **context: Any) -> str:
         template_name_or_list,
         **{
             **context,
-            **__fetch_flags__() # override any flag values
+            **fetch_flags() # override any flag values
         })
diff --git a/uploader/oauth2/client.py b/uploader/oauth2/client.py
index 4e81afd..e37816d 100644
--- a/uploader/oauth2/client.py
+++ b/uploader/oauth2/client.py
@@ -4,7 +4,7 @@ import time
 import uuid
 import random
 from datetime import datetime, timedelta
-from urllib.parse import urljoin, urlparse
+from urllib.parse import urljoin, urlparse, urlencode
 
 import requests
 from flask import request, current_app as app
@@ -18,6 +18,7 @@ from authlib.integrations.requests_client import OAuth2Session
 
 from uploader import session
 import uploader.monadic_requests as mrequests
+from uploader.flask_extensions import fetch_flags
 
 SCOPE = ("profile group role resource register-client user masquerade "
          "introspect migrate-data")
@@ -176,11 +177,13 @@ def authserver_authorise_uri():
     """Build up the authorisation URI."""
     req_baseurl = urlparse(request.base_url, scheme=request.scheme)
     host_uri = f"{req_baseurl.scheme}://{req_baseurl.netloc}/"
-    return urljoin(
-        authserver_uri(),
-        "auth/authorise?response_type=code"
-        f"&client_id={oauth2_clientid()}"
-        f"&redirect_uri={urljoin(host_uri, 'oauth2/code')}")
+    args = {
+        "response_type": "code",
+        "client_id": oauth2_clientid(),
+        "redirect_uri": (
+            f"{urljoin(host_uri, 'oauth2/code')}?{urlencode(fetch_flags())}")
+    }
+    return f"{urljoin(authserver_uri(), 'auth/authorise')}?{urlencode(args)}"
 
 
 def __no_token__(_err) -> Left:
diff --git a/uploader/phenotypes/models.py b/uploader/phenotypes/models.py
index b9841aa..3946a0f 100644
--- a/uploader/phenotypes/models.py
+++ b/uploader/phenotypes/models.py
@@ -87,11 +87,14 @@ def phenotype_publication_data(conn, phenotype_id) -> Optional[dict]:
         return dict(res)
 
 
-def dataset_phenotypes(conn: Connection,
-                       population_id: int,
-                       dataset_id: int,
-                       offset: int = 0,
-                       limit: Optional[int] = None) -> tuple[dict, ...]:
+def dataset_phenotypes(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+        conn: Connection,
+        population_id: int,
+        dataset_id: int,
+        offset: int = 0,
+        limit: Optional[int] = None,
+        xref_ids: tuple[int, ...] = tuple()
+) -> tuple[dict, ...]:
     """Fetch the actual phenotypes."""
     _query = (
         "SELECT pheno.*, pxr.Id AS xref_id, pxr.InbredSetId, ist.InbredSetCode "
@@ -100,9 +103,11 @@ def dataset_phenotypes(conn: Connection,
         "INNER JOIN PublishFreeze AS pf ON pxr.InbredSetId=pf.InbredSetId "
         "INNER JOIN InbredSet AS ist ON pf.InbredSetId=ist.Id "
         "WHERE pxr.InbredSetId=%s AND pf.Id=%s") + (
+            f" AND pxr.Id IN ({', '.join(['%s'] * len(xref_ids))})"
+            if len(xref_ids) > 0 else "") + (
             f" LIMIT {limit} OFFSET {offset}" if bool(limit) else "")
     with conn.cursor(cursorclass=DictCursor) as cursor:
-        cursor.execute(_query, (population_id, dataset_id))
+        cursor.execute(_query, (population_id, dataset_id) + xref_ids)
         debug_query(cursor, logger)
         return tuple(dict(row) for row in cursor.fetchall())
 
diff --git a/uploader/phenotypes/views.py b/uploader/phenotypes/views.py
index 2cf0ca0..ce73c89 100644
--- a/uploader/phenotypes/views.py
+++ b/uploader/phenotypes/views.py
@@ -612,6 +612,12 @@ def load_phenotypes_success_handler(job):
         job_id=job["job_id"]))
 
 
+def proceed_to_job_status(job):
+    """A generic 'job success' handler for asynchronous phenotype jobs."""
+    app.logger.debug("The new job: %s", job)
+    return redirect(url_for("background-jobs.job_status", job_id=job["job_id"]))
+
+
 @phenotypesbp.route(
     "<int:species_id>/populations/<int:population_id>/phenotypes/datasets"
     "/<int:dataset_id>/load-data-to-database",
@@ -654,11 +660,6 @@ def load_data_to_database(
         def __handle_error__(resp):
             return render_template("http-error.html", *resp.json())
 
-        def __handle_success__(load_job):
-            app.logger.debug("The phenotypes loading job: %s", load_job)
-            return redirect(url_for(
-                "background-jobs.job_status", job_id=load_job["job_id"]))
-
 
         return request_token(
             token_uri=urljoin(oauth2client.authserver_uri(), "auth/token"),
@@ -689,7 +690,7 @@ def load_data_to_database(
                 Path(f"{uploads_dir(app)}/job_errors"),
                 worker_manager="gn_libs.jobs.launcher",
                 loglevel=_loglevel)
-        ).either(__handle_error__, __handle_success__)
+        ).either(__handle_error__, proceed_to_job_status)
 
 
 def update_phenotype_metadata(conn, metadata: dict):
@@ -1158,6 +1159,12 @@ def rerun_qtlreaper_success_handler(job):
     return return_to_dataset_view_handler(job, "QTLReaper ran successfully!")
 
 
+def delete_phenotypes_success_handler(job):
+    """Handle success running the 'delete-phenotypes' script."""
+    return return_to_dataset_view_handler(
+        job, "Phenotypes deleted successfully.")
+
+
 @phenotypesbp.route(
     "<int:species_id>/populations/<int:population_id>/phenotypes/datasets"
     "/<int:dataset_id>/delete",
@@ -1167,14 +1174,29 @@ def rerun_qtlreaper_success_handler(job):
     species_redirect_uri="species.populations.phenotypes.index",
     population_redirect_uri="species.populations.phenotypes.select_population",
     redirect_uri="species.populations.phenotypes.list_datasets")
-def delete_phenotypes(# pylint: disable=[unused-argument]
+def delete_phenotypes(# pylint: disable=[unused-argument, too-many-locals]
         species: dict,
         population: dict,
         dataset: dict,
         **kwargs
 ):
     """Delete the specified phenotype data."""
-    with database_connection(app.config["SQL_URI"]) as conn:
+    _dataset_page = redirect(url_for(
+        "species.populations.phenotypes.view_dataset",
+        species_id=species["SpeciesId"],
+        population_id=population["Id"],
+        dataset_id=dataset["Id"]))
+
+    def __handle_error__(resp):
+        flash(
+            "Error retrieving authorisation token. Phenotype deletion "
+            "failed. Please try again later.",
+            "alert alert-danger")
+        return _dataset_page
+
+    _jobs_db = app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]
+    with (database_connection(app.config["SQL_URI"]) as conn,
+          sqlite3.connection(_jobs_db) as jobsconn):
         form = request.form
         xref_ids = tuple(int(item) for item in set(form.getlist("xref_ids")))
 
@@ -1186,16 +1208,68 @@ def delete_phenotypes(# pylint: disable=[unused-argument]
                     population_id=population["Id"],
                     dataset_id=dataset["Id"]))
             case "delete":
-                # delete everything
-                # python3 -m scripts.phenotypes.delete_phenotypes <mariadburi> <authdburi> <speciesid> <populationid>
-                #
-                # delete selected phenotypes
-                # python3 -m scripts.phenotypes.delete_phenotypes <mariadburi> <authdburi> <speciesid> <populationid> --xref-ids-file=/path/to/file.txt
-                return "Would actually delete the data!"
+                _loglevel = logging.getLevelName(
+                    app.logger.getEffectiveLevel()).lower()
+                if form.get("confirm_delete_all_phenotypes", "") == "on":
+                    _cmd = ["--delete-all"]
+                else:
+                    # setup phenotypes xref_ids file
+                    _xref_ids_file = Path(
+                        app.config["SCRATCH_DIRECTORY"],
+                        f"delete-phenotypes-{uuid.uuid4()}.txt")
+                    with _xref_ids_file.open(mode="w", encoding="utf8") as ptr:
+                        ptr.write("\n".join(str(_id) for _id in xref_ids))
+
+                    _cmd = ["--xref_ids_file", str(_xref_ids_file)]
+
+                _job_id = uuid.uuid4()
+                return request_token(
+                    token_uri=urljoin(
+                        oauth2client.authserver_uri(), "auth/token"),
+                    user_id=session.user_details()["user_id"]
+                ).then(
+                    lambda token: gnlibs_jobs.initialise_job(
+                        jobsconn,
+                        _job_id,
+                        [
+                            sys.executable,
+                            "-u",
+                            "-m",
+                            "scripts.phenotypes.delete_phenotypes",
+                            "--log-level", _loglevel,
+                            app.config["SQL_URI"],
+                            str(species["SpeciesId"]),
+                            str(population["Id"]),
+                            str(dataset["Id"]),
+                            app.config["AUTH_SERVER_URL"],
+                            token["access_token"]] + _cmd,
+                        "delete-phenotypes",
+                        extra_meta={
+                            "species_id": species["SpeciesId"],
+                            "population_id": population["Id"],
+                            "dataset_id": dataset["Id"],
+                            "success_handler": (
+                                "uploader.phenotypes.views."
+                                "delete_phenotypes_success_handler")
+                        },
+                        external_id=session.logged_in_user_id())
+                ).then(
+                    lambda _job: gnlibs_jobs.launch_job(
+                        _job,
+                        _jobs_db,
+                        Path(f"{uploads_dir(app)}/job_errors"),
+                        worker_manager="gn_libs.jobs.launcher",
+                        loglevel=_loglevel)
+                ).either(__handle_error__, proceed_to_job_status)
             case _:
+                _phenos: tuple[dict, ...] = tuple()
+                if len(xref_ids) > 0:
+                    _phenos = dataset_phenotypes(
+                        conn, population["Id"], dataset["Id"], xref_ids=xref_ids)
+
                 return render_template(
                     "phenotypes/confirm-delete-phenotypes.html",
                     species=species,
                     population=population,
                     dataset=dataset,
-                    phenotypes=xref_ids)
+                    phenotypes=_phenos)
diff --git a/uploader/population/rqtl2.py b/uploader/population/rqtl2.py
index 97d4854..bb5066e 100644
--- a/uploader/population/rqtl2.py
+++ b/uploader/population/rqtl2.py
@@ -134,7 +134,7 @@ def upload_rqtl2_bundle(species_id: int, population_id: int):
         try:
             app.logger.debug("Files in the form: %s", request.files)
             the_file = save_file(request.files["rqtl2_bundle_file"],
-                                 Path(app.config["UPLOAD_FOLDER"]))
+                                 Path(app.config["UPLOADS_DIRECTORY"]))
         except AssertionError:
             app.logger.debug(traceback.format_exc())
             flash("Please provide a valid R/qtl2 zip bundle.",
@@ -185,7 +185,7 @@ def trigger_rqtl2_bundle_qc(
                     "rqtl2-bundle-file": str(rqtl2bundle.absolute()),
                     "original-filename": originalfilename})}),
             redisuri,
-            f"{app.config['UPLOAD_FOLDER']}/job_errors")
+            f"{app.config['UPLOADS_DIRECTORY']}/job_errors")
         return jobid
 
 
@@ -895,7 +895,7 @@ def confirm_bundle_details(species_id: int, population_id: int):
                         })
                     }),
                 redisuri,
-                f"{app.config['UPLOAD_FOLDER']}/job_errors")
+                f"{app.config['UPLOADS_DIRECTORY']}/job_errors")
 
             return redirect(url_for("expression-data.rqtl2.rqtl2_processing_status",
                                     jobid=jobid))
diff --git a/uploader/samples/views.py b/uploader/samples/views.py
index ee002ba..2a09f8e 100644
--- a/uploader/samples/views.py
+++ b/uploader/samples/views.py
@@ -138,7 +138,7 @@ def upload_samples(species_id: int, population_id: int):#pylint: disable=[too-ma
 
     try:
         samples_file = save_file(request.files["samples_file"],
-                                 Path(app.config["UPLOAD_FOLDER"]))
+                                 Path(app.config["UPLOADS_DIRECTORY"]))
     except AssertionError:
         flash("You need to provide a file with the samples data.",
               "alert-error")
@@ -172,12 +172,33 @@ def upload_samples(species_id: int, population_id: int):#pylint: disable=[too-ma
                 ] + (["--firstlineheading"] if firstlineheading else []),
                 "samples_upload",
                 extra_meta={
-                    "job_name": f"Samples Upload: {samples_file.name}"
+                    "job_name": f"Samples Upload: {samples_file.name}",
+                    "species_id": species["SpeciesId"],
+                    "population_id": population["Id"],
+                    "success_handler": (
+                        "uploader.samples.views.samples_upload_success_handler")
                 },
                 external_id=session.logged_in_user_id()),
             _jobs_db,
-            Path(f"{app.config['UPLOAD_FOLDER']}/job_errors").absolute(),
+            Path(f"{app.config['UPLOADS_DIRECTORY']}/job_errors").absolute(),
             loglevel=logging.getLevelName(
                 app.logger.getEffectiveLevel()).lower())
         return redirect(
             url_for("background-jobs.job_status", job_id=job["job_id"]))
+
+
+def samples_upload_success_handler(job):
+    """Handler for background jobs: Successful upload of samples"""
+    return return_to_samples_list_view_handler(
+        job, "Samples uploaded successfully.")
+
+
+def return_to_samples_list_view_handler(job, msg):
+    """Handler for background jobs: Return to list_samples page."""
+    flash(msg, "alert alert-success")
+    return redirect(url_for(
+        "species.populations.samples."
+        "list_samples",
+        species_id=job["metadata"]["species_id"],
+        population_id=job["metadata"]["population_id"],
+        job_id=job["job_id"]))
diff --git a/uploader/static/css/layout-common.css b/uploader/static/css/layout-common.css
index 88e580c..9c9d034 100644
--- a/uploader/static/css/layout-common.css
+++ b/uploader/static/css/layout-common.css
@@ -2,20 +2,20 @@
     box-sizing: border-box;
 }
 
-    body {
-        display: grid;
-        grid-gap: 1em;
-    }
+body {
+    display: grid;
+    grid-gap: 1em;
+}
 
-    #header {
-        margin: -0.7em; /* Fill entire length of screen */
-        /* Define layout for the children elements */
-        display: grid;
-    }
+#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;
-    }
+#header #header-nav {
+    /* Place it in the parent element */
+    grid-column-start: 1;
+    grid-column-end: 2;
+    display: flex;
+}
diff --git a/uploader/templates/background-jobs/job-status.html b/uploader/templates/background-jobs/job-status.html
index 50cf6e5..2e75c6d 100644
--- a/uploader/templates/background-jobs/job-status.html
+++ b/uploader/templates/background-jobs/job-status.html
@@ -30,12 +30,16 @@
 
 <div class="row">
   <h3 class="subheading">STDOUT</h3>
-  <pre>{{job["stdout"]}}</pre>
+  <div style="max-width: 40em; overflow: scroll">
+    <pre>{{job["stdout"]}}</pre>
+  </div>
 </div>
 
 <div class="row">
   <h3 class="subheading">STDERR</h3>
-  <pre>{{job["stderr"]}}</pre>
+  <div style="max-width: 40em; overflow: scroll">
+    <pre>{{job["stderr"]}}</pre>
+  </div>
 </div>
 
 {%endblock%}
diff --git a/uploader/templates/background-jobs/job-summary.html b/uploader/templates/background-jobs/job-summary.html
index c2c2d6b..ef9ef6c 100644
--- a/uploader/templates/background-jobs/job-summary.html
+++ b/uploader/templates/background-jobs/job-summary.html
@@ -50,12 +50,16 @@
 
 <div class="row">
   <h3 class="subheading">Script Errors and Logging</h3>
-  <pre>{{job["stderr"]}}</pre>
+  <div style="max-width: 40em; overflow: scroll">
+    <pre>{{job["stderr"]}}</pre>
+  </div>
 </div>
 
 <div class="row">
   <h3 class="subheading">Script Output</h3>
-  <pre>{{job["stdout"]}}</pre>
+  <div style="max-width: 40em; overflow: scroll">
+    <pre>{{job["stdout"]}}</pre>
+  </div>
 </div>
 {%endblock%}
 
diff --git a/uploader/templates/phenotypes/add-phenotypes-base.html b/uploader/templates/phenotypes/add-phenotypes-base.html
index c74a0fa..b86cdcf 100644
--- a/uploader/templates/phenotypes/add-phenotypes-base.html
+++ b/uploader/templates/phenotypes/add-phenotypes-base.html
@@ -84,7 +84,8 @@
                       if(pub.PubMed_ID) {
                           return `<a href="https://pubmed.ncbi.nlm.nih.gov/` +
                               `${pub.PubMed_ID}/" target="_blank" ` +
-                              `title="Link to publication on NCBI.">` +
+                              `title="Link to publication on NCBI. This will ` +
+                              `open in a new tab.">` +
                               `${pub.PubMed_ID}</a>`;
                       }
                       return "";
@@ -97,10 +98,7 @@
                       if(pub.Title) {
                           title = pub.Title
                       }
-                      return `<a href="/publications/view/${pub.Id}" ` +
-                          `target="_blank" ` +
-                          `title="Link to view publication details">` +
-                          `${title}</a>`;
+                      return title;
                   }
               },
               {
diff --git a/uploader/templates/phenotypes/base.html b/uploader/templates/phenotypes/base.html
index fe7ccd3..5959422 100644
--- a/uploader/templates/phenotypes/base.html
+++ b/uploader/templates/phenotypes/base.html
@@ -3,6 +3,7 @@
 
 {%block breadcrumbs%}
 {{super()}}
+{%if dataset%}
 <li class="breadcrumb-item">
   <a href="{{url_for('species.populations.phenotypes.view_dataset',
            species_id=species['SpeciesId'],
@@ -11,6 +12,7 @@
     {{dataset["Name"]}}
   </a>
 </li>
+{%endif%}
 {%endblock%}
 
 {%block contents%}
diff --git a/uploader/templates/phenotypes/confirm-delete-phenotypes.html b/uploader/templates/phenotypes/confirm-delete-phenotypes.html
index b59fd7b..e6d67c7 100644
--- a/uploader/templates/phenotypes/confirm-delete-phenotypes.html
+++ b/uploader/templates/phenotypes/confirm-delete-phenotypes.html
@@ -56,13 +56,16 @@
       {%for phenotype in phenotypes%}
       <tr>
         <td>
-          <input id="chk-xref-id-{{phenotype}}"
+          <input id="chk-xref-id-{{phenotype.xref_id}}"
                  name="xref_ids"
                  type="checkbox"
+                 value="{{phenotype.xref_id}}"
                  class="chk-row-select" />
         </td>
-        <td>{{phenotype}}</td>
-        <td>{{phenotype}} — Description</td>
+        <td>{{phenotype.xref_id}}</td>
+        <td>{{phenotype.Post_publication_description or
+          phenotype.Pre_publication_description or
+          phenotype.original_description}}</td>
       </tr>
       {%endfor%}
     </tbody>
@@ -166,6 +169,27 @@
       $("#btn-deselect-all-phenotypes").on("click", function(event) {
           dt.deselectAll();
       });
+
+      $("#btn-delete-phenotypes-selected").on("click", function(event) {
+          event.preventDefault();
+          form = $("#frm-delete-phenotypes-selected");
+          form.find(".dynamically-added-element").remove();
+          dt.rows({selected: true}).nodes().each(function(node, index) {
+              var xref_id = $(node)
+                  .find('input[type="checkbox"]:checked')
+                  .val();
+              var chk = $('<input type="checkbox">');
+              chk.attr("class", "dynamically-added-element");
+              chk.attr("value", xref_id);
+              chk.attr("name", "xref_ids");
+              chk.attr("style", "display: none");
+              chk.prop("checked", true);
+              form.append(chk);
+          });
+          form.append(
+              $('<input type="hidden" name="action" value="delete" />'));
+          form.submit();
+      })
   });
 </script>
 {%endblock%}
diff --git a/uploader/templates/phenotypes/create-dataset.html b/uploader/templates/phenotypes/create-dataset.html
index 19a2b34..6eced05 100644
--- a/uploader/templates/phenotypes/create-dataset.html
+++ b/uploader/templates/phenotypes/create-dataset.html
@@ -48,7 +48,8 @@
              {%else%}
              class="form-control"
              {%endif%}
-             required="required" />
+             required="required"
+             disabled="disabled" />
       <small class="form-text text-muted">
         <p>A short representative name for the dataset.</p>
         <p>Recommended: Use the population name and append "Publish" at the end.
@@ -66,7 +67,7 @@
       <input id="txt-dataset-fullname"
              name="dataset-fullname"
              type="text"
-             value="{{original_formdata.get('dataset-fullname', '')}}"
+             value="{{original_formdata.get('dataset-fullname', '') or population.Name + ' Phenotypes'}}"
              {%if errors["dataset-fullname"] is defined%}
              class="form-control danger"
              {%else%}
diff --git a/uploader/templates/phenotypes/view-dataset.html b/uploader/templates/phenotypes/view-dataset.html
index de76cbf..3bb2586 100644
--- a/uploader/templates/phenotypes/view-dataset.html
+++ b/uploader/templates/phenotypes/view-dataset.html
@@ -77,7 +77,6 @@
     </form>
   </div>
 
-  {%if view_under_construction%}
   <div class="col">
     <form id="frm-delete-phenotypes"
           method="POST"
@@ -93,7 +92,6 @@
              value="delete phenotypes" />
     </form>
   </div>
-  {%endif%}
 </div>
 
 <div class="row" style="margin-top: 0.5em;">