"""Views for expression data""" import os import uuid import mimetypes from typing import Tuple from zipfile import ZipFile, is_zipfile import jsonpickle from redis import Redis from werkzeug.utils import secure_filename from flask import (flash, request, url_for, redirect, Blueprint, current_app as app) from quality_control.errors import InvalidValue, DuplicateHeading from uploader import jobs from uploader.datautils import order_by_family from uploader.ui import make_template_renderer from uploader.authorisation import require_login from uploader.species.models import all_species, species_by_id from uploader.db_utils import with_db_connection, database_connection from uploader.population.models import (populations_by_species, population_by_species_and_id) exprdatabp = Blueprint("expression-data", __name__) render_template = make_template_renderer("expression-data") def isinvalidvalue(item): """Check whether item is of type InvalidValue""" return isinstance(item, InvalidValue) def isduplicateheading(item): """Check whether item is of type DuplicateHeading""" return isinstance(item, DuplicateHeading) def errors(rqst) -> Tuple[str, ...]: """Return a tuple of the errors found in the request `rqst`. If no error is found, then an empty tuple is returned.""" def __filetype_error__(): return ( ("Invalid file type provided.",) if rqst.form.get("filetype") not in ("average", "standard-error") else tuple()) def __file_missing_error__(): return ( ("No file was uploaded.",) if ("qc_text_file" not in rqst.files or rqst.files["qc_text_file"].filename == "") else tuple()) def __file_mimetype_error__(): text_file = rqst.files["qc_text_file"] return ( ( ("Invalid file! Expected a tab-separated-values file, or a zip " "file of the a tab-separated-values file."),) if text_file.mimetype not in ( "text/plain", "text/tab-separated-values", "application/zip") else tuple()) return ( __filetype_error__() + (__file_missing_error__() or __file_mimetype_error__())) def zip_file_errors(filepath, upload_dir) -> Tuple[str, ...]: """Check the uploaded zip file for errors.""" zfile_errors: Tuple[str, ...] = tuple() if is_zipfile(filepath): with ZipFile(filepath, "r") as zfile: infolist = zfile.infolist() if len(infolist) != 1: zfile_errors = zfile_errors + ( ("Expected exactly one (1) member file within the uploaded zip " f"file. Got {len(infolist)} member files."),) if len(infolist) == 1 and infolist[0].is_dir(): zfile_errors = zfile_errors + ( ("Expected a member text file in the uploaded zip file. Got a " "directory/folder."),) if len(infolist) == 1 and not infolist[0].is_dir(): zfile.extract(infolist[0], path=upload_dir) mime = mimetypes.guess_type(f"{upload_dir}/{infolist[0].filename}") if mime[0] != "text/tab-separated-values": zfile_errors = zfile_errors + ( ("Expected the member text file in the uploaded zip file to" " be a tab-separated file."),) return zfile_errors @exprdatabp.route("populations/expression-data", methods=["GET"]) @require_login def index(): """Display the expression data index page.""" with database_connection(app.config["SQL_URI"]) as conn: if not bool(request.args.get("species_id")): return render_template("expression-data/index.html", species=order_by_family(all_species(conn)), activelink="expression-data") species = species_by_id(conn, request.args.get("species_id")) if not bool(species): flash("Could not find species selected!", "alert-danger") return redirect(url_for("species.populations.expression-data.index")) return redirect(url_for( "species.populations.expression-data.select_population", species_id=species["SpeciesId"])) @exprdatabp.route("/populations/expression-data/select-population", methods=["GET"]) @require_login def select_population(species_id: int): """Select the expression data's population.""" with database_connection(app.config["SQL_URI"]) as conn: species = species_by_id(conn, species_id) if not bool(species): flash("No such species!", "alert-danger") return redirect(url_for("species.populations.expression-data.index")) if not bool(request.args.get("population_id")): return render_template("expression-data/select-population.html", species=species, populations=order_by_family( populations_by_species(conn, species_id), order_key="FamilyOrder"), activelink="expression-data") population = population_by_species_and_id( conn, species_id, request.args.get("population_id")) if not bool(population): flash("No such population!", "alert-danger") return redirect(url_for( "species.populations.expression-data.select_population", species_id=species_id)) return redirect(url_for("species.populations.expression-data.upload_file", species_id=species_id, population_id=population["Id"])) @exprdatabp.route("/populations//" "expression-data/upload", methods=["GET", "POST"]) @require_login def upload_file(species_id: int, population_id: int): """Enables uploading the files""" with database_connection(app.config["SQL_URI"]) as conn: species = species_by_id(conn, species_id) population = population_by_species_and_id(conn, species_id, population_id) if request.method == "GET": return render_template("expression-data/select-file.html", species=species, population=population) upload_dir = app.config["UPLOAD_FOLDER"] request_errors = errors(request) if request_errors: for error in request_errors: flash(error, "alert-danger error-expr-data") return redirect(url_for("species.populations.expression-data.upload_file")) filename = secure_filename( request.files["qc_text_file"].filename)# type: ignore[arg-type] if not os.path.exists(upload_dir): os.mkdir(upload_dir) filepath = os.path.join(upload_dir, filename) request.files["qc_text_file"].save(os.path.join(upload_dir, filename)) zip_errors = zip_file_errors(filepath, upload_dir) if zip_errors: for error in zip_errors: flash(error, "alert-danger error-expr-data") return redirect(url_for("species.populations.expression-data.index.upload_file")) return redirect(url_for("species.populations.expression-data.parse_file", species_id=species_id, population_id=population_id, filename=filename, filetype=request.form["filetype"])) @exprdatabp.route("/data-review", methods=["GET"]) @require_login def data_review(): """Provide some help on data expectations to the user.""" return render_template("expression-data/data-review.html") @exprdatabp.route( "/populations//expression-data/parse", methods=["GET"]) @require_login def parse_file(species_id: int, population_id: int): """Trigger file parsing""" _errors = False filename = request.args.get("filename") filetype = request.args.get("filetype") species = with_db_connection(lambda con: species_by_id(con, species_id)) if not bool(species): flash("No such species.", "alert-danger") _errors = True if filename is None: flash("No file provided", "alert-danger") _errors = True if filetype is None: flash("No filetype provided", "alert-danger") _errors = True if filetype not in ("average", "standard-error"): flash("Invalid filetype provided", "alert-danger") _errors = True if filename: filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename) if not os.path.exists(filepath): flash("Selected file does not exist (any longer)", "alert-danger") _errors = True if _errors: return redirect(url_for("species.populations.expression-data.upload_file")) redisurl = app.config["REDIS_URL"] with Redis.from_url(redisurl, decode_responses=True) as rconn: job = jobs.launch_job( jobs.build_file_verification_job( rconn, app.config["SQL_URI"], redisurl, species_id, filepath, filetype,# type: ignore[arg-type] app.config["JOBS_TTL_SECONDS"]), redisurl, f"{app.config['UPLOAD_FOLDER']}/job_errors") return redirect(url_for("species.populations.expression-data.parse_status", species_id=species_id, population_id=population_id, job_id=job["jobid"])) @exprdatabp.route( "/populations//expression-data/parse/" "status/", methods=["GET"]) @require_login def parse_status(species_id: int, population_id: int, job_id: str): "Retrieve the status of the job" with Redis.from_url(app.config["REDIS_URL"], decode_responses=True) as rconn: try: job = jobs.job(rconn, jobs.jobsnamespace(), job_id) except jobs.JobNotFound as _exc: 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") if os.path.exists(error_filename): stat = os.stat(error_filename) if stat.st_size > 0: return redirect(url_for("parse.fail", job_id=job_id)) job_id = job["jobid"] progress = float(job["percent"]) status = job["status"] filename = job.get("filename", "uploaded file") _errors = jsonpickle.decode( job.get("errors", jsonpickle.encode(tuple()))) if status in ("success", "aborted"): return redirect(url_for("species.populations.expression-data.results", species_id=species_id, population_id=population_id, job_id=job_id)) if status == "parse-error": return redirect(url_for("species.populations.expression-data.fail", job_id=job_id)) app.jinja_env.globals.update( isinvalidvalue=isinvalidvalue, isduplicateheading=isduplicateheading) return render_template( "expression-data/job-progress.html", job_id = job_id, job_status = status, progress = progress, message = job.get("message", ""), job_name = f"Parsing '{filename}'", errors=_errors, species=with_db_connection( lambda conn: species_by_id(conn, species_id)), population=with_db_connection( lambda conn: population_by_species_and_id( conn, species_id, population_id))) @exprdatabp.route( "/populations//expression-data/parse/" "/results", methods=["GET"]) @require_login def results(species_id: int, population_id: int, job_id: uuid.UUID): """Show results of parsing...""" with Redis.from_url(app.config["REDIS_URL"], decode_responses=True) as rconn: job = jobs.job(rconn, jobs.jobsnamespace(), job_id) if job: filename = job["filename"] _errors = jsonpickle.decode(job.get("errors", jsonpickle.encode(tuple()))) app.jinja_env.globals.update( isinvalidvalue=isinvalidvalue, isduplicateheading=isduplicateheading) return render_template( "expression-data/parse-results.html", errors=_errors, job_name = f"Parsing '{filename}'", user_aborted = job.get("user_aborted"), job_id=job["jobid"], species=with_db_connection( lambda conn: species_by_id(conn, species_id)), population=with_db_connection( lambda conn: population_by_species_and_id( conn, species_id, population_id))) return render_template("expression-data/no-such-job.html", job_id=job_id) @exprdatabp.route( "/populations//expression-data/parse/" "/fail", methods=["GET"]) @require_login def fail(species_id: int, population_id: int, job_id: str): """Handle parsing failure""" with Redis.from_url(app.config["REDIS_URL"], decode_responses=True) as rconn: job = jobs.job(rconn, jobs.jobsnamespace(), job_id) if job: error_filename = jobs.error_filename( job_id, f"{app.config['UPLOAD_FOLDER']}/job_errors") if os.path.exists(error_filename): stat = os.stat(error_filename) if stat.st_size > 0: return render_template( "worker_failure.html", job_id=job_id) return render_template("parse_failure.html", job=job) return render_template("expression-data/no-such-job.html", **with_db_connection(lambda conn: { "species_id": species_by_id(conn, species_id), "population_id": population_by_species_and_id( conn, species_id, population_id)}), job_id=job_id) @exprdatabp.route( "/populations//expression-data/parse/" "abort", methods=["POST"]) @require_login def abort(species_id: int, population_id: int): """Handle user request to abort file processing""" job_id = request.form["job_id"] with Redis.from_url(app.config["REDIS_URL"], decode_responses=True) as rconn: job = jobs.job(rconn, jobs.jobsnamespace(), job_id) if job: rconn.hset(name=jobs.job_key(jobs.jobsnamespace(), job_id), key="user_aborted", value=int(True)) return redirect(url_for("species.populations.expression-data.parse_status", species_id=species_id, population_id=population_id, job_id=job_id))