diff options
Diffstat (limited to 'uploader')
63 files changed, 2397 insertions, 781 deletions
diff --git a/uploader/__init__.py b/uploader/__init__.py index 8b49ad5..7425b38 100644 --- a/uploader/__init__.py +++ b/uploader/__init__.py @@ -22,6 +22,7 @@ from .files.views import files from .species import speciesbp from .publications import pubbp from .oauth2.views import oauth2 +from .flask_extensions import url_for from .expression_data import exprdatabp from .errors import register_error_handlers from .background_jobs import background_jobs_bp @@ -64,17 +65,13 @@ def setup_logging(app: Flask) -> Flask: "SERVER_SOFTWARE", "").split('/') return __log_gunicorn__(app) if bool(software) else __log_dev__(app) -def setup_modules_logging(app_logger): +def setup_modules_logging(app_logger, modules): """Setup module-level loggers to the same log-level as the application.""" loglevel = logging.getLevelName(app_logger.getEffectiveLevel()) - - def __setup__(logger_name): - _logger = logging.getLogger(logger_name) + for module in modules: + _logger = logging.getLogger(module) _logger.setLevel(loglevel) - __setup__("uploader.publications.models") - __setup__("uploader.publications.datatables") - def create_app(config: Optional[dict] = None): """The application factory. @@ -111,15 +108,24 @@ def create_app(config: Optional[dict] = None): default_timeout=int(app.config["SESSION_FILESYSTEM_CACHE_TIMEOUT"])) setup_logging(app) - setup_modules_logging(app.logger) + setup_modules_logging(app.logger, ( + "uploader.base_routes", + "uploader.flask_extensions", + "uploader.publications.models", + "uploader.publications.datatables", + "uploader.phenotypes.models")) # setup jinja2 symbols - app.add_template_global(lambda : request.url, name="request_url") + app.add_template_global(user_logged_in) + app.add_template_global(url_for, name="url_for") app.add_template_global(authserver_authorise_uri) + app.add_template_global(lambda : request.url, name="request_url") app.add_template_global(lambda: app.config["GN2_SERVER_URL"], name="gn2server_uri") - app.add_template_global(user_logged_in) - app.add_template_global(lambda : session.user_details()["email"], name="user_email") + app.add_template_global(lambda : session.user_details()["email"], + name="user_email") + app.add_template_global(lambda: app.config["FEATURE_FLAGS_HTTP"], + name="http_feature_flags") Session(app) diff --git a/uploader/background_jobs.py b/uploader/background_jobs.py index dc9f837..d33c498 100644 --- a/uploader/background_jobs.py +++ b/uploader/background_jobs.py @@ -56,7 +56,7 @@ def register_job_handlers(job: str): return getattr(module, _parts[-1]) metadata = job["metadata"] - if metadata["success_handler"]: + if metadata.get("success_handler"): _success_handler = __load_handler__(metadata["success_handler"]) try: _error_handler = __load_handler__(metadata["error_handler"]) @@ -76,8 +76,7 @@ def handler(job: dict, handler_type: str) -> HandlerType: ).get(handler_type) if bool(_handler): return _handler(job) - raise Exception(# pylint: disable=[broad-exception-raised] - f"No '{handler_type}' handler registered for job type: {_job_type}") + return render_template("background-jobs/default-success-page.html", job=job) error_handler = partial(handler, handler_type="error") diff --git a/uploader/base_routes.py b/uploader/base_routes.py index 74a3b90..80a15a0 100644 --- a/uploader/base_routes.py +++ b/uploader/base_routes.py @@ -1,15 +1,23 @@ """Basic routes required for all pages""" import os +import logging from urllib.parse import urljoin -from flask import (Blueprint, +from gn_libs.mysqldb import database_connection +from flask import (flash, + request, + redirect, + Blueprint, current_app as app, send_from_directory) +from uploader.flask_extensions import url_for from uploader.ui import make_template_renderer from uploader.oauth2.client import user_logged_in +from uploader.species.models import all_species, species_by_id base = Blueprint("base", __name__) +logger = logging.getLogger(__name__) render_template = make_template_renderer("home") @@ -24,9 +32,29 @@ def favicon(): @base.route("/", methods=["GET"]) def index(): """Load the landing page""" - return render_template("index.html" if user_logged_in() else "login.html", - gn2server_intro=urljoin(app.config["GN2_SERVER_URL"], - "/intro")) + streamlined_ui = request.args.get("streamlined_ui") + if not bool(streamlined_ui):# TODO: Remove this section + return render_template( + "index.html" if user_logged_in() else "login.html", + gn2server_intro=urljoin(app.config["GN2_SERVER_URL"], "/intro")) + + with database_connection(app.config["SQL_URI"]) as conn: + print("We found a species ID. Processing...") + if not bool(request.args.get("species_id")): + return render_template( + "sui-index.html",# TODO: Rename: sui-index.html, sui_base.html + gn2server_intro=urljoin(app.config["GN2_SERVER_URL"], "/intro"), + species=all_species(conn), + streamlined_ui=streamlined_ui) + + species = species_by_id(conn, request.args.get("species_id")) + if not bool(species): + flash("Selected species was not found!", "alert alert-danger") + return redirect(url_for("base.index", streamlined_ui=streamlined_ui)) + + return redirect(url_for("species.view_species", + species_id=species["SpeciesId"])) + def appenv(): """Get app's guix environment path.""" diff --git a/uploader/datautils.py b/uploader/datautils.py index 46a55c4..d132c42 100644 --- a/uploader/datautils.py +++ b/uploader/datautils.py @@ -1,5 +1,7 @@ """Generic data utilities: Rename module.""" import math +import json +import base64 from functools import reduce from typing import Union, Sequence @@ -36,3 +38,13 @@ def safe_int(val: Union[str, int, float]) -> int: return int(val) except ValueError: return 0 + + +def base64_encode_dict(dct: dict, **kwargs) -> bytes: + """Base64 encode a dictionary. Takes the same keywords as `json.dumps` function.""" + return base64.urlsafe_b64encode(json.dumps(dct, **kwargs).encode("utf-8")) + + +def base64_decode_to_dict(value: str, **kwargs) -> dict: + """Base64 encode a dictionary. Takes the same keywords as `json.loads` function.""" + return json.loads(base64.urlsafe_b64decode(value), **kwargs) diff --git a/uploader/db/datasets.py b/uploader/db/datasets.py index 767ec41..4b263f5 100644 --- a/uploader/db/datasets.py +++ b/uploader/db/datasets.py @@ -53,7 +53,7 @@ def probeset_study_by_id(conn: mdb.Connection, studyid) -> Optional[dict]: _study = cursor.fetchone() return dict(_study) if bool(_study) else None -def probeset_create_study(conn: mdb.Connection,#pylint: disable=[too-many-arguments] +def probeset_create_study(conn: mdb.Connection,#pylint: disable=[too-many-arguments, too-many-positional-arguments] populationid: int, platformid: int, tissueid: int, @@ -87,7 +87,7 @@ def probeset_create_study(conn: mdb.Connection,#pylint: disable=[too-many-argume (studyid, studyid)) return {**studydata, "studyid": studyid} -def probeset_create_dataset(conn: mdb.Connection,#pylint: disable=[too-many-arguments] +def probeset_create_dataset(conn: mdb.Connection,#pylint: disable=[too-many-arguments, too-many-positional-arguments] studyid: int, averageid: int, datasetname: str, diff --git a/uploader/default_settings.py b/uploader/default_settings.py index 1136ff8..bb3a967 100644 --- a/uploader/default_settings.py +++ b/uploader/default_settings.py @@ -2,7 +2,6 @@ The default configuration file. The values here should be overridden in the actual configuration file used for the production and staging systems. """ -import hashlib LOG_LEVEL = "WARNING" SECRET_KEY = b"<Please! Please! Please! Change This!>" @@ -25,8 +24,12 @@ SESSION_FILESYSTEM_CACHE_PATH = "./flask_session" SESSION_FILESYSTEM_CACHE_THRESHOLD = 500 SESSION_FILESYSTEM_CACHE_TIMEOUT = 300 SESSION_FILESYSTEM_CACHE_MODE = 0o600 -SESSION_FILESYSTEM_CACHE_HASH_METHOD = hashlib.md5 +SESSION_FILESYSTEM_CACHE_HASH_METHOD = None # default: hashlib.md5 ## --- END: Settings for CacheLib session type --- ## JWKS_ROTATION_AGE_DAYS = 7 # Days (from creation) to keep a JWK in use. JWKS_DELETION_AGE_DAYS = 14 # Days (from creation) to keep a JWK around before deleting it. + + +## --- Feature flags --- +FEATURE_FLAGS_HTTP = [] diff --git a/uploader/expression_data/views.py b/uploader/expression_data/views.py index 7629f3e..0b318b7 100644 --- a/uploader/expression_data/views.py +++ b/uploader/expression_data/views.py @@ -11,7 +11,6 @@ from werkzeug.utils import secure_filename from gn_libs.mysqldb import database_connection from flask import (flash, request, - url_for, redirect, Blueprint, current_app as app) @@ -19,6 +18,7 @@ from flask import (flash, from quality_control.errors import InvalidValue, DuplicateHeading from uploader import jobs +from uploader.flask_extensions import url_for from uploader.datautils import order_by_family from uploader.ui import make_template_renderer from uploader.authorisation import require_login diff --git a/uploader/flask_extensions.py b/uploader/flask_extensions.py new file mode 100644 index 0000000..30fbad7 --- /dev/null +++ b/uploader/flask_extensions.py @@ -0,0 +1,33 @@ +"""Custom extensions to the default flask functions/classes.""" +import logging +from typing import Any, Optional + +from flask import (request, current_app as app, url_for as flask_url_for) + +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.""" + flags = {} + for flag in app.config["FEATURE_FLAGS_HTTP"]: + flag_value = (request.args.get(flag) or request.form.get(flag) or "").strip() + if bool(flag_value): + flags[flag] = flag_value + continue + continue + + logger.debug("HTTP FEATURE FLAGS: %s, other variables: %s", flags, values) + return flask_url_for(endpoint=endpoint, + _anchor=_anchor, + _method=_method, + _scheme=_scheme, + _external=_external, + **values, + **flags) diff --git a/uploader/genotypes/views.py b/uploader/genotypes/views.py index 54c2444..d991614 100644 --- a/uploader/genotypes/views.py +++ b/uploader/genotypes/views.py @@ -3,12 +3,12 @@ from MySQLdb.cursors import DictCursor from gn_libs.mysqldb import database_connection from flask import (flash, request, - url_for, redirect, Blueprint, render_template, current_app as app) +from uploader.flask_extensions import url_for from uploader.ui import make_template_renderer from uploader.oauth2.client import oauth2_post from uploader.authorisation import require_login diff --git a/uploader/oauth2/client.py b/uploader/oauth2/client.py index 12fbf80..b94a044 100644 --- a/uploader/oauth2/client.py +++ b/uploader/oauth2/client.py @@ -43,7 +43,8 @@ def __fetch_auth_server_jwks__() -> KeySet: return KeySet([ JsonWebKey.import_key(key) for key in requests.get( - urljoin(authserver_uri(), "auth/public-jwks") + urljoin(authserver_uri(), "auth/public-jwks"), + timeout=(9.13, 20) ).json()["jwks"]]) diff --git a/uploader/oauth2/tokens.py b/uploader/oauth2/tokens.py new file mode 100644 index 0000000..eb650f6 --- /dev/null +++ b/uploader/oauth2/tokens.py @@ -0,0 +1,47 @@ +"""Utilities for dealing with tokens.""" +import uuid +from typing import Union +from urllib.parse import urljoin +from datetime import datetime, timedelta + +from authlib.jose import jwt +from flask import current_app as app + +from uploader import monadic_requests as mrequests + +from . import jwks +from .client import (SCOPE, authserver_uri, oauth2_clientid) + + +def request_token(token_uri: str, user_id: Union[uuid.UUID, str], **kwargs): + """Request token from the auth server.""" + issued = datetime.now() + jwtkey = jwks.newest_jwk_with_rotation( + jwks.jwks_directory(app, "UPLOADER_SECRETS"), + int(app.config["JWKS_ROTATION_AGE_DAYS"])) + _mins2expiry = kwargs.get("minutes_to_expiry", 5) + return mrequests.post( + token_uri, + json={ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "scope": kwargs.get("scope", SCOPE), + "assertion": jwt.encode( + header={ + "alg": "RS256", + "typ": "JWT", + "kid": jwtkey.as_dict()["kid"] + }, + payload={ + "iss": str(oauth2_clientid()), + "sub": str(user_id), + "aud": urljoin(authserver_uri(), "auth/token"), + "exp": (issued + timedelta(minutes=_mins2expiry)).timestamp(), + "nbf": int(issued.timestamp()), + "iat": int(issued.timestamp()), + "jti": str(uuid.uuid4()) + }, + key=jwtkey).decode("utf8"), + "client_id": oauth2_clientid(), + **kwargs.get("extra_params", {}) + } + ) diff --git a/uploader/oauth2/views.py b/uploader/oauth2/views.py index db4ef61..05f8542 100644 --- a/uploader/oauth2/views.py +++ b/uploader/oauth2/views.py @@ -1,26 +1,22 @@ """Views for OAuth2 related functionality.""" -import uuid -from datetime import datetime, timedelta from urllib.parse import urljoin, urlparse, urlunparse -from authlib.jose import jwt from flask import ( flash, jsonify, - url_for, request, redirect, Blueprint, current_app as app) from uploader import session +from uploader.flask_extensions import url_for from uploader import monadic_requests as mrequests from uploader.monadic_requests import make_error_handler from . import jwks +from .tokens import request_token from .client import ( - SCOPE, - oauth2_get, user_logged_in, authserver_uri, oauth2_clientid, @@ -33,8 +29,8 @@ oauth2 = Blueprint("oauth2", __name__) @oauth2.route("/code") def authorisation_code(): """Receive authorisation code from auth server and use it to get token.""" - def __process_error__(resp_or_exception): - app.logger.debug("ERROR: (%s)", resp_or_exception) + def __process_error__(error_response): + app.logger.debug("ERROR: (%s)", error_response.content) flash("There was an error retrieving the authorisation token.", "alert alert-danger") return redirect("/") @@ -60,36 +56,14 @@ def authorisation_code(): return redirect("/") baseurl = urlparse(request.base_url, scheme=request.scheme) - issued = datetime.now() - jwtkey = jwks.newest_jwk_with_rotation( - jwks.jwks_directory(app, "UPLOADER_SECRETS"), - int(app.config["JWKS_ROTATION_AGE_DAYS"])) - return mrequests.post( - urljoin(authserver_uri(), "auth/token"), - json={ - "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + return request_token( + token_uri=urljoin(authserver_uri(), "auth/token"), + user_id=request.args["user_id"], + extra_params={ "code": code, - "scope": SCOPE, "redirect_uri": urljoin( urlunparse(baseurl), url_for("oauth2.authorisation_code")), - "assertion": jwt.encode( - header={ - "alg": "RS256", - "typ": "JWT", - "kid": jwtkey.as_dict()["kid"] - }, - payload={ - "iss": str(oauth2_clientid()), - "sub": request.args["user_id"], - "aud": urljoin(authserver_uri(),"auth/token"), - "exp": (issued + timedelta(minutes=5)).timestamp(), - "nbf": int(issued.timestamp()), - "iat": int(issued.timestamp()), - "jti": str(uuid.uuid4()) - }, - key=jwtkey).decode("utf8"), - "client_id": oauth2_clientid() }).either(__process_error__, __success__) @oauth2.route("/public-jwks") diff --git a/uploader/phenotypes/models.py b/uploader/phenotypes/models.py index c2aeebf..af06376 100644 --- a/uploader/phenotypes/models.py +++ b/uploader/phenotypes/models.py @@ -4,14 +4,15 @@ import tempfile from pathlib import Path from functools import reduce from datetime import datetime -from typing import Optional, Iterable +from typing import Union, Optional, Iterable import MySQLdb as mdb from MySQLdb.cursors import Cursor, DictCursor -from functional_tools import take from gn_libs.mysqldb import debug_query +from functional_tools import take + logger = logging.getLogger(__name__) @@ -91,7 +92,8 @@ def dataset_phenotypes(conn: mdb.Connection, limit: Optional[int] = None) -> tuple[dict, ...]: """Fetch the actual phenotypes.""" _query = ( - "SELECT pheno.*, pxr.Id AS xref_id, pxr.InbredSetId, ist.InbredSetCode FROM Phenotype AS pheno " + "SELECT pheno.*, pxr.Id AS xref_id, pxr.InbredSetId, ist.InbredSetCode " + "FROM Phenotype AS pheno " "INNER JOIN PublishXRef AS pxr ON pheno.Id=pxr.PhenotypeId " "INNER JOIN PublishFreeze AS pf ON pxr.InbredSetId=pf.InbredSetId " "INNER JOIN InbredSet AS ist ON pf.InbredSetId=ist.Id " @@ -217,7 +219,7 @@ def phenotype_by_id( ).values()) } if bool(_pheno) and len(_pheno.keys()) > 1: - raise Exception( + raise Exception(# pylint: disable=[broad-exception-raised] "We found more than one phenotype with the same identifier!") return None @@ -246,6 +248,59 @@ def phenotypes_data(conn: mdb.Connection, return tuple(dict(row) for row in cursor.fetchall()) +def phenotypes_vector_data( + conn: mdb.Connection, + species_id: int, + population_id: int, + xref_ids: tuple[int, ...] = tuple(), + offset: int = 0, + limit: Optional[int] = None +) -> dict[tuple[int, int, int]: dict[str, Union[int,float]]]: + """Retrieve the vector data values for traits in the database.""" + _params = (species_id, population_id) + _query = ("SELECT " + "Species.Id AS SpeciesId, iset.Id AS InbredSetId, " + "pxr.Id AS xref_id, pdata.*, Strain.Id AS StrainId, " + "Strain.Name AS StrainName " + "FROM " + "Species INNER JOIN InbredSet AS iset " + "ON Species.Id=iset.SpeciesId " + "INNER JOIN PublishXRef AS pxr " + "ON iset.Id=pxr.InbredSetId " + "INNER JOIN PublishData AS pdata " + "ON pxr.DataId=pdata.Id " + "INNER JOIN Strain " + "ON pdata.StrainId=Strain.Id " + "WHERE Species.Id=%s AND iset.Id=%s") + if len(xref_ids) > 0: + _paramstr = ", ".join(["%s"] * len(xref_ids)) + _query = _query + f" AND pxr.Id IN ({_paramstr})" + _params = _params + xref_ids + + def __organise__(acc, row): + _rowid = (species_id, population_id, row["xref_id"]) + _phenodata = { + **acc.get( + _rowid, { + "species_id": species_id, + "population_id": population_id, + "xref_id": row["xref_id"] + }), + row["StrainName"]: row["value"] + } + return { + **acc, + _rowid: _phenodata + } + + with conn.cursor(cursorclass=DictCursor) as cursor: + cursor.execute( + _query + (f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""), + _params) + debug_query(cursor, logger) + return reduce(__organise__, cursor.fetchall(), {}) + + def save_new_dataset(cursor: Cursor, population_id: int, dataset_name: str, @@ -302,33 +357,146 @@ def phenotypes_data_by_ids( reduce(__organise_by_phenotype__, cursor.fetchall(), {}).values()) -def create_new_phenotypes(conn: mdb.Connection, - phenotypes: Iterable[dict]) -> tuple[dict, ...]: - """Add entirely new phenotypes to the database.""" +def __pre_process_phenotype_data__(row): + _desc = row.get("description", "") + _pre_pub_desc = row.get("pre_publication_description", _desc) + _orig_desc = row.get("original_description", _desc) + _post_pub_desc = row.get("post_publication_description", _orig_desc) + _pre_pub_abbr = row.get("pre_publication_abbreviation", row["id"]) + _post_pub_abbr = row.get("post_publication_abbreviation", _pre_pub_abbr) + return { + "pre_publication_description": _pre_pub_desc, + "post_publication_description": _post_pub_desc, + "original_description": _orig_desc, + "units": row["units"], + "pre_publication_abbreviation": _pre_pub_abbr, + "post_publication_abbreviation": _post_pub_abbr + } + + +def create_new_phenotypes(# pylint: disable=[too-many-locals] + conn: mdb.Connection, + population_id: int, + publication_id: int, + phenotypes: Iterable[dict] +) -> tuple[dict, ...]: + """Add entirely new phenotypes to the database. WARNING: Not thread-safe.""" _phenos = tuple() with conn.cursor(cursorclass=DictCursor) as cursor: + def make_next_id(idcol, table): + cursor.execute(f"SELECT MAX({idcol}) AS last_id FROM {table}") + _last_id = int(cursor.fetchone()["last_id"]) + def __next_id__(): + _next_id = _last_id + 1 + while True: + yield _next_id + _next_id = _next_id + 1 + + return __next_id__ + + ### Bottleneck: Everything below makes this function not ### + ### thread-safe because we have to retrieve the last IDs from ### + ### the database and increment those to compute the next IDs. ### + ### This is an unfortunate result from the current schema that ### + ### has a cross-reference table that requires that a phenotype ### + ### be linked to an existing publication, and have data IDs to ### + ### link to that phenotype's data. ### + ### The fact that the IDs are sequential also compounds the ### + ### bottleneck. ### + ### + ### For extra safety, ensure the following tables are locked ### + ### for `WRITE`: ### + ### - PublishXRef ### + ### - Phenotype ### + ### - PublishXRef ### + __next_xref_id = make_next_id("Id", "PublishXRef")() + __next_pheno_id__ = make_next_id("Id", "Phenotype")() + __next_data_id__ = make_next_id("DataId", "PublishXRef")() + + def __build_params_and_prepubabbrevs__(acc, row): + processed = __pre_process_phenotype_data__(row) + return ( + acc[0] + ({ + **processed, + "population_id": population_id, + "publication_id": publication_id, + "phenotype_id": next(__next_pheno_id__), + "xref_id": next(__next_xref_id), + "data_id": next(__next_data_id__) + },), + acc[1] + (processed["pre_publication_abbreviation"],)) while True: batch = take(phenotypes, 1000) if len(batch) == 0: break + params, abbrevs = reduce(__build_params_and_prepubabbrevs__, + batch, + (tuple(), tuple())) + # Check for uniqueness for all "Pre_publication_description" values + abbrevs_paramsstr = ", ".join(["%s"] * len(abbrevs)) + _query = ("SELECT PublishXRef.PhenotypeId, Phenotype.* " + "FROM PublishXRef " + "INNER JOIN Phenotype " + "ON PublishXRef.PhenotypeId=Phenotype.Id " + "WHERE PublishXRef.InbredSetId=%s " + "AND Phenotype.Pre_publication_abbreviation IN " + f"({abbrevs_paramsstr})") + cursor.execute(_query, + ((population_id,) + abbrevs)) + existing = tuple(row["Pre_publication_abbreviation"] + for row in cursor.fetchall()) + if len(existing) > 0: + # Narrow this exception, perhaps? + raise Exception(# pylint: disable=[broad-exception-raised] + "Found already existing phenotypes with the following " + "'Pre-publication abbreviations':\n\t" + "\n\t".join(f"* {item}" for item in existing)) + + cursor.executemany( + ( + "INSERT INTO " + "Phenotype(" + "Id, " + "Pre_publication_description, " + "Post_publication_description, " + "Original_description, " + "Units, " + "Pre_publication_abbreviation, " + "Post_publication_abbreviation, " + "Authorized_Users" + ")" + "VALUES (" + "%(phenotype_id)s, " + "%(pre_publication_description)s, " + "%(post_publication_description)s, " + "%(original_description)s, " + "%(units)s, " + "%(pre_publication_abbreviation)s, " + "%(post_publication_abbreviation)s, " + "'robwilliams'" + ")"), + params) + _comments = f"Created at {datetime.now().isoformat()}" cursor.executemany( - ("INSERT INTO " - "Phenotype(Pre_publication_description, Original_description, Units, Authorized_Users) " - "VALUES (%s, %s, %s, 'robwilliams')"), - tuple((row["id"], row["description"], row["units"]) - for row in batch)) - paramstr = ", ".join(["%s"] * len(batch)) - cursor.execute( - "SELECT * FROM Phenotype WHERE Pre_publication_description IN " - f"({paramstr})", - tuple(item["id"] for item in batch)) - _phenos = _phenos + tuple({ - "phenotype_id": row["Id"], - "id": row["Pre_publication_description"], - "description": row["Original_description"], - "units": row["Units"] - } for row in cursor.fetchall()) + ("INSERT INTO PublishXRef(" + "Id, " + "InbredSetId, " + "PhenotypeId, " + "PublicationId, " + "DataId, " + "comments" + ")" + "VALUES(" + "%(xref_id)s, " + "%(population_id)s, " + "%(phenotype_id)s, " + "%(publication_id)s, " + "%(data_id)s, " + f"'{_comments}'" + ")"), + params) + _phenos = _phenos + params return _phenos diff --git a/uploader/phenotypes/views.py b/uploader/phenotypes/views.py index 70f375e..2afd8a3 100644 --- a/uploader/phenotypes/views.py +++ b/uploader/phenotypes/views.py @@ -1,61 +1,53 @@ -"""Views handling ('classical') phenotypes.""" +"""Views handling ('classical') phenotypes."""# pylint: disable=[too-many-lines] import sys -import csv import uuid import json import logging -import tempfile from typing import Any from pathlib import Path from zipfile import ZipFile -from urllib.parse import urljoin from functools import wraps, reduce -from logging import INFO, ERROR, DEBUG, FATAL, CRITICAL, WARNING +from urllib.parse import urljoin, urlparse, ParseResult, urlunparse, urlencode import datetime -from datetime import timedelta from redis import Redis from pymonad.either import Left from requests.models import Response from MySQLdb.cursors import DictCursor -from werkzeug.utils import secure_filename from gn_libs import sqlite3 from gn_libs import jobs as gnlibs_jobs from gn_libs.jobs.jobs import JobNotFound from gn_libs.mysqldb import database_connection -from gn_libs import monadic_requests as mrequests -from authlib.jose import jwt from flask import (flash, request, - url_for, jsonify, redirect, Blueprint, - send_file, current_app as app) -# from r_qtl import r_qtl2 as rqtl2 from r_qtl import r_qtl2_qc as rqc from r_qtl import exceptions as rqe from uploader import jobs from uploader import session -from uploader.files import save_file#, fullpath +from uploader.files import save_file +from uploader.flask_extensions import url_for from uploader.ui import make_template_renderer from uploader.oauth2.client import oauth2_post +from uploader.oauth2.tokens import request_token from uploader.authorisation import require_login -from uploader.oauth2 import jwks, client as oauth2client +from uploader.oauth2 import client as oauth2client +from uploader.route_utils import build_next_argument from uploader.route_utils import generic_select_population from uploader.datautils import safe_int, enumerate_sequence from uploader.species.models import all_species, species_by_id from uploader.monadic_requests import make_either_error_handler from uploader.publications.models import fetch_publication_by_id from uploader.request_checks import with_species, with_population -from uploader.samples.models import samples_by_species_and_population from uploader.input_validation import (encode_errors, decode_errors, is_valid_representative_name) @@ -66,9 +58,9 @@ from .models import (dataset_by_id, save_new_dataset, dataset_phenotypes, datasets_by_population, - phenotypes_data_by_ids, phenotype_publication_data) +logger = logging.getLogger(__name__) phenotypesbp = Blueprint("phenotypes", __name__) render_template = make_template_renderer("phenotypes") @@ -242,11 +234,6 @@ def view_phenotype(# pylint: disable=[unused-argument] population["Id"], dataset["Id"], xref_id) - def __non_empty__(value) -> bool: - if isinstance(value, str): - return value.strip() != "" - return bool(value) - return render_template( "phenotypes/view-phenotype.html", species=species, @@ -255,19 +242,14 @@ def view_phenotype(# pylint: disable=[unused-argument] xref_id=xref_id, phenotype=phenotype, has_se=any(bool(item.get("error")) for item in phenotype["data"]), - publish_data={ - key.replace("_", " "): val - for key,val in - (phenotype_publication_data(conn, phenotype["Id"]) or {}).items() - if (key in ("PubMed_ID", "Authors", "Title", "Journal") - and __non_empty__(val)) - }, - privileges=(privileges - ### For demo! Do not commit this part - + ("group:resource:edit-resource", - "group:resource:delete-resource",) - ### END: For demo! Do not commit this part - ), + publication=(phenotype_publication_data(conn, phenotype["Id"]) or {}), + privileges=privileges, + next=build_next_argument( + uri="species.populations.phenotypes.view_phenotype", + species_id=species["SpeciesId"], + population_id=population["Id"], + dataset_id=dataset["Id"], + xref_id=xref_id), activelink="view-phenotype") def __fail__(error): @@ -366,7 +348,6 @@ def process_phenotypes_rqtl2_bundle(error_uri): def process_phenotypes_individual_files(error_uri): """Process the uploaded individual files.""" form = request.form - _transposed = (form.get("file-transposed") or "off") == "on" cdata = { "sep": form["file-separator"], "comment.char": form["file-comment-character"], @@ -375,10 +356,17 @@ def process_phenotypes_individual_files(error_uri): bundlepath = Path(app.config["UPLOAD_FOLDER"], f"{str(uuid.uuid4()).replace('-', '')}.zip") with ZipFile(bundlepath,mode="w") as zfile: - for rqtlkey, formkey in (("phenocovar", "phenotype-descriptions"), - ("pheno", "phenotype-data"), - ("phenose", "phenotype-se"), - ("phenonum", "phenotype-n")): + for rqtlkey, formkey, _type in ( + ("phenocovar", "phenotype-descriptions", "mandatory"), + ("pheno", "phenotype-data", "mandatory"), + ("phenose", "phenotype-se", "optional"), + ("phenonum", "phenotype-n", "optional")): + if _type == "optional" and not bool(form.get(formkey)): + continue # skip if an optional key does not exist. + + cdata[f"{rqtlkey}_transposed"] = ( + (form.get(f"{formkey}-transposed") or "off") == "on") + if form.get("resumable-upload", False): # Chunked upload of large files was used filedata = json.loads(form[formkey]) @@ -387,7 +375,7 @@ def process_phenotypes_individual_files(error_uri): arcname=filedata["original-name"]) cdata[rqtlkey] = cdata.get(rqtlkey, []) + [filedata["original-name"]] else: - # TODO: Check this path: fix any bugs. + # T0DO: Check this path: fix any bugs. _sentfile = request.files[formkey] if not bool(_sentfile): flash(f"Expected file ('{formkey}') was not provided.", @@ -401,8 +389,6 @@ def process_phenotypes_individual_files(error_uri): arcname=filepath.name) cdata[rqtlkey] = cdata.get(rqtlkey, []) + [filepath.name] - if rqtlkey not in ("phenocovar",): - cdata[f"{rqtlkey}_transposed"] = _transposed zfile.writestr("control_data.json", data=json.dumps(cdata, indent=2)) @@ -428,10 +414,7 @@ def add_phenotypes(species: dict, population: dict, dataset: dict, **kwargs):# p dataset_id=dataset["Id"])) _redisuri = app.config["REDIS_URL"] _sqluri = app.config["SQL_URI"] - with (Redis.from_url(_redisuri, decode_responses=True) as rconn, - # database_connection(_sqluri) as conn, - # conn.cursor(cursorclass=DictCursor) as cursor - ): + with Redis.from_url(_redisuri, decode_responses=True) as rconn: if request.method == "GET": today = datetime.date.today() return render_template( @@ -466,7 +449,6 @@ def add_phenotypes(species: dict, population: dict, dataset: dict, **kwargs):# p [sys.executable, "-m", "scripts.rqtl2.phenotypes_qc", _sqluri, _redisuri, _namespace, str(_jobid), str(species["SpeciesId"]), str(population["Id"]), - # str(dataset["Id"]), str(phenobundle), "--loglevel", logging.getLevelName( @@ -644,12 +626,16 @@ def load_data_to_database( **kwargs ):# pylint: disable=[unused-argument] """Load the data from the given QC job into the database.""" - jobs_db = app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"] + _jobs_db = app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"] with (Redis.from_url(app.config["REDIS_URL"], decode_responses=True) as rconn, - sqlite3.connection(jobs_db) as conn): + sqlite3.connection(_jobs_db) as conn): + # T0DO: Maybe break the connection between the jobs here, pass: + # - the bundle name (rebuild the full path here.) + # - publication details, where separate + # - details about the files: e.g. total lines, etc qc_job = jobs.job(rconn, jobs.jobsnamespace(), request.form["data-qc-job-id"]) _meta = json.loads(qc_job["job-metadata"]) - load_job_id = uuid.uuid4() + _load_job_id = uuid.uuid4() _loglevel = logging.getLevelName(app.logger.getEffectiveLevel()).lower() command = [ sys.executable, @@ -657,8 +643,8 @@ def load_data_to_database( "-m", "scripts.load_phenotypes_to_db", app.config["SQL_URI"], - jobs_db, - str(load_job_id), + _jobs_db, + str(_load_job_id), "--log-level", _loglevel ] @@ -671,41 +657,14 @@ def load_data_to_database( return redirect(url_for( "background-jobs.job_status", job_id=load_job["job_id"])) - issued = datetime.datetime.now() - jwtkey = jwks.newest_jwk_with_rotation( - jwks.jwks_directory(app, "UPLOADER_SECRETS"), - int(app.config["JWKS_ROTATION_AGE_DAYS"])) - return mrequests.post( - urljoin(oauth2client.authserver_uri(), "auth/token"), - json={ - "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", - "scope": oauth2client.SCOPE, - "assertion": jwt.encode( - header={ - "alg": "RS256", - "typ": "JWT", - "kid": jwtkey.as_dict()["kid"] - }, - payload={ - "iss": str(oauth2client.oauth2_clientid()), - "sub": str(session.user_details()["user_id"]), - "aud": urljoin(oauth2client.authserver_uri(), - "auth/token"), - # TODO: Update expiry time once fix is implemented in - # auth server. - "exp": (issued + timedelta(minutes=5)).timestamp(), - "nbf": int(issued.timestamp()), - "iat": int(issued.timestamp()), - "jti": str(uuid.uuid4()) - }, - key=jwtkey).decode("utf8"), - "client_id": oauth2client.oauth2_clientid() - } + 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( conn, - load_job_id, + _load_job_id, command, "load-new-phenotypes-data", extra_meta={ @@ -723,7 +682,7 @@ def load_data_to_database( ).then( lambda job: gnlibs_jobs.launch_job( job, - jobs_db, + _jobs_db, Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"), worker_manager="gn_libs.jobs.launcher", loglevel=_loglevel) @@ -882,12 +841,7 @@ def edit_phenotype_data(# pylint: disable=[unused-argument] def __render__(**kwargs): processed_kwargs = { **kwargs, - "privileges": (kwargs.get("privileges", tuple()) - ### For demo! Do not commit this part - + ("group:resource:edit-resource", - "group:resource:delete-resource",) - ### END: For demo! Do not commit this part - ) + "privileges": kwargs.get("privileges", tuple()) } return render_template( "phenotypes/edit-phenotype.html", @@ -987,181 +941,211 @@ def edit_phenotype_data(# pylint: disable=[unused-argument] xref_id=xref_id)) -def process_phenotype_data_for_download(pheno: dict) -> dict: - """Sanitise data for download.""" - return { - "UniqueIdentifier": f"phId:{pheno['Id']}::xrId:{pheno['xref_id']}", - **{ - key: val for key, val in pheno.items() - if key not in ("Id", "xref_id", "data", "Units") - }, - **{ - data_item["StrainName"]: data_item["value"] - for data_item in pheno.get("data", {}).values() - } - } - - -BULK_EDIT_COMMON_FIELDNAMES = [ - "UniqueIdentifier", - "Post_publication_description", - "Pre_publication_abbreviation", - "Pre_publication_description", - "Original_description", - "Post_publication_abbreviation", - "PubMed_ID" -] - - @phenotypesbp.route( "<int:species_id>/populations/<int:population_id>/phenotypes/datasets" - "/<int:dataset_id>/edit-download", - methods=["POST"]) + "/<int:dataset_id>/load-data-success/<uuid:job_id>", + methods=["GET"]) @require_login @with_dataset( species_redirect_uri="species.populations.phenotypes.index", population_redirect_uri="species.populations.phenotypes.select_population", redirect_uri="species.populations.phenotypes.list_datasets") -def edit_download_phenotype_data(# pylint: disable=[unused-argument] +def load_data_success( species: dict, population: dict, dataset: dict, + job_id: uuid.UUID, **kwargs -): - formdata = request.json - with database_connection(app.config["SQL_URI"]) as conn: - samples_list = [ - sample["Name"] for sample in samples_by_species_and_population( - conn, species["SpeciesId"], population["Id"])] - data = ( - process_phenotype_data_for_download(pheno) - for pheno in phenotypes_data_by_ids(conn, tuple({ - "population_id": population["Id"], - "phenoid": row["phenotype_id"], - "xref_id": row["xref_id"] - } for row in formdata))) - - with (tempfile.TemporaryDirectory( - prefix=app.config["TEMPORARY_DIRECTORY"]) as tmpdir): - filename = Path(tmpdir).joinpath("tempfile.tsv") - with open(filename, mode="w") as outfile: - outfile.write( - "# **DO NOT** delete the 'UniqueIdentifier' row. It is used " - "by the system to identify and edit the correct rows and " - "columns in the database.\n") - outfile.write( - "# The '…_description' fields are useful for you to figure out " - "what row you are working on. Changing any of this fields will " - "also update the database, so do be careful.\n") - outfile.write( - "# Leave a field empty to delete the value in the database.\n") - outfile.write( - "# Any line beginning with a '#' character is considered a " - "comment line. This line, and all the lines above it, are " - "all comment lines. Comment lines will be ignored.\n") - writer = csv.DictWriter(outfile, - fieldnames= ( - BULK_EDIT_COMMON_FIELDNAMES + - samples_list), - dialect="excel-tab") - writer.writeheader() - writer.writerows(data) - outfile.flush() - - return send_file( - filename, - mimetype="text/csv", - as_attachment=True, - download_name=secure_filename(f"{dataset['Name']}_data")) +):# pylint: disable=[unused-argument] + """Display success page if loading data to database was successful.""" + with (database_connection(app.config["SQL_URI"]) as conn, + sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) + as jobsconn): + try: + gn2_uri = urlparse(app.config["GN2_SERVER_URL"]) + job = gnlibs_jobs.job(jobsconn, job_id, fulldetails=True) + app.logger.debug("THE JOB: %s", job) + _xref_ids = tuple( + str(item) for item + in json.loads(job["metadata"].get("xref_ids", "[]"))) + _publication = fetch_publication_by_id( + conn, int(job["metadata"].get("publication_id", "0"))) + _search_terms = (item for item in + (str(_publication["PubMed_ID"] or ""), + _publication["Authors"], + (_publication["Title"] or "")) + if item != "") + return render_template("phenotypes/load-phenotypes-success.html", + species=species, + population=population, + dataset=dataset, + job=job, + search_page_uri=urlunparse(ParseResult( + scheme=gn2_uri.scheme, + netloc=gn2_uri.netloc, + path="/search", + params="", + query=urlencode({ + "species": species["Name"], + "group": population["Name"], + "type": "Phenotypes", + "dataset": dataset["Name"], + "search_terms_or": ( + # Very long URLs will cause + # errors. + " ".join(_xref_ids) + if len(_xref_ids) <= 100 + else ""), + "search_terms_and": " ".join( + _search_terms).strip(), + "accession_id": "None", + "FormID": "searchResult" + }), + fragment=""))) + except JobNotFound as _jnf: + return render_template("jobs/job-not-found.html", job_id=job_id) @phenotypesbp.route( "<int:species_id>/populations/<int:population_id>/phenotypes/datasets" - "/<int:dataset_id>/edit-upload", - methods=["GET", "POST"]) + "/<int:dataset_id>/recompute-means", + methods=["POST"]) @require_login @with_dataset( species_redirect_uri="species.populations.phenotypes.index", population_redirect_uri="species.populations.phenotypes.select_population", redirect_uri="species.populations.phenotypes.list_datasets") -def edit_upload_phenotype_data(# pylint: disable=[unused-argument] +def recompute_means(# pylint: disable=[unused-argument] species: dict, population: dict, dataset: dict, **kwargs ): - if request.method == "GET": - return render_template( - "phenotypes/bulk-edit-upload.html", - species=species, - population=population, - dataset=dataset, - activelink="edit-phenotype") - - edit_file = save_file(request.files["file-upload-bulk-edit-upload"], - Path(app.config["UPLOAD_FOLDER"])) - - jobs_db = app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"] - with sqlite3.connection(jobs_db) as conn: - job_id = uuid.uuid4() - job_cmd = [ - sys.executable, "-u", - "-m", "scripts.phenotypes_bulk_edit", - app.config["SQL_URI"], - jobs_db, - str(job_id), - "--log-level", - logging.getLevelName( - app.logger.getEffectiveLevel() - ).lower() - ] - app.logger.debug("Phenotype-edit, bulk-upload command: %s", job_cmd) + """Compute/Recompute the means for phenotypes in a particular population.""" + _jobs_db = app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"] + _job_id = uuid.uuid4() + _xref_ids = tuple(int(item.split("_")[-1]) + for item in request.form.getlist("selected-phenotypes")) + + _loglevel = logging.getLevelName(app.logger.getEffectiveLevel()).lower() + command = [ + sys.executable, + "-u", + "-m", + "scripts.compute_phenotype_means", + app.config["SQL_URI"], + _jobs_db, + str(population["Id"]), + "--log-level", + _loglevel] + ( + ["--cross-ref-ids", ",".join(str(_id) for _id in _xref_ids)] + if len(_xref_ids) > 0 else + []) + logger.debug("%s.recompute_means: command (%s)", __name__, command) + + with sqlite3.connection(_jobs_db) as conn: _job = gnlibs_jobs.launch_job( - gnlibs_jobs.initialise_job(conn, - job_id, - job_cmd, - "phenotype-bulk-edit", - extra_meta = { - "edit-file": str(edit_file), - "species-id": species["SpeciesId"], - "population-id": population["Id"], - "dataset-id": dataset["Id"] - }), - jobs_db, - f"{app.config['UPLOAD_FOLDER']}/job_errors", - worker_manager="gn_libs.jobs.launcher") - + gnlibs_jobs.initialise_job( + conn, + _job_id, + command, + "(re)compute-phenotype-means", + extra_meta={ + "species_id": species["SpeciesId"], + "population_id": population["Id"], + "dataset_id": dataset["Id"], + "success_handler": ( + "uploader.phenotypes.views." + "recompute_phenotype_means_success_handler") + }), + _jobs_db, + Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"), + worker_manager="gn_libs.jobs.launcher", + loglevel=_loglevel) + return redirect(url_for("background-jobs.job_status", + job_id=_job["job_id"])) + + +def return_to_dataset_view_handler(job, msg: str): + flash(msg, "alert alert-success") + return redirect(url_for( + "species.populations.phenotypes.view_dataset", + species_id=job["metadata"]["species_id"], + population_id=job["metadata"]["population_id"], + dataset_id=job["metadata"]["dataset_id"], + job_id=job["job_id"])) - return redirect(url_for("background-jobs.job_status", - job_id=job_id, - job_type="phenotype-bulk-edit")) +def recompute_phenotype_means_success_handler(job): + """Handle loading new phenotypes into the database successfully.""" + return return_to_dataset_view_handler(job, "Means computed successfully!") @phenotypesbp.route( "<int:species_id>/populations/<int:population_id>/phenotypes/datasets" - "/<int:dataset_id>/load-data-success/<uuid:job_id>", - methods=["GET"]) + "/<int:dataset_id>/rerun-qtlreaper", + methods=["POST"]) @require_login @with_dataset( species_redirect_uri="species.populations.phenotypes.index", population_redirect_uri="species.populations.phenotypes.select_population", redirect_uri="species.populations.phenotypes.list_datasets") -def load_data_success( +def rerun_qtlreaper(# pylint: disable=[unused-argument] species: dict, population: dict, dataset: dict, - job_id: uuid.UUID, **kwargs -):# pylint: disable=[unused-argument] - with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn: - try: - job = gnlibs_jobs.job(conn, job_id, fulldetails=True) - app.logger.debug("THE JOB: %s", job) - return render_template("phenotypes/load-phenotypes-success.html", - species=species, - population=population, - dataset=dataset, - job=job, - gn2_server_url=app.config["GN2_SERVER_URL"]) - except JobNotFound as jnf: - return render_template("jobs/job-not-found.html", job_id=job_id) +): + """(Re)run QTLReaper for phenotypes in a particular population.""" + _jobs_db = app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"] + _job_id = uuid.uuid4() + _loglevel = logging.getLevelName(app.logger.getEffectiveLevel()).lower() + + _workingdir = Path(app.config["TEMPORARY_DIRECTORY"]).joinpath("qtlreaper") + _workingdir.mkdir(exist_ok=True) + command = [ + sys.executable, + "-u", + "-m", + "scripts.run_qtlreaper", + "--log-level", _loglevel, + app.config["SQL_URI"], + str(species["SpeciesId"]), + str(population["Id"]), + str(Path(app.config["GENOTYPE_FILES_DIRECTORY"]).joinpath( + "genotype")), + str(_workingdir) + ] + [ + str(_xref_id) for _xref_id in ( + int(item.split("_")[-1]) + for item in request.form.getlist("selected-phenotypes")) + ] + logger.debug("(Re)run QTLReaper: %s", command) + with sqlite3.connection(_jobs_db) as conn: + _job_id = uuid.uuid4() + _job = gnlibs_jobs.launch_job( + gnlibs_jobs.initialise_job( + conn, + _job_id, + command, + "(re)run-qtlreaper", + extra_meta={ + "species_id": species["SpeciesId"], + "population_id": population["Id"], + "dataset_id": dataset["Id"], + "success_handler": ( + "uploader.phenotypes.views." + "rerun_qtlreaper_success_handler") + }), + _jobs_db, + Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"), + worker_manager="gn_libs.jobs.launcher", + loglevel=_loglevel) + return redirect(url_for("background-jobs.job_status", + job_id=_job["job_id"])) + return redirect(url_for( + "background-jobs.job_status", job_id=_job["job_id"])) + + +def rerun_qtlreaper_success_handler(job): + """Handle success (re)running QTLReaper script.""" + return return_to_dataset_view_handler(job, "QTLReaper ran successfully!") diff --git a/uploader/platforms/views.py b/uploader/platforms/views.py index d12a9ef..ba0f0ef 100644 --- a/uploader/platforms/views.py +++ b/uploader/platforms/views.py @@ -4,11 +4,11 @@ from gn_libs.mysqldb import database_connection from flask import ( flash, request, - url_for, redirect, Blueprint, current_app as app) +from uploader.flask_extensions import url_for from uploader.ui import make_template_renderer from uploader.authorisation import require_login from uploader.species.models import all_species, species_by_id diff --git a/uploader/population/models.py b/uploader/population/models.py index d78a821..4d95065 100644 --- a/uploader/population/models.py +++ b/uploader/population/models.py @@ -26,13 +26,23 @@ def populations_by_species(conn: mdb.Connection, speciesid) -> tuple: return tuple() +__GENERIC_POPULATION_FAMILIES__ = ( + "Reference Populations (replicate average, SE, N)", + "Crosses and Heterogeneous Stock (individuals)", + "Groups Without Genotypes") -def population_families(conn) -> tuple: +def population_families(conn, species_id: int) -> tuple[str]: """Fetch the families under which populations are grouped.""" with conn.cursor(cursorclass=DictCursor) as cursor: + paramstr = ", ".join(["%s"] * len(__GENERIC_POPULATION_FAMILIES__)) cursor.execute( - "SELECT DISTINCT(Family) FROM InbredSet WHERE Family IS NOT NULL") - return tuple(row["Family"] for row in cursor.fetchall()) + "SELECT DISTINCT(Family) FROM InbredSet " + "WHERE SpeciesId=%s " + "AND Family IS NOT NULL " + f"AND Family NOT IN ({paramstr})", + (species_id, *__GENERIC_POPULATION_FAMILIES__)) + return __GENERIC_POPULATION_FAMILIES__ + tuple( + row["Family"] for row in cursor.fetchall()) def population_genetic_types(conn) -> tuple: @@ -47,9 +57,11 @@ def population_genetic_types(conn) -> tuple: def save_population(cursor: mdb.cursors.Cursor, population_details: dict) -> dict: """Save the population details to the db.""" cursor.execute("SELECT DISTINCT(Family), FamilyOrder FROM InbredSet " - "WHERE Family IS NOT NULL AND Family != '' " + "WHERE SpeciesId=%s " + "AND Family IS NOT NULL AND Family != '' " "AND FamilyOrder IS NOT NULL " - "ORDER BY FamilyOrder ASC") + "ORDER BY FamilyOrder ASC", + (population_details["SpeciesId"],)) _families = { row["Family"]: int(row["FamilyOrder"]) for row in cursor.fetchall() diff --git a/uploader/population/rqtl2.py b/uploader/population/rqtl2.py index 044cdd4..97d4854 100644 --- a/uploader/population/rqtl2.py +++ b/uploader/population/rqtl2.py @@ -12,9 +12,9 @@ import MySQLdb as mdb from redis import Redis from MySQLdb.cursors import DictCursor from gn_libs.mysqldb import database_connection +from markupsafe import escape from flask import ( flash, - escape, request, url_for, redirect, diff --git a/uploader/population/views.py b/uploader/population/views.py index 270dd5f..8d4ceb7 100644 --- a/uploader/population/views.py +++ b/uploader/population/views.py @@ -7,18 +7,19 @@ from MySQLdb.cursors import DictCursor from gn_libs.mysqldb import database_connection from flask import (flash, request, - url_for, redirect, Blueprint, current_app as app) from uploader.samples.views import samplesbp +from uploader.flask_extensions import url_for from uploader.oauth2.client import oauth2_post from uploader.ui import make_template_renderer from uploader.authorisation import require_login from uploader.genotypes.views import genotypesbp from uploader.datautils import enumerate_sequence from uploader.phenotypes.views import phenotypesbp +from uploader.phenotypes.models import datasets_by_population from uploader.expression_data.views import exprdatabp from uploader.species.models import all_species, species_by_id from uploader.monadic_requests import make_either_error_handler @@ -100,7 +101,7 @@ def create_population(species_id: int): return render_template( "populations/create-population.html", species=species, - families = population_families(conn), + families = population_families(conn, species["SpeciesId"]), genetic_types = population_genetic_types(conn), mapping_methods=( {"id": "0", "value": "No mapping support"}, @@ -153,7 +154,7 @@ def create_population(species_id: int): "FullName": population_fullname, "InbredSetCode": request.form.get("population_code") or None, "Description": request.form.get("population_description") or None, - "Family": request.form.get("population_family") or None, + "Family": request.form.get("population_family").strip() or None, "MappingMethodId": request.form.get("population_mapping_method_id"), "GeneticType": request.form.get("population_genetic_type") or None }) @@ -193,10 +194,15 @@ def create_population(species_id: int): @require_login def view_population(species_id: int, population_id: int): """View the details of a population.""" + streamlined_ui = request.args.get("streamlined_ui") 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) + datasets = datasets_by_population(conn, species_id, population_id) error = False + if len(datasets) > 1: + error = True + flash("Got more than one dataset for the population.", "alert alert-danger") if not bool(species): flash("You must select a species.", "alert-danger") @@ -207,9 +213,18 @@ def view_population(species_id: int, population_id: int): error = True if error: - return redirect(url_for("species.populations.index")) + return redirect(url_for(("species.view_species" + if bool(streamlined_ui) + else "species.populations.index"), + species_id=species["SpeciesId"], + streamlined_ui=streamlined_ui)) - return render_template("populations/view-population.html", + return render_template(("populations/sui-view-population.html" + if bool(streamlined_ui) + else "populations/view-population.html"), species=species, population=population, - activelink="view-population") + **({"dataset": datasets[0]} if len(datasets) == 1 else {}), + activelink="view-population", + streamlined_ui=streamlined_ui, + view_under_construction=request.args.get("view_under_construction", False)) diff --git a/uploader/publications/models.py b/uploader/publications/models.py index b199991..dcfa02b 100644 --- a/uploader/publications/models.py +++ b/uploader/publications/models.py @@ -1,6 +1,6 @@ """Module to handle persistence and retrieval of publication to/from MariaDB""" import logging -from typing import Iterable, Optional +from typing import Iterable from MySQLdb.cursors import DictCursor @@ -30,6 +30,7 @@ def create_new_publications( conn: Connection, publications: tuple[dict, ...] ) -> tuple[dict, ...]: + """Create new publications in the database.""" if len(publications) > 0: with conn.cursor(cursorclass=DictCursor) as cursor: cursor.executemany( @@ -47,7 +48,8 @@ def create_new_publications( return tuple({ **row, "publication_id": row["Id"] } for row in cursor.fetchall()) - return tuple() + + return tuple() def update_publications(conn: Connection , publications: tuple[dict, ...]) -> tuple[dict, ...]: @@ -69,6 +71,27 @@ def update_publications(conn: Connection , publications: tuple[dict, ...]) -> tu return tuple() +def delete_publications(conn: Connection , publications: tuple[dict, ...]): + """Delete multiple publications""" + publications = tuple(pub for pub in publications if bool(pub)) + if len(publications) > 0: + _pub_ids = tuple(pub["Id"] for pub in publications) + _paramstr = ", ".join(["%s"] * len(_pub_ids)) + _phenos_query = ( + "SELECT PublicationId, COUNT(PhenotypeId) FROM PublishXRef " + f"WHERE PublicationId IN ({_paramstr}) GROUP BY PublicationId;") + + with conn.cursor(cursorclass=DictCursor) as cursor: + cursor.execute(_phenos_query, _pub_ids) + _linked_phenos = cursor.fetchall() + if len(_linked_phenos) > 0: + raise Exception(# pylint: disable=[broad-exception-raised] + "Cannot delete publications with linked phenotypes.") + + cursor.execute( + f"DELETE FROM Publication WHERE Id IN ({_paramstr})", _pub_ids) + + def fetch_publication_by_id(conn: Connection, publication_id: int) -> dict: """Fetch a specific publication from the database.""" with conn.cursor(cursorclass=DictCursor) as cursor: diff --git a/uploader/publications/pubmed.py b/uploader/publications/pubmed.py index ed9b652..2531c4a 100644 --- a/uploader/publications/pubmed.py +++ b/uploader/publications/pubmed.py @@ -29,9 +29,7 @@ def __journal__(journal: etree.Element) -> dict: } def __author__(author: etree.Element) -> str: - return "%s %s" % ( - author.find("LastName").text, - author.find("Initials").text) + return f'{author.find("LastName").text} {author.find("Initials").text}' def __pages__(pagination: etree.Element) -> str: @@ -88,7 +86,8 @@ def fetch_publications(pubmed_ids: tuple[int, ...]) -> tuple[dict, ...]: "db": "pubmed", "retmode": "xml", "id": ",".join(str(item) for item in pubmed_ids) - }) + }, + timeout=(9.13, 20)) if response.status_code == 200: return __process_pubmed_publication_data__(response.text) diff --git a/uploader/publications/views.py b/uploader/publications/views.py index 0608a35..4ec832f 100644 --- a/uploader/publications/views.py +++ b/uploader/publications/views.py @@ -1,28 +1,28 @@ """Endpoints for publications""" import json -from MySQLdb.cursors import DictCursor from gn_libs.mysqldb import database_connection from flask import ( flash, request, - url_for, redirect, Blueprint, render_template, current_app as app) +from uploader.flask_extensions import url_for from uploader.authorisation import require_login +from uploader.route_utils import redirect_to_next from .models import ( + delete_publications, + update_publications, fetch_publication_by_id, create_new_publications, fetch_publication_phenotypes) from .datatables import fetch_publications -from gn_libs.debug import __pk__ - pubbp = Blueprint("publications", __name__) @@ -30,21 +30,21 @@ pubbp = Blueprint("publications", __name__) @require_login def index(): """Index page for publications.""" - with database_connection(app.config["SQL_URI"]) as conn: - return render_template("publications/index.html") + return render_template("publications/index.html") @pubbp.route("/list", methods=["GET"]) @require_login def list_publications(): + """Fetch publications that fulfill a specific search, or all of them, if + there is no search term.""" # request breakdown: # https://datatables.net/manual/server-side _page = int(request.args.get("draw")) _length = int(request.args.get("length") or '-1') _start = int(request.args.get("start") or '0') _search = request.args["search[value]"] - with (database_connection(app.config["SQL_URI"]) as conn, - conn.cursor(cursorclass=DictCursor) as cursor): + with database_connection(app.config["SQL_URI"]) as conn: _publications, _current_rows, _totalfiltered, _totalrows = fetch_publications( conn, _search, @@ -65,9 +65,15 @@ def list_publications(): def view_publication(publication_id: int): """View more details on a particular publication.""" with database_connection(app.config["SQL_URI"]) as conn: + publication = fetch_publication_by_id(conn, publication_id) + + if not bool(publication): + flash("Requested publication was not found!", "alert-warning") + return redirect(url_for('publications.index')) + return render_template( "publications/view-publication.html", - publication=fetch_publication_by_id(conn, publication_id), + publication=publication, linked_phenotypes=tuple(fetch_publication_phenotypes( conn, publication_id))) @@ -76,7 +82,7 @@ def view_publication(publication_id: int): @require_login def create_publication(): """Create a new publication.""" - if(request.method == "GET"): + if request.method == "GET": return render_template("publications/create-publication.html") form = request.form authors = form.get("publication-authors").encode("utf8") @@ -105,3 +111,75 @@ def create_publication(): flash("Publication creation failed!", "alert alert-danger") app.logger.debug("Failed to create the new publication.", exc_info=True) return redirect(url_for("publications.create_publication")) + + +@pubbp.route("/edit/<int:publication_id>", methods=["GET", "POST"]) +@require_login +def edit_publication(publication_id: int): + """Edit a publication's details.""" + with database_connection(app.config["SQL_URI"]) as conn: + if request.method == "GET": + return render_template( + "publications/edit-publication.html", + publication=fetch_publication_by_id(conn, publication_id), + linked_phenotypes=tuple(fetch_publication_phenotypes( + conn, publication_id)), + publication_id=publication_id) + + form = request.form + _pub = update_publications(conn, ({ + "publication_id": publication_id, + "pubmed_id": form.get("pubmed-id") or None, + "abstract": form.get("publication-abstract").encode("utf8") or None, + "authors": form.get("publication-authors").encode("utf8"), + "title": form.get("publication-title").encode("utf8") or None, + "journal": form.get("publication-journal").encode("utf8") or None, + "volume": form.get("publication-volume").encode("utf8") or None, + "pages": form.get("publication-pages").encode("utf8") or None, + "month": (form.get("publication-month") or "").encode("utf8").capitalize() or None, + "year": form.get("publication-year").encode("utf8") or None + },)) + + if not _pub: + flash("There was an error updating the publication details.", + "alert-danger") + return redirect(url_for( + "publications.edit_publication", publication_id=publication_id)) + + flash("Successfully updated the publication details.", + "alert-success") + return redirect_to_next({ + "uri": "publications.view_publication", + "publication_id": publication_id + }) + + +@pubbp.route("/delete/<int:publication_id>", methods=["GET", "POST"]) +@require_login +def delete_publication(publication_id: int): + """Delete a particular publication.""" + with database_connection(app.config["SQL_URI"]) as conn: + publication = fetch_publication_by_id(conn, publication_id) + linked_phenotypes=tuple(fetch_publication_phenotypes( + conn, publication_id)) + + if not bool(publication): + flash("Requested publication was not found!", "alert-warning") + return redirect(url_for('publications.index')) + + if len(linked_phenotypes) > 0: + flash("Cannot delete publication with linked phenotypes!", + "alert-warning") + return redirect(url_for( + "publications.view_publication", publication_id=publication_id)) + + if request.method == "GET": + return render_template( + "publications/delete-publication.html", + publication=publication, + linked_phenotypes=linked_phenotypes, + publication_id=publication_id) + + delete_publications(conn, (publication,)) + flash("Deleted the publication successfully.", "alert-success") + return render_template("publications/delete-publication-success.html") diff --git a/uploader/route_utils.py b/uploader/route_utils.py index ce718fb..4449475 100644 --- a/uploader/route_utils.py +++ b/uploader/route_utils.py @@ -1,11 +1,22 @@ """Generic routing utilities.""" -from flask import flash, url_for, redirect, render_template, current_app as app +import logging +from json.decoder import JSONDecodeError + +from flask import (flash, + request, + redirect, + render_template, + current_app as app) from gn_libs.mysqldb import database_connection +from uploader.flask_extensions import url_for +from uploader.datautils import base64_encode_dict, base64_decode_to_dict from uploader.population.models import (populations_by_species, population_by_species_and_id) +logger = logging.getLogger(__name__) + def generic_select_population( # pylint: disable=[too-many-arguments, too-many-positional-arguments] species: dict, @@ -40,3 +51,40 @@ def generic_select_population( return redirect(url_for(forward_to, species_id=species["SpeciesId"], population_id=population["Id"])) + + +def redirect_to_next(default: dict): + """Redirect to the next uri if specified, else redirect to default.""" + assert "uri" in default, "You must provide at least the 'uri' value." + try: + next_page = base64_decode_to_dict(request.args.get("next")) + _uri = next_page["uri"] + next_page.pop("uri") + return redirect(url_for(_uri, **next_page)) + except (TypeError, JSONDecodeError) as _err: + logger.debug("We could not decode the next value '%s'", + next_page, + exc_info=True) + + return redirect(url_for( + default["uri"], + **{key:value for key,value in default.items() if key != "uri"})) + + +def build_next_argument(uri: str, **kwargs) -> str: + """Build the `next` URI argument from provided details.""" + dumps_keywords = ( + "skipkeys", "ensure_ascii", "check_circular", "allow_nan", "cls", + "indent", "separators", "default", "sort_keys") + return base64_encode_dict( + { + "uri": uri, + **{ + key: val for key,val in kwargs.items() + if key not in dumps_keywords + } + }, + **{ + key: val for key,val in kwargs.items() + if key in dumps_keywords + }) diff --git a/uploader/samples/models.py b/uploader/samples/models.py index b419d61..1e9293f 100644 --- a/uploader/samples/models.py +++ b/uploader/samples/models.py @@ -34,8 +34,7 @@ def read_samples_file(filepath, separator: str, firstlineheading: bool, **kwargs else ("Name", "Name2", "Symbol", "Alias")), delimiter=separator, quotechar=kwargs.get("quotechar", '"')) - for row in reader: - yield row + yield from reader def save_samples_data(conn: mdb.Connection, diff --git a/uploader/samples/views.py b/uploader/samples/views.py index c0adb88..f8baf7e 100644 --- a/uploader/samples/views.py +++ b/uploader/samples/views.py @@ -7,13 +7,13 @@ from pathlib import Path from redis import Redis from flask import (flash, request, - url_for, redirect, Blueprint, current_app as app) from uploader import jobs from uploader.files import save_file +from uploader.flask_extensions import url_for from uploader.ui import make_template_renderer from uploader.authorisation import require_login from uploader.input_validation import is_integer_input @@ -96,7 +96,7 @@ def list_samples(species: dict, population: dict, **kwargs):# pylint: disable=[u activelink="list-samples") -def build_sample_upload_job(# pylint: disable=[too-many-arguments] +def build_sample_upload_job(# pylint: disable=[too-many-arguments, too-many-positional-arguments] speciesid: int, populationid: int, samplesfile: Path, @@ -159,7 +159,7 @@ def upload_samples(species_id: int, population_id: int):#pylint: disable=[too-ma "alert-error") return samples_uploads_page - firstlineheading = (request.form.get("first_line_heading") == "on") + firstlineheading = request.form.get("first_line_heading") == "on" separator = request.form.get("separator", ",") if separator == "other": @@ -172,7 +172,7 @@ def upload_samples(species_id: int, population_id: int):#pylint: disable=[too-ma redisuri = app.config["REDIS_URL"] with Redis.from_url(redisuri, decode_responses=True) as rconn: - #TODO: Add a QC step here — what do we check? + #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 @@ -251,7 +251,7 @@ def upload_status(species: dict, population: dict, job_id: uuid.UUID, **kwargs): @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): +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)) diff --git a/uploader/species/models.py b/uploader/species/models.py index db53d48..acfa51e 100644 --- a/uploader/species/models.py +++ b/uploader/species/models.py @@ -92,7 +92,7 @@ def save_species(conn: mdb.Connection, } -def update_species(# pylint: disable=[too-many-arguments] +def update_species(# pylint: disable=[too-many-arguments, too-many-positional-arguments] conn: mdb.Connection, species_id: int, common_name: str, diff --git a/uploader/species/views.py b/uploader/species/views.py index cea2f68..20acd01 100644 --- a/uploader/species/views.py +++ b/uploader/species/views.py @@ -4,17 +4,19 @@ from pymonad.either import Left, Right, Either from gn_libs.mysqldb import database_connection from flask import (flash, request, - url_for, redirect, Blueprint, current_app as app) from uploader.population import popbp from uploader.platforms import platformsbp +from uploader.flask_extensions import url_for from uploader.ui import make_template_renderer from uploader.oauth2.client import oauth2_get, oauth2_post from uploader.authorisation import require_login, require_token from uploader.datautils import order_by_family, enumerate_sequence +from uploader.population.models import (populations_by_species, + population_by_species_and_id) from .models import (all_species, save_species, @@ -41,15 +43,28 @@ def list_species(): @require_login def view_species(species_id: int): """View details of a particular species and menus to act upon it.""" + streamlined_ui = request.args.get("streamlined_ui") with database_connection(app.config["SQL_URI"]) as conn: species = species_by_id(conn, species_id) if bool(species): - return render_template("species/view-species.html", - species=species, - activelink="view-species") + population = population_by_species_and_id( + conn, species_id, request.args.get("population_id")) + if bool(population): + return redirect(url_for("species.populations.view_population", + species_id=species_id, + population_id=population["Id"])) + return render_template( + ("species/sui-view-species.html" + if bool(streamlined_ui) + else "species/view-species.html"), + species=species, + activelink="view-species", + populations=populations_by_species(conn, species["SpeciesId"])) flash("Could not find a species with the given identifier.", "alert-danger") - return redirect(url_for("species.view_species")) + return redirect(url_for("base.index" + if streamlined_ui + else "species.view_species")) @speciesbp.route("/create", methods=["GET", "POST"]) @require_login diff --git a/uploader/static/css/layout-common.css b/uploader/static/css/layout-common.css new file mode 100644 index 0000000..36a5735 --- /dev/null +++ b/uploader/static/css/layout-common.css @@ -0,0 +1,3 @@ +* { + box-sizing: border-box; +} diff --git a/uploader/static/css/layout-large.css b/uploader/static/css/layout-large.css new file mode 100644 index 0000000..8abd2dd --- /dev/null +++ b/uploader/static/css/layout-large.css @@ -0,0 +1,62 @@ +@media screen and (min-width: 20.1in) { + body { + display: grid; + grid-template-columns: 7fr 3fr; + grid-gap: 1em; + } + + #header { + /* 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; + } + + #header #header-text { + /* Place it in the parent element */ + grid-column-start: 1; + grid-column-end: 2; + + /* Content styling */ + padding-left: 1em; + } + + #header #header-nav { + /* Place it in the parent element */ + grid-column-start: 2; + grid-column-end: 3; + } + + #main { + /* 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: 7fr 3fr; + grid-gap: 1.5em; + } + + #main #breadcrumbs { + grid-column-start: 1; + grid-column-end: 3; + padding: 0 3px; + } + + #main #main-content { + max-width: 950px; + + grid-column-start: 1; + grid-column-end: 2; + } + + #main #sidebar-content { + grid-column-start: 2; + grid-column-end: 3; + padding: 1em 0 0 0; + } +} diff --git a/uploader/static/css/layout-medium.css b/uploader/static/css/layout-medium.css new file mode 100644 index 0000000..2cca711 --- /dev/null +++ b/uploader/static/css/layout-medium.css @@ -0,0 +1,64 @@ +@media screen and (width > 8in) and (max-width: 20in) { + body { + display: grid; + grid-template-columns: 65fr 35fr; + grid-gap: 1em; + } + + #header { + /* 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; + } + + #header #header-text { + /* Place it in the parent element */ + grid-column-start: 1; + grid-column-end: 2; + + /* Content styling */ + padding-left: 1em; + } + + #header #header-nav { + /* Place it in the parent element */ + grid-column-start: 2; + grid-column-end: 3; + } + + #main { + /* 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: 7fr 3fr; + grid-gap: 5px; + } + + #main #breadcrumbs { + grid-column-start: 1; + grid-column-end: 3; + padding: 0 3px; + } + + #main #main-content { + /* Place it in the parent element */ + grid-column-start: 1; + grid-column-end: 2; + grid-gap: 5px; + + /* Define layout for the children elements */ + max-width: 100%; + } + + #main #sidebar-content { + grid-column-start: 2; + grid-column-end: 3; + } +} diff --git a/uploader/static/css/layout-small.css b/uploader/static/css/layout-small.css new file mode 100644 index 0000000..80a3759 --- /dev/null +++ b/uploader/static/css/layout-small.css @@ -0,0 +1,60 @@ +@media screen and (max-width: 8in) { + body { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr 2fr 7fr; + grid-gap: 1em; + } + + #header { + /* 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: 1fr; + } + + #header #header-text { + /* Place it in the parent element */ + grid-column-start: 1; + grid-column-end: 2; + + /* Content styling */ + padding-left: 1em; + } + + #header #header-nav { + /* Place it in the parent element */ + grid-column-start: 1; + grid-column-end: 2; + } + + #main { + /* Place it in the parent element */ + grid-column-start: 1; + grid-column-end: 2; + display: grid; + + /* Define layout for the children elements */ + grid-template-rows: 1.5em 80% 20%; + grid-template-columns: 1fr; + } + + #main #breadcrumbs { + grid-row-start: 1; + grid-row-end: 2; + + } + + #main #main-content { + grid-row-start: 2; + grid-row-end: 3; + } + + #main #sidebar-content { + grid-row-start: 3; + grid-row-end: 4; + } +} diff --git a/uploader/static/css/theme.css b/uploader/static/css/theme.css new file mode 100644 index 0000000..2acce5f --- /dev/null +++ b/uploader/static/css/theme.css @@ -0,0 +1,81 @@ +body { + margin: 0.7em; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-style: normal; + font-size: 20px; +} + +#header { + background-color: #336699; + color: #FFFFFF; + border-radius: 3px; + min-height: 30px; +} + +#header #header-nav .nav li a { + /* Content styling */ + color: #FFFFFF; + background: #4477AA; + border: solid 5px #336699; + border-radius: 5px; + font-size: 0.7em; + text-align: center; + padding: 1px 7px; +} + +#main #breadcrumbs { + border-radius:3px; + text-align: center; +} + +#main #main-content { + border-radius: 5px; + padding: 0 5px; +} + +#main #sidebar-content { + background: #EEEEEE; + + border-radius: 5px; + padding: 10px 5px; +} + +#main .row { + margin: 0 2px; +} + + +.heading { + border-bottom: solid #EEBB88; + text-transform: capitalize; +} + +.subheading { + padding: 1em 0 0.1em 0.5em; + border-bottom: solid #88BBEE; + text-transform: capitalize; +} + +input[type="search"] { + border-radius: 5px; +} + +.btn { + text-transform: Capitalize; +} + +table.dataTable thead th, table.dataTable tfoot th{ + border-right: 1px solid white; + color: white; + background-color: #369 !important; +} + +table.dataTable tbody tr.selected td { + background-color: #ffee99 !important; +} + +.form-group { + margin-bottom: 2em; + padding-bottom: 0.2em; + border-bottom: solid gray 1px; +} diff --git a/uploader/static/js/populations.js b/uploader/static/js/populations.js index be1231f..111ebb7 100644 --- a/uploader/static/js/populations.js +++ b/uploader/static/js/populations.js @@ -13,9 +13,24 @@ $(() => { } }, { + searchable: true, data: (apopulation) => { return `${apopulation.FullName} (${apopulation.InbredSetName})`; } } - ]); + ], + { + select: "single", + paging: true, + scrollY: 500, + deferRender: true, + scroller: true, + scrollCollapse: true, + layout: { + topStart: "info", + topEnd: "search", + bottomStart: "pageLength", + bottomEnd: false + } + }); }); diff --git a/uploader/static/js/pubmed.js b/uploader/static/js/pubmed.js index 9afd4c3..f425f49 100644 --- a/uploader/static/js/pubmed.js +++ b/uploader/static/js/pubmed.js @@ -22,7 +22,7 @@ var extract_details = (pubmed_id, details) => { "journal": details[pubmed_id].fulljournalname, "volume": details[pubmed_id].volume, "pages": details[pubmed_id].pages, - "month": _date.length > 1 ? months[_date[1].toLowerCase()] : "jan", + "month": _date.length > 1 ? (months[_date[1].toLowerCase()] || "January") : "January", "year": _date[0], }; }; diff --git a/uploader/static/js/species.js b/uploader/static/js/species.js index 9ea3017..fb0d2d2 100644 --- a/uploader/static/js/species.js +++ b/uploader/static/js/species.js @@ -16,5 +16,19 @@ $(() => { return `${aspecies.FullName} (${aspecies.SpeciesName})`; } } - ]); + ], + { + select: "single", + paging: true, + scrollY: 500, + deferRender: true, + scroller: true, + scrollCollapse: true, + layout: { + topStart: "info", + topEnd: "search", + bottomStart: "pageLength", + bottomEnd: false + } + }); }); diff --git a/uploader/templates/background-jobs/default-success-page.html b/uploader/templates/background-jobs/default-success-page.html new file mode 100644 index 0000000..5732456 --- /dev/null +++ b/uploader/templates/background-jobs/default-success-page.html @@ -0,0 +1,17 @@ +{%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/base.html b/uploader/templates/base.html index 3c0d0d4..d521ccb 100644 --- a/uploader/templates/base.html +++ b/uploader/templates/base.html @@ -45,7 +45,7 @@ <aside id="nav-sidebar"> <ul class="nav flex-column"> <li {%if activemenu=="home"%}class="activemenu"{%endif%}> - <a href="/" >Home</a></li> + <a href="{{url_for('base.index')}}" >Home</a></li> <li {%if activemenu=="publications"%}class="activemenu"{%endif%}> <a href="{{url_for('publications.index')}}" title="View and manage publications.">Publications</a></li> diff --git a/uploader/templates/flash_messages.html b/uploader/templates/flash_messages.html index b7af178..b42e64e 100644 --- a/uploader/templates/flash_messages.html +++ b/uploader/templates/flash_messages.html @@ -1,11 +1,11 @@ {%macro flash_all_messages()%} {%with messages = get_flashed_messages(with_categories=true)%} {%if messages:%} -<ul> +<div> {%for category, message in messages:%} - <li class="{{category}}">{{message}}</li> + <div class="alert {{category}}">{{message}}</div> {%endfor%} -</ul> +</div> {%endif%} {%endwith%} {%endmacro%} @@ -13,13 +13,13 @@ {%macro flash_messages(filter_class)%} {%with messages = get_flashed_messages(with_categories=true)%} {%if messages:%} -<ul> +<div> {%for category, message in messages:%} {%if filter_class in category%} - <li class="{{category}}">{{message}}</li> + <div class="alert {{category}}">{{message}}</div> {%endif%} {%endfor%} -</ul> +</div> {%endif%} {%endwith%} {%endmacro%} diff --git a/uploader/templates/macro-forms.html b/uploader/templates/macro-forms.html new file mode 100644 index 0000000..0ccab32 --- /dev/null +++ b/uploader/templates/macro-forms.html @@ -0,0 +1,9 @@ +{%macro add_http_feature_flags()%} +{%for flag in http_feature_flags():%} +{%if (request.args.get(flag) or request.form.get(flag) or ""):%} +<input type="hidden" + name="{{flag}}" + value="{{(request.args.get(flag) or request.form.get(flag))}}" /> +{%endif%} +{%endfor%} +{%endmacro%} diff --git a/uploader/templates/phenotypes/add-phenotypes-base.html b/uploader/templates/phenotypes/add-phenotypes-base.html index 01cd0fe..9909c20 100644 --- a/uploader/templates/phenotypes/add-phenotypes-base.html +++ b/uploader/templates/phenotypes/add-phenotypes-base.html @@ -139,6 +139,7 @@ scrollY: 700, deferRender: true, scroller: true, + scrollCollapse: true, layout: { topStart: "info", topEnd: "search" diff --git a/uploader/templates/phenotypes/add-phenotypes-raw-files.html b/uploader/templates/phenotypes/add-phenotypes-raw-files.html index 5c6aaab..67b56e3 100644 --- a/uploader/templates/phenotypes/add-phenotypes-raw-files.html +++ b/uploader/templates/phenotypes/add-phenotypes-raw-files.html @@ -103,139 +103,215 @@ <a href="#docs-file-na" title="Documentation for no-value fields"> documentation for more information</a>.</span> </div> - - <div class="form-check form-group"> - <div class=""> - <input id="chk-file-transposed" - name="file-transposed" - type="checkbox" - class="form-check-input" - style="border: solid #8EABF0" /> - <label for="chk-file-transposed" class="form-check-label"> - File transposed</label> - </div> - <span class="form-text text-muted"> - We expect the data in the file as a matrix of - <strong>samples × phenotypes</strong>, but it could be your file is - transposed, (i.e. it is a matrix of - “<em>phenotypes × samples</em>”, rather than the expected - form). - <br /> - If that is the case for your files, mark your files as trasposed by - ensuring that this checkbox is checked. - </span> - </div> </fieldset> -<fieldset id="fldset-data-files"> +<fieldset id="fldset-files"> <legend>Data File(s)</legend> - <div class="form-group non-resumable-elements"> - <label for="finput-phenotype-descriptions" class="form-label"> - Phenotype Descriptions</label> - <input id="finput-phenotype-descriptions" - name="phenotype-descriptions" - class="form-control" - type="file" - data-preview-table="tbl-preview-pheno-desc" - required="required" /> - <span class="form-text text-muted"> - Provide a file that contains only the phenotype descriptions, - <a href="#docs-file-phenotype-description" - title="Documentation of the phenotype data file format."> - the documentation for the expected format of the file</a>.</span> - </div> - - {{display_resumable_elements( - "resumable-phenotype-descriptions", - "phenotype descriptions", - '<p>You can drop a CSV file that contains the phenotype descriptions here, - or you can click the "Browse" button (below and to the right) to select it - from your computer.</p> - <p>The CSV file must conform to some standards, as documented in the - <a href="#docs-file-phenotype-description" - title="Documentation of the phenotype data file format."> - "Phenotypes Descriptions" documentation</a> section below.</p>')}} - {{display_preview_table("tbl-preview-pheno-desc", "phenotype descriptions")}} - - - <div class="form-group non-resumable-elements"> - <label for="finput-phenotype-data" class="form-label">Phenotype Data</label> - <input id="finput-phenotype-data" - name="phenotype-data" - class="form-control" - type="file" - data-preview-table="tbl-preview-pheno-data" - required="required" /> - <span class="form-text text-muted"> - Provide a file that contains only the phenotype data. See - <a href="#docs-file-phenotype-data" - title="Documentation of the phenotype data file format."> - the documentation for the expected format of the file</a>.</span> - </div> - - {{display_resumable_elements( - "resumable-phenotype-data", - "phenotype data", - '<p>You can drop a CSV file that contains the phenotype data here, - or you can click the "Browse" button (below and to the right) to select it - from your computer.</p> - <p>The CSV file must conform to some standards, as documented in the - <a href="#docs-file-phenotype-data" - title="Documentation of the phenotype data file format."> - "Phenotypes Data" documentation</a> section below.</p>')}} - {{display_preview_table("tbl-preview-pheno-data", "phenotype data")}} - - {%if population.Family in families_with_se_and_n%} - <div class="form-group non-resumable-elements"> - <label for="finput-phenotype-se" class="form-label">Phenotype: Standard Errors</label> - <input id="finput-phenotype-se" - name="phenotype-se" - class="form-control" - type="file" - data-preview-table="tbl-preview-pheno-se" - required="required" /> - <span class="form-text text-muted"> - Provide a file that contains only the standard errors for the phenotypes, - computed from the data above.</span> - </div> - {{display_resumable_elements( - "resumable-phenotype-se", - "standard errors", - '<p>You can drop a CSV file that contains the computed standard-errors data - here, or you can click the "Browse" button (below and to the right) to - select it from your computer.</p> - <p>The CSV file must conform to some standards, as documented in the - <a href="#docs-file-phenotype-se" - title="Documentation of the phenotype data file format."> - "Phenotypes Data" documentation</a> section below.</p>')}} - {{display_preview_table("tbl-preview-pheno-se", "standard errors")}} + <fieldset id="fldset-descriptions-file"> + <div class="form-group"> + <div class="form-check"> + <input id="chk-phenotype-descriptions-transposed" + name="phenotype-descriptions-transposed" + type="checkbox" + class="form-check-input" + style="border: solid #8EABF0" /> + <label for="chk-phenotype-descriptions-transposed" + class="form-check-label"> + Description file transposed?</label> + </div> + + <div class="non-resumable-elements"> + <label for="finput-phenotype-descriptions" class="form-label"> + Phenotype Descriptions</label> + <input id="finput-phenotype-descriptions" + name="phenotype-descriptions" + class="form-control" + type="file" + data-preview-table="tbl-preview-pheno-desc" + required="required" /> + <span class="form-text text-muted"> + Provide a file that contains only the phenotype descriptions, + <a href="#docs-file-phenotype-description" + title="Documentation of the phenotype data file format."> + the documentation for the expected format of the file</a>.</span> + </div> + {{display_resumable_elements( + "resumable-phenotype-descriptions", + "phenotype descriptions", + '<p>Drag and drop the CSV file that contains the descriptions of your + phenotypes here.</p> + + <p>The CSV file should be a matrix of + <strong>phenotypes × descriptions</strong> i.e. The first column + contains the phenotype names/identifiers whereas the first row is a list + of metadata fields like, "description", "units", etc.</p> + + <p>If the format is transposed (i.e. + <strong>descriptions × phenotypes</strong>) select the checkbox above. + </p> + + <p>Please see the + <a href="#docs-file-phenotype-description" + title="Documentation of the phenotype data file format."> + "Phenotypes Descriptions" documentation</a> section below for more + information on the expected format of the file provided here.</p>')}} + {{display_preview_table( + "tbl-preview-pheno-desc", "phenotype descriptions")}} + </div> + </fieldset> + + + <fieldset id="fldset-data-file"> + <div class="form-group"> + <div class="form-check"> + <input id="chk-phenotype-data-transposed" + name="phenotype-data-transposed" + type="checkbox" + class="form-check-input" + style="border: solid #8EABF0" /> + <label for="chk-phenotype-data-transposed" class="form-check-label"> + Data file transposed?</label> + </div> + + <div class="non-resumable-elements"> + <label for="finput-phenotype-data" class="form-label">Phenotype Data</label> + <input id="finput-phenotype-data" + name="phenotype-data" + class="form-control" + type="file" + data-preview-table="tbl-preview-pheno-data" + required="required" /> + <span class="form-text text-muted"> + Provide a file that contains only the phenotype data. See + <a href="#docs-file-phenotype-data" + title="Documentation of the phenotype data file format."> + the documentation for the expected format of the file</a>.</span> + </div> + + {{display_resumable_elements( + "resumable-phenotype-data", + "phenotype data", + '<p>Drag and drop a CSV file that contains the phenotypes numerical data + here. You can click the "Browse" button (below and to the right) to + select the file from your computer.</p> + + <p>The CSV should be a matrix of <strong>samples × phenotypes</strong>, + i.e. The first column contains the samples identifiers while the first + row is the list of phenotypes identifiers occurring in the phenotypes + descriptions file.</p> + + <p>If the format is transposed (i.e <strong>phenotypes × samples</strong>) + select the checkbox above.</p> + <p>Please see the + <a href="#docs-file-phenotype-data" + title="Documentation of the phenotype data file format."> + "Phenotypes Data" documentation</a> section below for more information + on the expected format for the file provided here.</p>')}} + {{display_preview_table("tbl-preview-pheno-data", "phenotype data")}} + </div> + </fieldset> - <div class="form-group non-resumable-elements"> - <label for="finput-phenotype-n" class="form-label">Phenotype: Number of Samples/Individuals</label> - <input id="finput-phenotype-n" - name="phenotype-n" - class="form-control" - type="file" - data-preview-table="tbl-preview-pheno-n" - required="required" /> - <span class="form-text text-muted"> - Provide a file that contains only the number of samples/individuals used in - the computation of the standard errors above.</span> - </div> - {{display_resumable_elements( - "resumable-phenotype-n", - "number of samples/individuals", - '<p>You can drop a CSV file that contains the number of samples/individuals - used in computation of the standard-errors here, or you can click the - "Browse" button (below and to the right) to select it from your computer. - </p> - <p>The CSV file must conform to some standards, as documented in the - <a href="#docs-file-phenotype-n" - title="Documentation of the phenotype data file format."> - "Phenotypes Data" documentation</a> section below.</p>')}} - {{display_preview_table("tbl-preview-pheno-n", "number of samples/individuals")}} + {%if population.Family in families_with_se_and_n%} + <fieldset id="fldset-se-file"> + <div class="form-group"> + <div class="form-check"> + <input id="chk-phenotype-se-transposed" + name="phenotype-se-transposed" + type="checkbox" + class="form-check-input" + style="border: solid #8EABF0" /> + <label for="chk-phenotype-se-transposed" class="form-check-label"> + Standard-Errors file transposed?</label> + </div> + <div class="group non-resumable-elements"> + <label for="finput-phenotype-se" class="form-label">Phenotype: Standard Errors</label> + <input id="finput-phenotype-se" + name="phenotype-se" + class="form-control" + type="file" + data-preview-table="tbl-preview-pheno-se" + required="required" /> + <span class="form-text text-muted"> + Provide a file that contains only the standard errors for the phenotypes, + computed from the data above.</span> + </div> + + {{display_resumable_elements( + "resumable-phenotype-se", + "standard errors", + '<p>Drag and drop a CSV file that contains the phenotypes standard-errors + data here. You can click the "Browse" button (below and to the right) to + select the file from your computer.</p> + + <p>The CSV should be a matrix of <strong>samples × phenotypes</strong>, + i.e. The first column contains the samples identifiers while the first + row is the list of phenotypes identifiers occurring in the phenotypes + descriptions file.</p> + + <p>If the format is transposed (i.e <strong>phenotypes × samples</strong>) + select the checkbox above.</p> + + <p>Please see the + <a href="#docs-file-phenotype-se" + title="Documentation of the phenotype data file format."> + "Phenotypes Data" documentation</a> section below for more information + on the expected format of the file provided here.</p>')}} + + {{display_preview_table("tbl-preview-pheno-se", "standard errors")}} + </div> + </fieldset> + + + <fieldset id="fldset-n-file"> + <div class="form-group"> + <div class="form-check"> + <input id="chk-phenotype-n-transposed" + name="phenotype-n-transposed" + type="checkbox" + class="form-check-input" + style="border: solid #8EABF0" /> + <label for="chk-phenotype-n-transposed" class="form-check-label"> + Counts file transposed?</label> + </div> + <div class="non-resumable-elements"> + <label for="finput-phenotype-n" class="form-label">Phenotype: Number of Samples/Individuals</label> + <input id="finput-phenotype-n" + name="phenotype-n" + class="form-control" + type="file" + data-preview-table="tbl-preview-pheno-n" + required="required" /> + <span class="form-text text-muted"> + Provide a file that contains only the number of samples/individuals used in + the computation of the standard errors above.</span> + </div> + + {{display_resumable_elements( + "resumable-phenotype-n", + "number of samples/individuals", + '<p>Drag and drop a CSV file that contains the samples\' phenotypes counts + data here. You can click the "Browse" button (below and to the right) to + select the file from your computer.</p> + + <p>The CSV should be a matrix of <strong>samples × phenotypes</strong>, + i.e. The first column contains the samples identifiers while the first + row is the list of phenotypes identifiers occurring in the phenotypes + descriptions file.</p> + + <p>If the format is transposed (i.e <strong>phenotypes × samples</strong>) + select the checkbox above.</p> + + <p>Please see the + <a href="#docs-file-phenotype-se" + title="Documentation of the phenotype data file format."> + "Phenotypes Data" documentation</a> section below for more information + on the expected format of the file provided here.</p>')}} + + {{display_preview_table("tbl-preview-pheno-n", "number of samples/individuals")}} + </div> + </fieldset> </fieldset> {%endif%} {%endblock%} diff --git a/uploader/templates/phenotypes/edit-phenotype.html b/uploader/templates/phenotypes/edit-phenotype.html index 32c903f..115d6af 100644 --- a/uploader/templates/phenotypes/edit-phenotype.html +++ b/uploader/templates/phenotypes/edit-phenotype.html @@ -201,130 +201,6 @@ </form> </div> - -<div class="row"> - <h3 class="subheading">publication information</h3> - <p>Use the form below to update the publication information for this - phenotype.</p> - <form id="frm-edit-phenotype-pub-data" - class="form-horizontal" - method="POST" - action="#"> - <div class="form-group"> - <label for="txt-pubmed-id" class="control-label col-sm-2">Pubmed ID</label> - <div class="col-sm-10"> - <input id="txt-pubmed-id" name="pubmed-id" type="text" - class="form-control" /> - <span class="form-text text-muted"> - Enter your publication's PubMed ID.</span> - </div> - </div> - - <div class="form-group"> - <label for="txt-publication-authors" class="control-label col-sm-2">Authors</label> - <div class="col-sm-10"> - <input id="txt-publication-authors" name="publication-authors" - type="text" class="form-control" /> - <span class="form-text text-muted"> - Enter the authors.</span> - </div> - </div> - - <div class="form-group"> - <label for="txt-publication-title" class="control-label col-sm-2"> - Publication Title</label> - <div class="col-sm-10"> - <input id="txt-publication-title" name="publication-title" type="text" - class="form-control" /> - <span class="form-text text-muted"> - Enter your publication's title.</span> - </div> - </div> - - <div class="form-group"> - <label for="txt-publication-abstract" class="control-label col-sm-2"> - Publication Abstract</label> - <div class="col-sm-10"> - <textarea id="txt-publication-abstract" name="publication-abstract" - class="form-control" rows="10"></textarea> - <span class="form-text text-muted"> - Enter the abstract for your publication.</span> - </div> - </div> - - <div class="form-group"> - <label for="txt-publication-journal" class="control-label col-sm-2">Journal</label> - <div class="col-sm-10"> - <input id="txt-publication-journal" name="journal" type="text" - class="form-control" /> - <span class="form-text text-muted"> - Enter the name of the journal where your work was published.</span> - </div> - </div> - - <div class="form-group"> - <label for="txt-publication-volume" class="control-label col-sm-2">Volume</label> - <div class="col-sm-10"> - <input id="txt-publication-volume" name="publication-volume" type="text" - class="form-control" /> - <span class="form-text text-muted"> - Enter the volume in the following format …</span> - </div> - </div> - - <div class="form-group"> - <label for="txt-publication-pages" class="control-label col-sm-2">Pages</label> - <div class="col-sm-10"> - <input id="txt-publication-pages" name="publication-pages" type="text" - class="form-control" /> - <span class="form-text text-muted"> - Enter the journal volume where your work was published.</span> - </div> - </div> - - <div class="form-group"> - <label for="select-publication-month" class="control-label col-sm-2"> - Publication Month</label> - <div class="col-sm-10"> - <select id="select-publication-month" name="publication-month" - class="form-control"> - {%for month in monthnames%} - <option value="{{month | lower}}" - {%if current_month | lower == month | lower%} - selected="selected" - {%endif%}>{{month | capitalize}}</option> - {%endfor%} - </select> - <span class="form-text text-muted"> - Select the month when the work was published. - <span class="text-danger"> - This cannot be before, say 1600 and cannot be in the future!</span></span> - </div> - </div> - - <div class="form-group"> - <label for="txt-publication-year" class="control-label col-sm-2">Publication Year</label> - <div class="col-sm-10"> - <input id="txt-publication-year" name="publication-year" type="text" - class="form-control" value="{{current_year}}" /> - <span class="form-text text-muted"> - Enter the year your work was published. - <span class="text-danger"> - This cannot be before, say 1600 and cannot be in the future!</span> - </span> - </div> - </div> - <div class="form-group"> - <div class="col-sm-offset-2 col-sm-10"> - <input type="submit" - name="submit" - class="btn btn-primary not-implemented" - value="update publication" /> - </div> - </div> - </form> -</div> - {%endblock%} {%block sidebarcontents%} diff --git a/uploader/templates/phenotypes/job-status.html b/uploader/templates/phenotypes/job-status.html index 12963c1..257f726 100644 --- a/uploader/templates/phenotypes/job-status.html +++ b/uploader/templates/phenotypes/job-status.html @@ -105,7 +105,7 @@ <td>{{error.filename}}</td> <td>{{error.rowtitle}}</td> <td>{{error.coltitle}}</td> - <td>{%if error.cellvalue | length > 25%} + <td>{%if error.cellvalue is not none and error.cellvalue | length > 25%} {{error.cellvalue[0:24]}}… {%else%} {{error.cellvalue}} diff --git a/uploader/templates/phenotypes/load-phenotypes-success.html b/uploader/templates/phenotypes/load-phenotypes-success.html index 3baca5b..645be16 100644 --- a/uploader/templates/phenotypes/load-phenotypes-success.html +++ b/uploader/templates/phenotypes/load-phenotypes-success.html @@ -28,7 +28,7 @@ <!-- TODO: Maybe notify user that they have sole access. --> <!-- TODO: Maybe provide a link to go to GeneNetwork to view the data. --> <p>View your data - <a href="{{gn2_server_url}}search?species={{species.Name}}&group={{population.Name}}&type=Phenotypes&dataset={{dataset.Name}}&search_terms_or=*%0D%0A&search_terms_and=*%0D%0A&accession_id=None&FormID=searchResult" + <a href="{{search_page_uri}}" target="_blank">on GeneNetwork2</a>. You might need to login to GeneNetwork2 to view specific traits.</p> </div> diff --git a/uploader/templates/phenotypes/view-dataset.html b/uploader/templates/phenotypes/view-dataset.html index 21563d6..c634a48 100644 --- a/uploader/templates/phenotypes/view-dataset.html +++ b/uploader/templates/phenotypes/view-dataset.html @@ -46,12 +46,50 @@ </div> <div class="row"> - <p><a href="{{url_for('species.populations.phenotypes.add_phenotypes', - species_id=species.SpeciesId, - population_id=population.Id, - dataset_id=dataset.Id)}}" - title="Add a bunch of phenotypes" - class="btn btn-primary">Add phenotypes</a></p> + <div class="col"> + <a href="{{url_for('species.populations.phenotypes.add_phenotypes', + species_id=species.SpeciesId, + population_id=population.Id, + dataset_id=dataset.Id)}}" + title="Add a bunch of phenotypes" + class="btn btn-primary">Add phenotypes</a> + </div> + + <div class="col"> + <form id="frm-recompute-phenotype-means" + method="POST" + action="{{url_for( + 'species.populations.phenotypes.recompute_means', + species_id=species['SpeciesId'], + population_id=population['Id'], + dataset_id=dataset['Id'])}}" + class="d-flex flex-row align-items-center flex-wrap" + style="display: inline;"> + <input type="submit" + title="Compute/Recompute the means for all phenotypes." + class="btn btn-info" + value="(rec/c)ompute means" + id="submit-frm-recompute-phenotype-means" /> + </form> + </div> + + <div class="col"> + <form id="frm-run-qtlreaper" + method="POST" + action="{{url_for( + 'species.populations.phenotypes.rerun_qtlreaper', + species_id=species['SpeciesId'], + population_id=population['Id'], + dataset_id=dataset['Id'])}}" + class="d-flex flex-row align-items-center flex-wrap" + style="display: inline;"> + <input type="submit" + title="Run/Rerun QTLReaper." + class="btn btn-info" + value="(re)run QTLReaper" + id="submit-frm-rerun-qtlreaper" /> + </form> + </div> </div> <div class="row"> @@ -133,100 +171,6 @@ { select: "multi+shift", layout: { - top2: { - buttons: [ - { - extend: "selectAll", - className: "btn btn-info", - titleAttr: "Click to select ALL records in the table." - }, - { - extend: "selectNone", - className: "btn btn-info", - titleAttr: "Click to deselect ANY selected record(s) in the table." - }, - { - text: "Bulk Edit (Download Data)", - className: "btn btn-info btn-bulk-edit", - titleAttr: "Click to download data for editing.", - action: (event, dt, node, config) => { - var phenoids = []; - var selected = dt.rows({selected: true, page: "all"}).data(); - for(var idx = 0; idx < selected.length; idx++) { - phenoids.push({ - phenotype_id: selected[idx].Id, - xref_id: selected[idx].xref_id - }); - } - if(phenoids.length == 0) { - alert("No record selected. Nothing to do!"); - return false; - } - - $(".btn-bulk-edit").prop("disabled", true); - $(".btn-bulk-edit").addClass("d-none"); - var spinner = $( - "<div id='bulk-edit-spinner' class='spinner-grow text-info'>"); - spinner_content = $( - "<span class='visually-hidden'>"); - spinner_content.html( - "Downloading data …"); - spinner.append(spinner_content) - $(".btn-bulk-edit").parent().append( - spinner); - - $.ajax( - (`/species/${species_id}/populations/` + - `${population_id}/phenotypes/datasets/` + - `${dataset_id}/edit-download`), - { - method: "POST", - data: JSON.stringify(phenoids), - xhrFields: { - responseType: "blob" - }, - success: (data, textStatus, jqXHR) => { - var link = document.createElement("a"); - uri = window.URL.createObjectURL(data); - link.href = uri; - link.download = `${dataset_name}_data.tsv`; - - document.body.appendChild(link); - link.click(); - window.URL.revokeObjectURL(uri); - link.remove(); - }, - error: (jQXHR, textStatus, errorThrown) => { - console.log("Experienced an error: ", textStatus); - console.log("The ERROR: ", errorThrown); - }, - complete: (jqXHR, textStatus) => { - $("#bulk-edit-spinner").remove(); - $(".btn-bulk-edit").removeClass( - "d-none"); - $(".btn-bulk-edit").prop( - "disabled", false); - }, - contentType: "application/json" - }); - } - }, - { - text: "Bulk Edit (Upload Data)", - className: "btn btn-info btn-bulk-edit", - titleAttr: "Click to upload edited data you got by clicking the `Bulk Edit (Download Data)` button.", - action: (event, dt, node, config) => { - window.location.assign( - `${window.location.protocol}//` + - `${window.location.host}` + - `/species/${species_id}` + - `/populations/${population_id}` + - `/phenotypes/datasets/${dataset_id}` + - `/edit-upload`) - } - } - ] - }, top1Start: { pageLength: { text: "Show _MENU_ of _TOTAL_" @@ -239,6 +183,27 @@ return `${pheno.InbredSetCode}_${pheno.xref_id}`; } }); + + + $("#submit-frm-rerun-qtlreaper").on( + "click", + function(event) { + // (Re)run the QTLReaper script for selected phenotypes. + event.preventDefault(); + var form = $("#frm-run-qtlreaper"); + form.find(".dynamically-added-element").remove(); + dtPhenotypesList.rows({selected: true}).nodes().each((node, index) => { + _cloned = $(node).find(".chk-row-select").clone(); + _cloned.removeAttr("id"); + _cloned.removeAttr("class"); + _cloned.attr("style", "display: none;"); + _cloned.attr("data-type", "dynamically-added-element"); + _cloned.attr("class", "dynamically-added-element checkbox"); + _cloned.prop("checked", true); + form.append(_cloned); + }); + form.submit(); + }); }); </script> {%endblock%} diff --git a/uploader/templates/phenotypes/view-phenotype.html b/uploader/templates/phenotypes/view-phenotype.html index 21ac501..75e3c1e 100644 --- a/uploader/templates/phenotypes/view-phenotype.html +++ b/uploader/templates/phenotypes/view-phenotype.html @@ -24,8 +24,10 @@ {{flash_all_messages()}} <div class="row"> - <div class="panel panel-default"> - <div class="panel-heading"><strong>Basic Phenotype Details</strong></div> + <div class="card"> + <div class="card-header"> + <h5 class="card-title">Basic Phenotype Details</h5> + </div> <table class="table"> <tbody> @@ -41,24 +43,46 @@ <td><strong>Units</strong></td> <td>{{phenotype.Units}}</td> </tr> - {%for key,value in publish_data.items()%} - <tr> - <td><strong>{{key}}</strong></td> - <td>{{value}}</td> - </tr> - {%else%} - <tr> - <td colspan="2" class="text-muted"> - <span class="glyphicon glyphicon-exclamation-sign"></span> - No publication data found. - </td> - </tr> - {%endfor%} </tbody> </table> </div> </div> +<div class="row" style="margin-top:5px;"> + <div class="card"> + <div class="card-header"> + <h5 class="card-title">Publication Details</h5> + </div> + + <div class="card-body"> + <table class="table"> + <tbody> + <tr> + {%for key in ("PubMed_ID", "Authors", "Title", "Journal"):%} + <tr> + <td><strong>{{key}}</strong></td> + <td>{{publication.get(key, "")}}</td> + </tr> + {%else%} + <tr> + <td colspan="2" class="text-muted"> + <span class="glyphicon glyphicon-exclamation-sign"></span> + No publication data found. + </td> + </tr> + {%endfor%} + </tr> + </tbody> + </table> + <div style="text-align: right;"> + <a href="{{url_for('publications.edit_publication', publication_id=publication.Id, next=next)}}" + class="btn btn-info">edit</a> + <a href="#" class="btn btn-danger not-implemented">change</a> + </div> + </div> + </div> +</div> + {%if "group:resource:edit-resource" in privileges or "group:resource:delete-resource" in privileges%} <div class="row"> diff --git a/uploader/templates/populations/create-population.html b/uploader/templates/populations/create-population.html index c0c4f45..007b6bf 100644 --- a/uploader/templates/populations/create-population.html +++ b/uploader/templates/populations/create-population.html @@ -154,24 +154,35 @@ {%else%} class="form-group" {%endif%}> - <label for="select-population-family" class="form-label">Family</label> - <select id="select-population-family" - name="population_family" - class="form-control" - required="required"> - <option value="">Please select a family</option> + <label for="txt-population-family" class="form-label">Family</label> + <input type="text" + id="txt-population-family" + name="population_family" + class="form-control" + list="families-list" /> + <datalist id="families-list"> {%for family in families%} - <option value="{{family}}" - {%if error_values.population_family == family%} - selected="selected" - {%endif%}>{{family}}</option> + <option value="{{family}}">{{family}}</option> {%endfor%} - </select> + </datalist> <small class="form-text text-muted"> <p> - This is a rough grouping of the populations in GeneNetwork into lists - of common types of populations. - </p> + This is <strong>optional</strong> metadata. It is used to group + populations into "families" for presentation in the menus. + {%if families | length > 0%} + Examples of currently existing families are: + <ul> + {%for family in families[0:7]%} + <li>{{family}}</li> + {%endfor%} + <li>etc.</li> + </ul> + {%endif%} + + You can + {%if families|length>0%} select from existing families, or {%endif%} + create a new family by typing in the input box above. You can also + leave the family blank.</p> </small> </div> diff --git a/uploader/templates/populations/list-populations.html b/uploader/templates/populations/list-populations.html index f780e94..a092e34 100644 --- a/uploader/templates/populations/list-populations.html +++ b/uploader/templates/populations/list-populations.html @@ -54,7 +54,7 @@ <th></th> <th>Name</th> <th>Full Name</th> - <th>Description</th> + <th>Information</th> </tr> </thead> @@ -71,7 +71,10 @@ </a> </td> <td>{{population.FullName}}</td> - <td>{{population.Description}}</td> + <td><a href="https://info.genenetwork.org/species/source.php?SpeciesName={{species.Name}}&InbredSetName={{population.Name}}" + title="Link to detailed information on this population." + class="btn btn-info" + target="_blank">info</a></td> </tr> {%else%} <tr> diff --git a/uploader/templates/populations/macro-display-population-card.html b/uploader/templates/populations/macro-display-population-card.html index 16b477f..6b5f1e0 100644 --- a/uploader/templates/populations/macro-display-population-card.html +++ b/uploader/templates/populations/macro-display-population-card.html @@ -1,4 +1,4 @@ -{%from "species/macro-display-species-card.html" import display_species_card%} +{%from "species/macro-display-species-card.html" import display_species_card,display_sui_species_card%} {%macro display_population_card(species, population)%} {{display_species_card(species)}} @@ -39,3 +39,40 @@ </div> </div> {%endmacro%} + + +{%macro display_sui_population_card(species, population)%} +{{display_sui_species_card(species)}} + +<div class="row"> + <table class="table"> + <caption>Current population</caption> + <tbody> + <tr> + <th>Name</th> + <td>{{population.Name}}</td> + </tr> + + <tr> + <th>Full Name</th> + <td>{{population.FullName}}</td> + </tr> + + <tr> + <th>Code</th> + <td>{{population.InbredSetCode}}</td> + </tr> + + <tr> + <th>Genetic Type</th> + <td>{{population.GeneticType}}</td> + </tr> + + <tr> + <th>Family</th> + <td>{{population.Family}}</td> + </tr> + </tbody> + </table> +</div> +{%endmacro%} diff --git a/uploader/templates/populations/sui-base.html b/uploader/templates/populations/sui-base.html new file mode 100644 index 0000000..cc01c9e --- /dev/null +++ b/uploader/templates/populations/sui-base.html @@ -0,0 +1,12 @@ +{%extends "species/sui-base.html"%} + +{%block breadcrumbs%} +{{super()}} +<li class="breadcrumb-item"> + <a href="{{url_for('species.populations.view_population', + species_id=species['SpeciesId'], + population_id=population['Id'])}}"> + {{population["FullName"]}} + </a> +</li> +{%endblock%} diff --git a/uploader/templates/populations/sui-view-population.html b/uploader/templates/populations/sui-view-population.html new file mode 100644 index 0000000..3bf8d0d --- /dev/null +++ b/uploader/templates/populations/sui-view-population.html @@ -0,0 +1,138 @@ +{%extends "populations/sui-base.html"%} +{%from "macro-step-indicator.html" import step_indicator%} +{%from "populations/macro-display-population-card.html" import display_sui_population_card%} + +{%block contents%} +<div class="row"> + <h2 class="heading">{{population.FullName}} ({{population.Name}})</h2> +</div> + +<div class="row"> + <ul class="nav nav-tabs" id="population-actions"> + <li class="nav-item presentation"> + <button class="nav-link" + id="samples-tab" + data-bs-toggle="tab" + data-bs-target="#samples-content" + type="button" + role="tab" + aria-controls="samples-content" + aria-selected="true">Samples</button></li> + <li class="nav-item presentation"> + <button class="nav-link active" + id="phenotypes-tab" + data-bs-toggle="tab" + data-bs-target="#phenotypes-content" + type="button" + role="tab" + aria-controls="phenotypes-content" + aria-selected="false">Phenotypes</button></li> + {%if view_under_construction%} + <li class="nav-item presentation"> + <button class="nav-link" + id="genotypes-tab" + data-bs-toggle="tab" + data-bs-target="#genotypes-content" + type="button" + role="tab" + aria-controls="genotypes-content" + aria-selected="false">Genotypes</button></li> + <li class="nav-item presentation"> + <button class="nav-link" + id="expression-data-tab" + data-bs-toggle="tab" + data-bs-target="#expression-data-content" + type="button" + role="tab" + aria-controls="expression-data-content" + aria-selected="false">Expression-Data</button></li> + {%endif%} + </ul> +</div> + +<div class="row"> + <div class="tab-content" id="populations-tabs-content"> + <div class="tab-pane fade" + id="samples-content" + role="tabpanel" + aria-labelledby="samples-content-tab"> + <p>Think of a <strong>"sample"</strong> as say a single case or individual + in the experiment. It could even be a single strain (where applicable) + under consideration.</p> + <p>This is a convenience feature for when you want to upload phenotypes to + the system, but do not have the genotypes data ready yet.</p> + <p>Please click the button below to provide the samples that will be used + in your data.</p> + <a href="{{url_for('species.populations.samples.list_samples', + species_id=species.SpeciesId, + population_id=population.Id)}}" + title="Upload samples for population '{{population['Name']}}'" + class="btn btn-primary">Upload Samples</a> + </div> + + <div class="tab-pane fade show active" + id="phenotypes-content" + role="tabpanel" + aria-labelledby="phenotypes-content-tab"> + <p>Upload and manage phenotypes and publications for population + "<em>{{population.FullName}} ({{population.Name}})</em>" of species + "<em>{{species.FullName}} ({{species.Name}})</em>".</p> + + <p class="text-danger">Tabs will not work nicely here. Maybe present + options e.g.: + </p> + <div class="row"> + <div class="col"> + <a href="{{url_for('species.populations.phenotypes.view_dataset', + species_id=species.SpeciesId, + population_id=population.Id, + dataset_id=dataset.Id)}}" + title="Upload phenotype data for population '{{population['Name']}}'" + class="btn btn-primary">Upload new Phenotypes</a> + <!-- Go straight to upload form(s). --> + </div> + <div class="col"> + <a href="#" + title="List all existing phenotypes for this population." + class="btn btn-info not-implemented">list existing phenotypes</a> + <!-- Means and QTLReaper will be computed in this page. --> + </div> + <div class="col"> + <a href="#" + title="List all existing publications for this population." + class="btn btn-info not-implemented">list existing publications</a> + <!-- Maybe, actually filter publications by population? --> + <!-- Provide other features for publications on loaded page. --> + </div> + </div> + </div> + <div class="tab-pane fade" + id="genotypes-content" + role="tabpanel" + aria-labelledby="genotypes-content-tab"> + <p>This allows you to upload the data that concerns your genotypes.</p> + <p>Any samples/individuals/cases/strains that do not already exist in the + system will be added. This does not delete any existing data.</p> + <a href="{{url_for('species.populations.genotypes.list_genotypes', + species_id=species.SpeciesId, + population_id=population.Id)}}" + title="Upload genotype information for the '{{population.FullName}}' population of the '{{species.FullName}}' species." + class="btn btn-primary">upload genotypes</a> + </div> + <div class="tab-pane fade" id="expression-data-content" role="tabpanel" aria-labelledby="expression-data-content-tab"> + <p>Upload expression data (mRNA data) for this population.</p> + <a href="#" title="" class="btn btn-primary">upload genotypes</a> + </div> + </div> +</div> +{%endblock%} + +{%block sidebarcontents%} +<div class="row"> + <p>Each tab presents a feature that's available at the population level. + Select the tab that allows you to continue with your task.</p> +</div> +{{display_sui_population_card(species, population)}} +{%endblock%} + + diff --git a/uploader/templates/populations/view-population.html b/uploader/templates/populations/view-population.html index b23caeb..3b9661b 100644 --- a/uploader/templates/populations/view-population.html +++ b/uploader/templates/populations/view-population.html @@ -42,8 +42,10 @@ <dt>Family</dt> <dd>{{population.Family}}</dd> - <dt>Description</dt> - <dd><pre>{{population.Description or "-"}}</pre></dd> + <dt>Information</dt> + <dd><a href="https://info.genenetwork.org/species/source.php?SpeciesName={{species.Name}}&InbredSetName={{population.Name}}" + title="Link to detailed information on this population." + target="_blank">Population Information</a></dd> </dl> </div> diff --git a/uploader/templates/publications/delete-publication-success.html b/uploader/templates/publications/delete-publication-success.html new file mode 100644 index 0000000..53a44ec --- /dev/null +++ b/uploader/templates/publications/delete-publication-success.html @@ -0,0 +1,18 @@ +{%extends "publications/base.html"%} +{%from "flash_messages.html" import flash_all_messages%} + +{%block title%}View Publication{%endblock%} + +{%block pagetitle%}View Publication{%endblock%} + + +{%block contents%} +{{flash_all_messages()}} +{%endblock%} + + +{%block javascript%} +<script type="text/javascript"> + $(function() {}); +</script> +{%endblock%} diff --git a/uploader/templates/publications/delete-publication.html b/uploader/templates/publications/delete-publication.html new file mode 100644 index 0000000..0ac93ec --- /dev/null +++ b/uploader/templates/publications/delete-publication.html @@ -0,0 +1,88 @@ +{%extends "publications/base.html"%} +{%from "flash_messages.html" import flash_all_messages%} + +{%block title%}View Publication{%endblock%} + +{%block pagetitle%}View Publication{%endblock%} + + +{%block contents%} +{{flash_all_messages()}} +<div class="row"> + <p>You are about to delete the publication with the following details:</p> +</div> + +<div class="row"> + <table class="table"> + <tr> + <th>Linked Phenotypes</th> + <td>{{linked_phenotypes | count}}</td> + </tr> + <tr> + <th>PubMed</th> + <td> + {%if publication.PubMed_ID%} + <a href="https://pubmed.ncbi.nlm.nih.gov/{{publication.PubMed_ID}}/" + target="_blank">{{publication.PubMed_ID}}</a> + {%else%} + — + {%endif%} + </td> + </tr> + <tr> + <th>Title</th> + <td>{{publication.Title or "—"}}</td> + </tr> + <tr> + <th>Authors</th> + <td>{{publication.Authors or "—"}}</td> + </tr> + <tr> + <th>Journal</th> + <td>{{publication.Journal or "—"}}</td> + </tr> + <tr> + <th>Published</th> + <td>{{publication.Month or ""}} {{publication.Year or "—"}}</td> + </tr> + <tr> + <th>Volume</th> + <td>{{publication.Volume or "—"}}</td> + </tr> + <tr> + <th>Pages</th> + <td>{{publication.Pages or "—"}}</td> + </tr> + <tr> + <th>Abstract</th> + <td> + {%for line in (publication.Abstract or "—").replace("\r\n", "<br />").replace("\n", "<br />").split("<br />")%} + <p>{{line}}</p> + {%endfor%} + </td> + </tr> + </table> +</div> + +<div class="row"> + <p>If you are sure that is what you want, click the button below to delete the + publication</p> + <p class="form-text text-small"> + <small>You will not be able to recover the data if you click + delete below.</small></p> + + <form action="{{url_for('publications.delete_publication', publication_id=publication_id)}}" + method="POST"> + <div class="form-group"> + <input type="submit" value="delete" class="btn btn-danger" /> + </div> + </form> +</div> +{%endblock%} + + +{%block javascript%} +<script type="text/javascript"> + $(function() {}); +</script> +{%endblock%} diff --git a/uploader/templates/publications/edit-publication.html b/uploader/templates/publications/edit-publication.html new file mode 100644 index 0000000..540ecf1 --- /dev/null +++ b/uploader/templates/publications/edit-publication.html @@ -0,0 +1,194 @@ +{%extends "publications/base.html"%} +{%from "flash_messages.html" import flash_all_messages%} + +{%block title%}View Publication{%endblock%} + +{%block pagetitle%}View Publication{%endblock%} + + +{%block contents%} +{{flash_all_messages()}} + +<div class="row"> + <form id="frm-create-publication" + method="POST" + action="{{url_for('publications.edit_publication', publication_id=publication_id, **request.args)}}" + class="form-horizontal"> + + <div class="row mb-3"> + <label for="txt-pubmed-id" class="col-sm-2 col-form-label"> + PubMed ID</label> + <div class="col-sm-10"> + <div class="input-group"> + <input type="text" + id="txt-pubmed-id" + name="pubmed-id" + value="{{publication.PubMed_ID or ''}}" + class="form-control" /> + <div class="input-group-text"> + <button class="btn btn-outline-primary" + id="btn-search-pubmed-id">search</button> + </div> + </div> + <span id="search-pubmed-id-error" + class="form-text text-muted text-danger visually-hidden"> + </span> + <span class="form-text text-muted">This is the publication's ID on + <a href="https://pubmed.ncbi.nlm.nih.gov/" + title="Link to NCBI's PubMed service">NCBI's Pubmed Service</a> + </span> + </div> + </div> + + <div class="row mb-3"> + <label for="txt-publication-title" class="col-sm-2 col-form-label"> + Title</label> + <div class="col-sm-10"> + <input type="text" + id="txt-publication-title" + name="publication-title" + value="{{publication.Title}}" + class="form-control" /> + <span class="form-text text-muted">Provide the publication's title here.</span> + </div> + </div> + + <div class="row mb-3"> + <label for="txt-publication-authors" class="col-sm-2 col-form-label"> + Authors</label> + <div class="col-sm-10"> + <input type="text" + id="txt-publication-authors" + name="publication-authors" + value="{{publication.Authors}}" + required="required" + class="form-control" /> + <span class="form-text text-muted"> + A publication <strong>MUST</strong> have an author. You <em>must</em> + provide a value for the authors field. + </span> + </div> + </div> + + <div class="row mb-3"> + <label for="txt-publication-journal" class="col-sm-2 col-form-label"> + Journal</label> + <div class="col-sm-10"> + <input type="text" + id="txt-publication-journal" + name="publication-journal" + value="{{publication.Journal}}" + class="form-control" /> + <span class="form-text text-muted">Provide the name journal where the + publication was done, here.</span> + </div> + </div> + + <div class="row mb-3"> + <label for="select-publication-month" + class="col-sm-2 col-form-label"> + Month</label> + <div class="col-sm-4"> + <select class="form-control" + id="select-publication-month" + name="publication-month"> + <option value="">Select a month</option> + {%for month in ("january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"):%} + <option value="{{month}}" + {%if publication.Month | lower == month %} + selected="selected" + {%endif%}> + {{month | title}} + </option> + {%endfor%} + </select> + <span class="form-text text-muted">Month of publication</span> + </div> + + <label for="txt-publication-year" + class="col-sm-2 col-form-label"> + Year</label> + <div class="col-sm-4"> + <input type="number" + id="txt-publication-year" + name="publication-year" + value="{{publication.Year}}" + class="form-control" + min="1960" /> + <span class="form-text text-muted">Year of publication</span> + </div> + </div> + + <div class="row mb-3"> + <label for="txt-publication-volume" + class="col-sm-2 col-form-label"> + Volume</label> + <div class="col-sm-4"> + <input type="text" + id="txt-publication-volume" + name="publication-volume" + value="{{publication.Volume}}" + class="form-control"> + <span class="form-text text-muted">Journal volume</span> + </div> + + <label for="txt-publication-pages" + class="col-sm-2 col-form-label"> + Pages</label> + <div class="col-sm-4"> + <input type="text" + id="txt-publication-pages" + name="publication-pages" + value="{{publication.Pages}}" + class="form-control" /> + <span class="form-text text-muted">Journal pages for the publication</span> + </div> + </div> + + <div class="row mb-3"> + <label for="txt-abstract" class="col-sm-2 col-form-label">Abstract</label> + <div class="col-sm-10"> + <textarea id="txt-publication-abstract" + name="publication-abstract" + class="form-control" + rows="7">{{publication.Abstract or ""}}</textarea> + </div> + </div> + + <div class="row mb-3"> + <div class="col-sm-2"></div> + <div class="col-sm-8"> + <input type="submit" class="btn btn-primary" value="Save" /> + <input type="reset" class="btn btn-danger" /> + </div> + </div> + +</form> +</div> + +{%endblock%} + + +{%block javascript%} +<script type="text/javascript" src="/static/js/pubmed.js"></script> +<script type="text/javascript"> + $(function() { + $("#btn-search-pubmed-id").on("click", (event) => { + event.preventDefault(); + var search_button = event.target; + var pubmed_id = $("#txt-pubmed-id").val().trim(); + remove_class($("#txt-pubmed-id").parent(), "has-error"); + if(pubmed_id == "") { + add_class($("#txt-pubmed-id").parent(), "has-error"); + return false; + } + + search_button.disabled = true; + // Fetch publication details + fetch_publication_details(pubmed_id, + [() => {search_button.disabled = false;}]); + return false; + }); + }); +</script> +{%endblock%} diff --git a/uploader/templates/publications/index.html b/uploader/templates/publications/index.html index f846d54..369812b 100644 --- a/uploader/templates/publications/index.html +++ b/uploader/templates/publications/index.html @@ -41,6 +41,7 @@ [ {data: "index"}, { + searchable: true, data: (pub) => { if(pub.PubMed_ID) { return `<a href="https://pubmed.ncbi.nlm.nih.gov/` + @@ -52,6 +53,7 @@ } }, { + searchable: true, data: (pub) => { var title = "⸻"; if(pub.Title) { @@ -64,6 +66,7 @@ } }, { + searchable: true, data: (pub) => { authors = pub.Authors.split(",").map( (item) => {return item.trim();}); @@ -75,16 +78,21 @@ } ], { + serverSide: true, ajax: { url: "/publications/list", dataSrc: "publications" }, scrollY: 700, - paging: false, + scroller: true, + scrollCollapse: true, + paging: true, deferRender: true, layout: { topStart: "info", - topEnd: "search" + topEnd: "search", + bottomStart: "pageLength", + bottomEnd: false } }); }); diff --git a/uploader/templates/publications/view-publication.html b/uploader/templates/publications/view-publication.html index 388547a..0bd7bc5 100644 --- a/uploader/templates/publications/view-publication.html +++ b/uploader/templates/publications/view-publication.html @@ -12,6 +12,10 @@ <div class="row"> <table class="table"> <tr> + <th>Linked Phenotypes</th> + <td>{{linked_phenotypes | count}}</td> + </tr> + <tr> <th>PubMed</th> <td> {%if publication.PubMed_ID%} @@ -58,15 +62,15 @@ </div> <div class="row"> - <form id="frm-edit-delete-publication" method="POST" action="#"> - <input type="hidden" name="publication_id" value="{{publication.Id}}" /> - <div class="form-group"> - <input type="submit" value="edit" class="btn btn-primary not-implemented" /> - {%if linked_phenotypes | length == 0%} - <input type="submit" value="delete" class="btn btn-danger not-implemented" /> - {%endif%} - </div> - </form> + <div> + <a href="{{url_for('publications.edit_publication', publication_id=publication.Id)}}" + title="Edit details for this publication." + class="btn btn-primary">Edit</a> + {%if linked_phenotypes | length == 0%} + <a href="{{url_for('publications.delete_publication', publication_id=publication.Id)}}" + class="btn btn-danger">delete</a> + {%endif%} + </div> </div> {%endblock%} diff --git a/uploader/templates/samples/list-samples.html b/uploader/templates/samples/list-samples.html index 185e784..aed27c3 100644 --- a/uploader/templates/samples/list-samples.html +++ b/uploader/templates/samples/list-samples.html @@ -29,6 +29,19 @@ </p> </div> +<div class="row"> + <p> + <a href="{{url_for('species.populations.samples.upload_samples', + species_id=species.SpeciesId, + population_id=population.Id)}}" + title="Add samples for population '{{population.FullName}}' from species + '{{species.FullName}}'." + class="btn btn-primary"> + add samples + </a> + </p> +</div> + {%if samples | length > 0%} <div class="row"> <p> @@ -96,32 +109,17 @@ <p> <a href="#" - title="Add samples for population '{{population.FullName}}' from species + title="Delete samples from population '{{population.FullName}}' from species '{{species.FullName}}'." - class="btn btn-danger"> + class="btn btn-danger not-implemented"> delete all samples </a> </p> </div> - {%else%} - <div class="row"> - <p> - There are no samples entered for this population. Do please go ahead and add - the samples for this population by clicking on the button below. - </p> - - <p> - <a href="{{url_for('species.populations.samples.upload_samples', - species_id=species.SpeciesId, - population_id=population.Id)}}" - title="Add samples for population '{{population.FullName}}' from species - '{{species.FullName}}'." - class="btn btn-primary"> - add samples - </a> - </p> + <p>There are no samples entered for this population. Click the "Add Samples" + button above, to add some new samples.</p> </div> {%endif%} diff --git a/uploader/templates/samples/upload-samples.html b/uploader/templates/samples/upload-samples.html index 25d3290..6422094 100644 --- a/uploader/templates/samples/upload-samples.html +++ b/uploader/templates/samples/upload-samples.html @@ -66,7 +66,7 @@ <div class="form-group"> <label for="file-samples" class="form-label">select file</label> <input type="file" name="samples_file" id="file:samples" - accept="text/csv, text/tab-separated-values" + accept="text/csv, text/tab-separated-values, text/plain" class="form-control" /> </div> diff --git a/uploader/templates/species/macro-display-species-card.html b/uploader/templates/species/macro-display-species-card.html index 166c7b9..30c564f 100644 --- a/uploader/templates/species/macro-display-species-card.html +++ b/uploader/templates/species/macro-display-species-card.html @@ -20,3 +20,32 @@ </div> </div> {%endmacro%} + + +{%macro display_sui_species_card(species)%} +<div class="row"> + <table class="table"> + <caption>Current Species</caption> + <tbody> + <tr> + <th>Name</th> + <td>{{species["Name"] | title}}</td> + </tr> + <tr> + <th>Scientific</th> + <td>{{species["FullName"]}}</td> + </tr> + {%if species["TaxonomyId"]%} + <tr> + <th>Taxonomy ID</th> + <td> + <a href="https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?id={{species.TaxonomyId}}" + title="NCBI's Taxonomy Browser page for {{species.Name}}"> + {{species.TaxonomyId}}</a> + </td> + </tr> + </tbody> + {%endif%} + </table> +</div> +{%endmacro%} diff --git a/uploader/templates/species/sui-base.html b/uploader/templates/species/sui-base.html new file mode 100644 index 0000000..f7b4fef --- /dev/null +++ b/uploader/templates/species/sui-base.html @@ -0,0 +1,10 @@ +{%extends "sui-base.html"%} + +{%block breadcrumbs%} +{{super()}} +<li class="breadcrumb-item"> + <a href="{{url_for('species.view_species', species_id=species['SpeciesId'])}}"> + {{species["Name"]|title}} + </a> +</li> +{%endblock%} diff --git a/uploader/templates/species/sui-view-species.html b/uploader/templates/species/sui-view-species.html new file mode 100644 index 0000000..4b6402e --- /dev/null +++ b/uploader/templates/species/sui-view-species.html @@ -0,0 +1,127 @@ +{%extends "species/sui-base.html"%} +{%from "flash_messages.html" import flash_all_messages%} +{%from "macro-forms.html" import add_http_feature_flags%} +{%from "macro-step-indicator.html" import step_indicator%} +{%from "species/macro-display-species-card.html" import display_sui_species_card%} + +{%block title%}View Species{%endblock%} + +{%macro add_form_buttons()%} +<div class="row form-buttons"> + <div class="col"> + <input type="submit" + value="use selected population" + class="btn btn-primary" /> + </div> + + <div class="col"> + <a href="url_for('species.population.create_population', + species_id=species.SpeciesId, + return_to='species.view_species')" + title="Create a new population for species '{{species.Name}}'." + class="btn btn-outline-info"> + Create a new population + </a> + </div> +</div> +{%endmacro%} + + +{%block contents%} +<div class="row"> + <h2 class="heading">{{species.FullName}} ({{species.Name}})</h2> +</div> + +<div class "row"> + <ul class="nav nav-tabs" id="species-actions"> + <li class="nav-item presentation"> + <button class="nav-link active" + id="populations-tab" + data-bs-toggle="tab" + data-bs-target="#populations-content" + type="button" + role="tab" + aria-controls="populations-content" + aria-selected="true">Populations</button> + </li> + <li class="nav-item presentation"> + <button class="nav-link" + id="sequencing-platforms-tab" + data-bs-toggle="tab" + data-bs-target="#sequencing-platforms-content" + type="button" + role="tab" + aria-controls="sequencing-platforms-content" + aria-selected="true">Sequencing Platforms</button> + </li> + </ul> +</div> + +<div class="row"> + <div class="tab-content" id="species-tabs-content"> + <div class="tab-pane fade show active" + id="populations-content" + role="tabpanel" + aria-labelledby="populations-content-tab"> + <p>Data belonging to a particular species is further divided into one or more + populations for easier handling. Please select the population you want to work + with.</p> + + <form method="GET" + action="{{url_for('species.view_species', species_id=species.SpeciesId)}}" + class="form-horizontal"> + {{add_http_feature_flags()}} + {{add_form_buttons()}} + + {%if populations | length != 0%} + <div style="margin-top:0.3em;"> + <table id="tbl-select-population" class="table compact stripe" + data-populations-list='{{populations | tojson}}'> + <thead> + <tr> + <th></th> + <th>Population</th> + </tr> + </thead> + + <tbody></tbody> + </table> + </div> + + {%else%} + <p class="form-text"> + There are no populations currently defined for {{species['FullName']}} + ({{species['SpeciesName']}}).</p> + {%endif%} + + {{add_form_buttons()}} + + </form> + </div> + <div class="tab-pane fade" + id="sequencing-platforms-content" + role="tabpanel" + aria-labelledby="sequencing-platforms-content-tab"> + <p>Upload and manage the sequencing platforms for species + '{{species.Name | title}} ({{species.FullName}})' + <a href="{{url_for('species.platforms.list_platforms', + species_id=species.SpeciesId)}}" + title="Manage sequencing platforms for {{species.Name}}">here</a>. + </p> + </div> + </div> +</div> +{%endblock%} + +{%block sidebarcontents%} +<div class="row"> + <p>You can manage species' populations and sequencing platforms here. Select + the tab for the feature you wish to continue working on.</p> +</div> +{{display_sui_species_card(species)}} +{%endblock%} + + +{%block javascript%} +<script type="text/javascript" src="/static/js/populations.js"></script> +{%endblock%} diff --git a/uploader/templates/sui-base.html b/uploader/templates/sui-base.html new file mode 100644 index 0000000..719a646 --- /dev/null +++ b/uploader/templates/sui-base.html @@ -0,0 +1,103 @@ +<!DOCTYPE html> +<html lang="en"> + + <head> + + <meta charset="UTF-8" /> + <meta application-name="GeneNetwork Quality-Control Application" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + {%block extrameta%}{%endblock%} + + <title>Data Upload and Quality Control: {%block title%}{%endblock%}</title> + + <link rel="stylesheet" type="text/css" + href="{{url_for('base.bootstrap', + filename='css/bootstrap.min.css')}}" /> + <link rel="stylesheet" type="text/css" + href="{{url_for('base.datatables', + filename='css/dataTables.bootstrap5.min.css')}}" /> + <link rel="stylesheet" type="text/css" href="/static/css/layout-common.css" /> + <link rel="stylesheet" type="text/css" href="/static/css/layout-large.css" /> + <link rel="stylesheet" type="text/css" href="/static/css/layout-medium.css" /> + <link rel="stylesheet" type="text/css" href="/static/css/layout-small.css" /> + <link rel="stylesheet" type="text/css" href="/static/css/theme.css" /> + + {%block css%}{%endblock%} + + </head> + + <body> + <header id="header"> + <span id="header-text">GeneNetwork</span> + <nav id="header-nav"> + <ul class="nav justify-content-end"> + <li> + {%if user_logged_in()%} + <a href="{{url_for('oauth2.logout')}}" + title="Log out of the system"> + <span class="glyphicon glyphicon-user"></span> + {{user_email()}} Sign Out</a> + {%else%} + <a href="{{authserver_authorise_uri()}}" + title="Log in to the system">Sign In</a> + {%endif%} + </li> + </ul> + </nav> + </header> + + + <main id="main" class="main"> + <nav id="breadcrumbs" aria-label="breadcrumb"> + <ol class="breadcrumb"> + {%block breadcrumbs%} + <li class="breadcrumb-item"> + <a href="{{url_for('base.index')}}">Home</a></li> + {%endblock%} + </ol> + </nav> + + <div id="main-content"> + {%block contents%}{%endblock%} + </div> + + <div id="sidebar-content"> + {%block sidebarcontents%}{%endblock%} + </div> + </main> + + + + <script type="text/javascript" src="/static/js/debug.js"></script> + <!-- + Core dependencies + --> + <script src="{{url_for('base.jquery', + filename='jquery.min.js')}}"></script> + <script src="{{url_for('base.bootstrap', + filename='js/bootstrap.min.js')}}"></script> + + <!-- + DataTables dependencies + --> + <script type="text/javascript" + src="{{url_for('base.datatables', + filename='js/dataTables.min.js')}}"></script> + <script type="text/javascript" + src="{{url_for('base.datatables_extensions', + filename='scroller/js/dataTables.scroller.min.js')}}"></script> + <script type="text/javascript" + src="{{url_for('base.datatables_extensions', + filename='buttons/js/dataTables.buttons.min.js')}}"></script> + <script type="text/javascript" + src="{{url_for('base.datatables_extensions', + filename='select/js/dataTables.select.min.js')}}"></script> + + <!-- + local dependencies + --> + <script type="text/javascript" src="/static/js/utils.js"></script> + <script type="text/javascript" src="/static/js/datatables.js"></script> + {%block javascript%}{%endblock%} + </body> +</html> diff --git a/uploader/templates/sui-index.html b/uploader/templates/sui-index.html new file mode 100644 index 0000000..888823f --- /dev/null +++ b/uploader/templates/sui-index.html @@ -0,0 +1,123 @@ +{%extends "sui-base.html"%} +{%from "flash_messages.html" import flash_all_messages%} +{%from "macro-forms.html" import add_http_feature_flags%} +{%from "macro-step-indicator.html" import step_indicator%} + +{%block title%}Home{%endblock%} + +{%block pagetitle%}Home{%endblock%} + +{%block extra_breadcrumbs%}{%endblock%} + +{%block contents%} + +{%macro add_form_buttons()%} +<div class="row form-buttons"> + <div class="col"> + <input type="submit" + class="btn btn-primary" + value="use selected species" /> + </div> + <div class="col"> + <a href="{{url_for('species.create_species', return_to='base.index')}}" + class="btn btn-outline-primary" + title="Create a new species.">Create a new Species</a> + </div> +</div> +{%endmacro%} + +<div class="row">{{flash_all_messages()}}</div> + +{%if user_logged_in()%} + +<div class="row"> + <div class="row"> + <h2 class="heading">Species</h2> + + <p>Select the species you want to work with.</p> + </div> +</div> + +<div class="row"> + <form method="GET" action="{{url_for('base.index')}}" class="form-horizontal"> + {{add_http_feature_flags()}} + + {{add_form_buttons()}} + + {%if species | length != 0%} + <div style="margin-top:1em;"> + <table id="tbl-select-species" class="table compact stripe" + data-species-list='{{species | tojson}}'> + <thead> + <tr> + <th></th> + <th>Species Name</th> + </tr> + </thead> + + <tbody></tbody> + </table> + </div> + + {%else%} + + <label class="control-label" for="rdo-cant-find-species"> + <input id="rdo-cant-find-species" type="radio" name="species_id" + value="CREATE-SPECIES" /> + There are no species to select from. Create the first one.</label> + + <div class="col-sm-offset-10 col-sm-2"> + <input type="submit" + class="btn btn-primary col-sm-offset-1" + value="continue" /> + </div> + + {%endif%} + + {{add_form_buttons()}} + + </form> +</div> + +{%else%} + +<div class="row"> + <p>The Genenetwork Uploader (<em>gn-uploader</em>) enables upload of new data + into the Genenetwork System. It provides Quality Control over data, and + guidance in case you data does not meet the standards for acceptance.</p> + <p> + <a href="{{authserver_authorise_uri()}}" + title="Sign in to the system" + class="btn btn-primary">Sign in</a> + to get started.</p> +</div> +{%endif%} + +{%endblock%} + + + +{%block sidebarcontents%} +<div class="row"> + <p>The data in Genenetwork is related to one species or another. Use the form + provided to select from existing species, or click on the + "Create a New Species" button if you cannot find the species you want to + work with.</p> +</div> +<div class="row"> + <form id="frm-quick-navigation"> + <legend>Quick Navigation</legend> + <div class="form-group"> + <label for="fqn-species-id">Species</label> + <select name="species_id"> + <option value="">Select species</option> + </select> + </div> + </form> +</div> +{%endblock%} + + +{%block javascript%} +<script type="text/javascript" src="/static/js/species.js"></script> +{%endblock%} |
