diff options
61 files changed, 1396 insertions, 727 deletions
diff --git a/bin/genenetwork2 b/bin/genenetwork2 index fc14eedc..f5d6b055 100755 --- a/bin/genenetwork2 +++ b/bin/genenetwork2 @@ -186,7 +186,7 @@ fi if [ "$1" = '-gunicorn-dev' ] ; then echo PYTHONPATH="${PYTHONPATH}" if [ -z "${SERVER_PORT}" ]; then echo "ERROR: Provide a SERVER_PORT" ; exit 1 ; fi - cmd="--bind 0.0.0.0:$SERVER_PORT --workers=1 --timeout 180 --reload ${GUNICORN_EXTRA_ARGS} gn2.run_gunicorn:app" + cmd="--bind 0.0.0.0:$SERVER_PORT --workers=3 --timeout 180 --reload ${GUNICORN_EXTRA_ARGS} gn2.run_gunicorn:app" echo "RUNNING gunicorn ${cmd}" gunicorn $cmd exit $? diff --git a/containers/db-container.scm b/containers/db-container.scm index 848b6a2e..ec4dd7ba 100644 --- a/containers/db-container.scm +++ b/containers/db-container.scm @@ -30,6 +30,8 @@ (bootloader grub-bootloader) (targets (list "does-not-matter")))) (file-systems %base-file-systems) + (packages (cons mariadb ;; for the mysql CLI client + %base-packages)) (services (cons* (service mysql-service-type) (service redis-service-type) (simple-service 'set-permissions diff --git a/gn2/base/trait.py b/gn2/base/trait.py index 701958d7..24288ba1 100644 --- a/gn2/base/trait.py +++ b/gn2/base/trait.py @@ -611,3 +611,34 @@ def retrieve_trait_info(trait, dataset, get_qtl_info=False): f"{repr(trait.name)} information is not found in the database " f"for dataset '{dataset.name}' with id '{dataset.id}'.") return trait + +def fetch_symbols(trait_db_list): + """ + Fetch list of trait symbols + + From a list of traits and datasets (where each item has + the trait and dataset name separated by a colon), return + + """ + + trimmed_trait_list = [trait_db for trait_db in trait_db_list + if 'Publish' not in trait_db and 'Geno' not in trait_db.split(":")[1]] + + symbol_list = [] + with database_connection(get_setting("SQL_URI")) as conn, conn.cursor() as cursor: + for trait_db in trimmed_trait_list: + symbol_query = """ + SELECT ps.Symbol + FROM ProbeSet as ps + INNER JOIN ProbeSetXRef psx ON psx.ProbeSetId = ps.Id + INNER JOIN ProbeSetFreeze psf ON psx.ProbeSetFreezeId = psf.Id + WHERE + ps.Name = %(trait_name)s AND + psf.Name = %(db_name)s + """ + + cursor.execute(symbol_query, {'trait_name': trait_db.split(":")[0], + 'db_name': trait_db.split(":")[1]}) + symbol_list.append(cursor.fetchone()[0]) + + return "+".join(symbol_list)
\ No newline at end of file diff --git a/gn2/base/webqtlConfig.py b/gn2/base/webqtlConfig.py index 998c0efc..88f199f9 100644 --- a/gn2/base/webqtlConfig.py +++ b/gn2/base/webqtlConfig.py @@ -34,7 +34,7 @@ MAXLRS = 460.0 PUBLICTHRESH = 0 # Groups to treat as unique when drawing correlation dropdowns (not sure if this logic even makes sense or is necessary) -BXD_GROUP_EXCEPTIONS = ['BXD-Longevity', 'BXD-AE', 'BXD-Heart-Metals', 'BXD-NIA-AD'] +BXD_GROUP_EXCEPTIONS = ['BXD-Longevity', 'BXD-AE', 'BXD-Heart-Metals', 'BXD-NIA-AD', 'BXD-JAX-OFS'] # EXTERNAL LINK ADDRESSES PUBMEDLINK_URL = "http://www.ncbi.nlm.nih.gov/entrez/query.fcgi?cmd=Retrieve&db=PubMed&list_uids=%s&dopt=Abstract" @@ -68,6 +68,8 @@ RGD_URL = "https://rgd.mcw.edu/rgdweb/elasticResults.html?term=%s&category=Gene& PHENOGEN_URL = "https://phenogen.org/gene.jsp?speciesCB=Rn&auto=Y&geneTxt=%s&genomeVer=rn7§ion=geneEQTL" RRID_MOUSE_URL = "https://www.jax.org/strain/%s" RRID_RAT_URL = "https://rgd.mcw.edu/rgdweb/report/strain/main.html?id=%s" +GENE_CUP_URL = "https://genecup.org/progress?type=GWAS&type=addiction&type=drug&type=brain&type=stress&type=psychiatric&type=cell&type=function&query=%s" + # Temporary storage (note that this TMPDIR can be set as an # environment variable - use utility.tools.TEMPDIR when you diff --git a/gn2/default_settings.py b/gn2/default_settings.py index e781f196..ab15dbe9 100644 --- a/gn2/default_settings.py +++ b/gn2/default_settings.py @@ -120,3 +120,9 @@ OAUTH2_CLIENT_SECRET="yadabadaboo" SESSION_TYPE = "redis" SESSION_PERMANENT = True SESSION_USE_SIGNER = True + + +# BEGIN: JSON WEB KEYS ##### +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. +# END: JSON WEB KEYS ##### diff --git a/gn2/gn2_main.py b/gn2/gn2_main.py index 38444620..197eed83 100644 --- a/gn2/gn2_main.py +++ b/gn2/gn2_main.py @@ -15,5 +15,6 @@ from gn2.wqflask import search_results from gn2.wqflask import resource_manager from gn2.wqflask import update_search_results +import gn2.wqflask.collect import gn2.wqflask.views import gn2.wqflask.partial_correlations_views diff --git a/gn2/runserver.py b/gn2/runserver.py index 0c95f715..dd176752 100644 --- a/gn2/runserver.py +++ b/gn2/runserver.py @@ -28,7 +28,7 @@ if WEBSERVER_MODE == 'DEBUG': port=SERVER_PORT, debug=True, use_debugger=False, - threaded=False, + threaded=True, processes=0, use_reloader=True) elif WEBSERVER_MODE == 'DEV': @@ -37,7 +37,7 @@ elif WEBSERVER_MODE == 'DEV': port=SERVER_PORT, debug=False, use_debugger=False, - threaded=False, + threaded=True, processes=0, use_reloader=True) else: # staging/production modes diff --git a/gn2/wqflask/__init__.py b/gn2/wqflask/__init__.py index e3708b0b..00fe5127 100644 --- a/gn2/wqflask/__init__.py +++ b/gn2/wqflask/__init__.py @@ -33,6 +33,7 @@ from gn2.wqflask.api.markdown import environments_blueprint from gn2.wqflask.api.markdown import facilities_blueprint from gn2.wqflask.api.markdown import blogs_blueprint from gn2.wqflask.api.markdown import news_blueprint +from gn2.wqflask.api.markdown import xapian_syntax_blueprint from gn2.wqflask.api.jobs import jobs as jobs_bp from gn2.wqflask.oauth2.routes import oauth2 from gn2.wqflask.oauth2.client import user_logged_in @@ -55,28 +56,17 @@ def numcoll(): return "ERROR" -def parse_ssl_key(app: Flask, keyconfig: str): - """Parse key file paths into objects""" - keypath = app.config.get(keyconfig, "").strip() - if not bool(keypath): - app.logger.error("Expected configuration '%s'", keyconfig) - return - - with open(keypath) as _sslkey: - app.config[keyconfig] = JsonWebKey.import_key(_sslkey.read()) - - def dev_loggers(appl: Flask) -> None: """Default development logging.""" formatter = logging.Formatter( - fmt="[%(asctime)s] %(levelname)s in %(module)s: %(message)s") + fmt="[%(asctime)s] %(levelname)s [%(thread)d -- %(threadName)s] in %(module)s: %(message)s") stderr_handler = logging.StreamHandler(stream=sys.stderr) stderr_handler.setFormatter(formatter) appl.logger.addHandler(stderr_handler) root_logger = logging.getLogger() root_logger.addHandler(stderr_handler) - root_logger.setLevel(appl.config.get("LOGLEVEL", "WARNING")) + root_logger.setLevel(appl.config.get("LOG_LEVEL", "WARNING")) def gunicorn_loggers(appl: Flask) -> None: @@ -93,12 +83,20 @@ def setup_logging(appl: Flask) -> None: app = Flask(__name__) -setup_logging(app) - +## BEGIN: Setup configurations ## # See http://flask.pocoo.org/docs/config/#configuring-from-files # Note no longer use the badly named WQFLASK_OVERRIDES (nyi) app.config.from_object('gn2.default_settings') app.config.from_envvar('GN2_SETTINGS') +app.config["SESSION_REDIS"] = redis.from_url(app.config["REDIS_URL"]) +# BEGIN: SECRETS -- Should be the last of the settings to load +secrets_file = Path(app.config.get("GN2_SECRETS", "")).absolute() +if secrets_file.exists() and secrets_file.is_file(): + app.config.from_pyfile(str(secrets_file)) +# END: SECRETS +## END: Setup configurations ## +setup_logging(app) +### DO NOT USE logging BEFORE THIS POINT!!!! ### app.jinja_env.globals.update( undefined=jinja2.StrictUndefined, @@ -109,14 +107,6 @@ app.jinja_env.globals.update( num_collections=numcoll, datetime=datetime) -app.config["SESSION_REDIS"] = redis.from_url(app.config["REDIS_URL"]) - -## BEGIN: SECRETS -- Should be the last of the settings to load -secrets_file = Path(app.config.get("GN2_SECRETS", "")).absolute() -if secrets_file.exists() and secrets_file.is_file(): - app.config.from_pyfile(str(secrets_file)) -## END: SECRETS - # Registering blueprints app.register_blueprint(glossary_blueprint, url_prefix="/glossary") @@ -127,6 +117,7 @@ app.register_blueprint(environments_blueprint, url_prefix="/environments") app.register_blueprint(facilities_blueprint, url_prefix="/facilities") app.register_blueprint(blogs_blueprint, url_prefix="/blogs") app.register_blueprint(news_blueprint, url_prefix="/news") +app.register_blueprint(xapian_syntax_blueprint, url_prefix="/search-syntax") app.register_blueprint(jupyter_notebooks, url_prefix="/jupyter_notebooks") app.register_blueprint(resource_management, url_prefix="/resource-management") @@ -148,29 +139,11 @@ except StartupError as serr: server_session = Session(app) -parse_ssl_key(app, "SSL_PRIVATE_KEY") -parse_ssl_key(app, "AUTH_SERVER_SSL_PUBLIC_KEY") - @app.before_request def before_request(): g.request_start_time = time.time() g.request_time = lambda: "%.5fs" % (time.time() - g.request_start_time) - token = session.get("oauth2_token", False) - if token and not bool(session.get("user_details", False)): - from gn2.wqflask.oauth2.client import oauth2_client - config = current_app.config - resp = oauth2_client().client.get( - urljoin(config["GN_SERVER_URL"], "oauth2/user")) - user_details = resp.json() - session["user_details"] = user_details - - if user_details.get("error") == "invalid_token": - flash(user_details["error_description"], "alert-danger") - flash("You are now logged out.", "alert-info") - session.pop("user_details", None) - session.pop("oauth2_token", None) - @app.context_processor def include_admin_role_class(): return {'AdminRole': AdminRole} diff --git a/gn2/wqflask/api/markdown.py b/gn2/wqflask/api/markdown.py index 580b9ac0..aa7dd3c4 100644 --- a/gn2/wqflask/api/markdown.py +++ b/gn2/wqflask/api/markdown.py @@ -24,6 +24,7 @@ links_blueprint = Blueprint("links_blueprint", __name__) policies_blueprint = Blueprint("policies_blueprint", __name__) facilities_blueprint = Blueprint("facilities_blueprint", __name__) news_blueprint = Blueprint("news_blueprint", __name__) +xapian_syntax_blueprint = Blueprint("xapian_syntax_blueprint", __name__) blogs_blueprint = Blueprint("blogs_blueprint", __name__) @@ -117,6 +118,13 @@ def news(): rendered_markdown=render_markdown("general/news/news.md")), 200 +@xapian_syntax_blueprint.route('/') +def xapian(): + return render_template( + "search-syntax.html", + rendered_markdown=render_markdown("general/search/xapian_syntax.md")), 200 + + @environments_blueprint.route("/") def environments(): diff --git a/gn2/wqflask/api/router.py b/gn2/wqflask/api/router.py index bcd08e8d..e9b70919 100644 --- a/gn2/wqflask/api/router.py +++ b/gn2/wqflask/api/router.py @@ -379,23 +379,28 @@ def fetch_traits(dataset_name, file_format="json"): if len(trait_ids) > 0: if data_type == "ProbeSet": query = """ - SELECT - ProbeSet.Id, ProbeSet.Name, ProbeSet.Symbol, ProbeSet.description, ProbeSet.Chr, ProbeSet.Mb, ProbeSet.alias, - ProbeSetXRef.mean, ProbeSetXRef.se, ProbeSetXRef.Locus, ProbeSetXRef.LRS, ProbeSetXRef.pValue, ProbeSetXRef.additive, ProbeSetXRef.h2 - FROM - ProbeSet, ProbeSetXRef, ProbeSetFreeze + SELECT DISTINCT + ProbeSet.`Id`, ProbeSet.`Name`, ProbeSet.`Symbol`, ProbeSet.`description`, + ProbeSet.`Chr`, ProbeSet.`Mb`, ProbeSet.`alias`, ProbeSetXRef.`mean`, + ProbeSetXRef.`se`, ProbeSetXRef.`Locus`, ProbeSetXRef.`LRS`, + ProbeSetXRef.`pValue`, ProbeSetXRef.`additive`, ProbeSetXRef.`h2`, + Geno.`Chr`, Geno.`Mb` + FROM + Species + INNER JOIN InbredSet ON InbredSet.`SpeciesId`= Species.`Id` + INNER JOIN ProbeFreeze ON ProbeFreeze.`InbredSetId` = InbredSet.`Id` + INNER JOIN Tissue ON ProbeFreeze.`TissueId` = Tissue.`Id` + INNER JOIN ProbeSetFreeze ON ProbeSetFreeze.`ProbeFreezeId` = ProbeFreeze.`Id` + INNER JOIN ProbeSetXRef ON ProbeSetXRef.`ProbeSetFreezeId` = ProbeSetFreeze.`Id` + INNER JOIN ProbeSet ON ProbeSet.`Id` = ProbeSetXRef.`ProbeSetId` + LEFT JOIN Geno ON ProbeSetXRef.`Locus` = Geno.`Name` AND Geno.`SpeciesId` = Species.`Id` WHERE - ProbeSetXRef.ProbeSetFreezeId = "{0}" AND - ProbeSetXRef.ProbeSetId = ProbeSet.Id AND - ProbeSetXRef.ProbeSetFreezeId = ProbeSetFreeze.Id AND - ProbeSetFreeze.public > 0 AND - ProbeSetFreeze.confidentiality < 1 + ProbeSetXRef.ProbeSetFreezeId = "{0}" ORDER BY - ProbeSet.Id - """ + ProbeSet.Id""" - field_list = ["Id", "Name", "Symbol", "Description", "Chr", "Mb", - "Aliases", "Mean", "SE", "Locus", "LRS", "P-Value", "Additive", "h2"] + field_list = ["Id", "Name", "Symbol", "Description", "Chr", "Mb", "Aliases", "Mean", + "SE", "Locus", "LRS", "P-Value", "Additive", "h2", "Peak Chr", "Peak Mb"] elif data_type == "Geno": query = """ SELECT diff --git a/gn2/wqflask/app_errors.py b/gn2/wqflask/app_errors.py index 503f4e1c..b7b8527b 100644 --- a/gn2/wqflask/app_errors.py +++ b/gn2/wqflask/app_errors.py @@ -6,7 +6,8 @@ import traceback from uuid import uuid4 from werkzeug.exceptions import InternalServerError -from authlib.integrations.base_client.errors import InvalidTokenError +from authlib.integrations.base_client.errors import ( + OAuthError, InvalidTokenError) from flask import ( flash, request, redirect, current_app, render_template, make_response) @@ -46,13 +47,37 @@ def handle_authorisation_error(exc: AuthorisationError): "authorisation_error.html", error_type=type(exc).__name__, error=exc) def handle_invalid_token_error(exc: InvalidTokenError): + """Handle InvalidTokenError""" flash("An invalid session token was detected. " "You have been logged out of the system.", "alert-danger") + current_app.logger.error("Invalid token detected. %s", request.url, exc_info=True) + session.clear_session_info() + return redirect("/") + +def __build_message__(exc: OAuthError) -> str: + """Build up the message to flash for any OAuthError""" + match exc.args[0]: + case "ForbiddenAccess: Token does not belong to client.": + return "An invalid token was used. The session has been cleared." + case "ForbiddenAccess: Token is expired.": + return "The session has expired." + case "ForbiddenAccess: Token has previously been revoked.": + return "Revoked token was used. The session has been cleared." + case _: + return exc.args[0] + +def handle_oauth_error(exc: OAuthError): + """Handle generic OAuthError""" + flash((f"{type(exc).__name__}: {__build_message__(exc)} " + "Please log in again to continue."), + "alert-danger") + current_app.logger.error("Invalid token detected. %s", request.url, exc_info=True) session.clear_session_info() return redirect("/") __handlers__ = { + OAuthError: handle_oauth_error, AuthorisationError: handle_authorisation_error, ExternalRequestError: lambda exc: render_error(exc), InternalServerError: lambda exc: render_error(exc), diff --git a/gn2/wqflask/collect.py b/gn2/wqflask/collect.py index 9e0640a5..dd561a78 100644 --- a/gn2/wqflask/collect.py +++ b/gn2/wqflask/collect.py @@ -72,7 +72,7 @@ def store_traits_list(): return hash -@app.route("/collections/add", methods=["POST"]) +@app.route("/collections/add", methods=('GET', 'POST')) def collections_add(): anon_id = session_info()["anon_id"] traits = request.args.get("traits", request.form.get("traits")) diff --git a/gn2/wqflask/correlation/show_corr_results.py b/gn2/wqflask/correlation/show_corr_results.py index c8625222..1abf1a28 100644 --- a/gn2/wqflask/correlation/show_corr_results.py +++ b/gn2/wqflask/correlation/show_corr_results.py @@ -91,6 +91,8 @@ def apply_filters(trait, target_trait, target_dataset, **filters): min_location_mb, max_location_mb): if target_dataset["type"] in ["ProbeSet", "Geno"] and location_type == "gene": + if not target_trait['mb'] or not target_trait['chr']: + return True return ( ((location_chr!=None) and (target_trait["chr"]!=location_chr)) or @@ -154,7 +156,7 @@ def get_user_filters(start_vars): if all(keys in start_vars for keys in ["loc_chr", "min_loc_mb", - "max_location_mb"]): + "max_loc_mb"]): location_chr = get_string(start_vars, "loc_chr") min_location_mb = get_int(start_vars, "min_loc_mb") diff --git a/gn2/wqflask/do_search.py b/gn2/wqflask/do_search.py index 3c81783d..b64c6fce 100644 --- a/gn2/wqflask/do_search.py +++ b/gn2/wqflask/do_search.py @@ -253,13 +253,15 @@ class PhenotypeSearch(DoSearch): # Todo: Zach will figure out exactly what both these lines mean # and comment here - # if "'" not in self.search_term[0]: - search_term = "%" + \ - self.handle_wildcard(self.search_term[0]) + "%" + search_term = self.search_term[0] if "_" in self.search_term[0]: if len(self.search_term[0].split("_")[0]) == 3: - search_term = "%" + self.handle_wildcard( - self.search_term[0].split("_")[1]) + "%" + search_term = self.search_term[0].split("_")[1] + + if not search_term.isnumeric() or len(search_term) != 5: # To make sure phenotype trait IDs aren't included in a fulltext search + search_term = "%" + \ + self.handle_wildcard(search_term) + "%" + # This adds a clause to the query that matches the search term # against each field in the search_fields tuple diff --git a/gn2/wqflask/gsearch.py b/gn2/wqflask/gsearch.py index cad6db94..2a214cf8 100644 --- a/gn2/wqflask/gsearch.py +++ b/gn2/wqflask/gsearch.py @@ -28,10 +28,16 @@ class GSearch: hmac = curry(3, lambda trait_name, dataset, data_hmac: f"{trait_name}:{dataset}:{data_hmac}") convert_lod = lambda x: x / 4.61 self.trait_list = [] - for i, trait in enumerate(requests.get( + response = requests.get( urljoin(GN3_LOCAL_URL, "/api/search?" + urlencode({"query": self.terms, "type": self.type, - "per_page": MAX_SEARCH_RESULTS}))).json()): + "per_page": MAX_SEARCH_RESULTS}))) + if response.status_code == 400 and "parsererror" in response.text.lower(): + raise ValueError(f"Query `{self.terms}` has a problem: {response.json()}") + response.raise_for_status() + response_json = response.json() + + for i, trait in enumerate(response_json): trait = MonadicDict(trait) trait["index"] = Just(i) trait["location_repr"] = (Maybe.apply(chr_mb) diff --git a/gn2/wqflask/marker_regression/display_mapping_results.py b/gn2/wqflask/marker_regression/display_mapping_results.py index dec414c0..345751ad 100644 --- a/gn2/wqflask/marker_regression/display_mapping_results.py +++ b/gn2/wqflask/marker_regression/display_mapping_results.py @@ -773,8 +773,9 @@ class DisplayMappingResults: pass # draw position, no need to use a separate function - self.drawProbeSetPosition( - canvas, plotXScale, offset=newoffset, zoom=zoom) + if self.traitList[0].mb: # Only draw if position actually exists + self.drawProbeSetPosition( + canvas, plotXScale, offset=newoffset, zoom=zoom) return gifmap @@ -1130,7 +1131,7 @@ class DisplayMappingResults: ) TEXT_Y_DISPLACEMENT = -8 im_drawer.text( - text="Sequence Site", + text="Gene Location", xy=(leftOffset + 20, startPosY + TEXT_Y_DISPLACEMENT), font=smallLabelFont, fill=self.TOP_RIGHT_INFO_COLOR) @@ -1170,20 +1171,20 @@ class DisplayMappingResults: if self.haplotypeAnalystChecked: im_drawer.line( - xy=((startPosX - 34, startPosY), (startPosX - 17, startPosY)), - fill=self.HAPLOTYPE_POSITIVE, width=4) + xy=((startPosX - 60, startPosY), (startPosX - 45, startPosY)), + fill=self.HAPLOTYPE_NEGATIVE, width=8) im_drawer.line( - xy=((startPosX - 17, startPosY), (startPosX, startPosY)), - fill=self.HAPLOTYPE_NEGATIVE, width=4) + xy=((startPosX - 45, startPosY), (startPosX - 30, startPosY)), + fill=self.HAPLOTYPE_POSITIVE, width=8) im_drawer.line( - xy=((startPosX, startPosY), (startPosX + 17, startPosY)), - fill=self.HAPLOTYPE_HETEROZYGOUS, width=4) + xy=((startPosX - 30, startPosY), (startPosX - 15, startPosY)), + fill=self.HAPLOTYPE_HETEROZYGOUS, width=8) im_drawer.line( - xy=((startPosX + 17, startPosY), (startPosX + 34, startPosY)), - fill=self.HAPLOTYPE_RECOMBINATION, width=4) + xy=((startPosX - 15, startPosY), (startPosX, startPosY)), + fill=self.HAPLOTYPE_RECOMBINATION, width=8) im_drawer.text( - text='Haplotypes (Pat, Mat, Het, Unk)', - xy=(startPosX + 41, startPosY + TEXT_Y_DISPLACEMENT), font=labelFont, fill=BLACK) + text='Haplotypes (Mat, Pat, Het, Unknown)', + xy=(startPosX + 10, startPosY + TEXT_Y_DISPLACEMENT), font=labelFont, fill=BLACK) startPosY += stepPosY if self.permChecked and self.nperm > 0: diff --git a/gn2/wqflask/oauth2/checks.py b/gn2/wqflask/oauth2/checks.py index 7f33348e..4a5a117f 100644 --- a/gn2/wqflask/oauth2/checks.py +++ b/gn2/wqflask/oauth2/checks.py @@ -2,12 +2,10 @@ from functools import wraps from urllib.parse import urljoin +from flask import flash, request, redirect from authlib.integrations.requests_client import OAuth2Session -from flask import ( - flash, request, redirect, session as flask_session) from . import session -from .session import clear_session_info from .client import ( oauth2_get, oauth2_client, @@ -24,8 +22,6 @@ def require_oauth2(func): def __clear_session__(_no_token): session.clear_session_info() - flask_session.pop("oauth2_token", None) - flask_session.pop("user_details", None) flash("You need to be logged in.", "alert-warning") return redirect("/") @@ -36,7 +32,7 @@ def require_oauth2(func): if not user_details.get("error", False): return func(*args, **kwargs) - return clear_session_info(token) + return __clear_session__(token) return session.user_token().either(__clear_session__, __with_token__) diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 75e438cc..a7d20f6b 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -1,12 +1,17 @@ """Common oauth2 client utilities.""" import json +import time +import random import requests from typing import Optional from urllib.parse import urljoin +from datetime import datetime, timedelta from flask import current_app as app from pymonad.either import Left, Right, Either -from authlib.jose import jwt +from authlib.common.urls import url_decode +from authlib.jose.errors import BadSignatureError +from authlib.jose import KeySet, JsonWebKey, JsonWebToken from authlib.integrations.requests_client import OAuth2Session from gn2.wqflask.oauth2 import session @@ -31,11 +36,76 @@ def oauth2_clientsecret(): def user_logged_in(): """Check whether the user has logged in.""" suser = session.session_info()["user"] - if suser["logged_in"]: - if session.expired(): - session.clear_session_info() - return False - return suser["token"].is_right() + return suser["logged_in"] and suser["token"].is_right() + + +def __make_token_validator__(keys: KeySet): + """Make a token validator function.""" + def __validator__(token: dict): + for key in keys.keys: + try: + # Fixes CVE-2016-10555. See + # https://docs.authlib.org/en/latest/jose/jwt.html + jwt = JsonWebToken(["RS256"]) + jwt.decode(token["access_token"], key) + return Right(token) + except BadSignatureError: + pass + + return Left("INVALID-TOKEN") + + return __validator__ + + +def auth_server_jwks() -> Optional[KeySet]: + """Fetch the auth-server JSON Web Keys information.""" + _jwks = session.session_info().get("auth_server_jwks") + if bool(_jwks): + return { + "last-updated": _jwks["last-updated"], + "jwks": KeySet([ + JsonWebKey.import_key(key) for key in _jwks.get( + "auth_server_jwks", {}).get( + "jwks", {"keys": []})["keys"]])} + + +def __validate_token__(keys): + """Validate that the token is really from the auth server.""" + def __index__(_sess): + return _sess + return session.user_token().then(__make_token_validator__(keys)).then( + session.set_user_token).either(__index__, __index__) + + +def __update_auth_server_jwks__(): + """Updates the JWKs every 2 hours or so.""" + jwks = auth_server_jwks() + if bool(jwks): + last_updated = jwks.get("last-updated") + now = datetime.now().timestamp() + if bool(last_updated) and (now - last_updated) < timedelta(hours=2).seconds: + return __validate_token__(jwks["jwks"]) + + jwksuri = urljoin(authserver_uri(), "auth/public-jwks") + jwks = KeySet([ + JsonWebKey.import_key(key) + for key in requests.get(jwksuri).json()["jwks"]]) + return __validate_token__(jwks) + + +def is_token_expired(token): + """Check whether the token has expired.""" + __update_auth_server_jwks__() + jwks = auth_server_jwks() + if bool(jwks): + for jwk in jwks["jwks"].keys: + try: + jwt = JsonWebToken(["RS256"]).decode( + token["access_token"], key=jwk) + return datetime.now().timestamp() > jwt["exp"] + except BadSignatureError as _bse: + pass + return False @@ -43,20 +113,56 @@ def oauth2_client(): def __update_token__(token, refresh_token=None, access_token=None): """Update the token when refreshed.""" session.set_user_token(token) + return token + + def __delay__(): + """Do a tiny delay.""" + time.sleep(random.choice(tuple(i/1000.0 for i in range(0,100)))) + + def __refresh_token__(token): + """Synchronise token refresh.""" + if is_token_expired(token): + __delay__() + if session.is_token_refreshing(): + while session.is_token_refreshing(): + __delay__() + + _token = session.user_token().either(None, lambda _tok: _tok) + return _token + + session.toggle_token_refreshing() + _client = __client__(token) + _client.get(urljoin(authserver_uri(), "auth/user/")) + session.toggle_token_refreshing() + return _client.token + + return token + + def __json_auth__(client, method, uri, headers, body): + return ( + uri, + {**headers, "Content-Type": "application/json"}, + json.dumps({ + **dict(url_decode(body)), + "client_id": oauth2_clientid(), + "client_secret": oauth2_clientsecret() + })) def __client__(token) -> OAuth2Session: - _jwt = jwt.decode(token["access_token"], - app.config["AUTH_SERVER_SSL_PUBLIC_KEY"]) client = OAuth2Session( oauth2_clientid(), oauth2_clientsecret(), scope=SCOPE, - token_endpoint=urljoin(authserver_uri(), "/auth/token"), + token_endpoint=urljoin(authserver_uri(), "auth/token"), token_endpoint_auth_method="client_secret_post", token=token, update_token=__update_token__) + client.register_client_auth_method( + ("client_secret_post", __json_auth__)) return client - return session.user_token().either( + + __update_auth_server_jwks__() + return session.user_token().then(__refresh_token__).either( lambda _notok: __client__(None), lambda token: __client__(token)) @@ -70,12 +176,18 @@ def __no_token__(_err) -> Left: resp.status_code = 400 return Left(resp) -def oauth2_get(uri_path: str, data: dict = {}, - jsonify_p: bool = False, **kwargs) -> Either: +def oauth2_get( + uri_path: str, + data: dict = {}, + jsonify_p: bool = False, + headers: dict = {"Content-Type": "application/json"}, + **kwargs +) -> Either: def __get__(token) -> Either: resp = oauth2_client().get( urljoin(authserver_uri(), uri_path), data=data, + headers=headers, **kwargs) if resp.status_code == 200: if jsonify_p: @@ -87,11 +199,18 @@ def oauth2_get(uri_path: str, data: dict = {}, return session.user_token().either(__no_token__, __get__) def oauth2_post( - uri_path: str, data: Optional[dict] = None, json: Optional[dict] = None, - **kwargs) -> Either: + uri_path: str, + data: Optional[dict] = None, + json: Optional[dict] = None, + headers: dict = {"Content-Type": "application/json"}, + **kwargs +) -> Either: def __post__(token) -> Either: resp = oauth2_client().post( - urljoin(authserver_uri(), uri_path), data=data, json=json, + urljoin(authserver_uri(), uri_path), + data=data, + json=json, + headers=headers, **kwargs) if resp.status_code == 200: return Right(resp.json()) @@ -100,10 +219,14 @@ def oauth2_post( return session.user_token().either(__no_token__, __post__) -def no_token_get(uri_path: str, **kwargs) -> Either: +def no_token_get( + uri_path: str, + headers: dict = {"Content-Type": "application/json"}, + **kwargs +) -> Either: uri = urljoin(authserver_uri(), uri_path) try: - resp = requests.get(uri, **kwargs) + resp = requests.get(uri, headers=headers, **kwargs) if resp.status_code == 200: return Right(resp.json()) return Left(resp) diff --git a/gn2/wqflask/oauth2/data.py b/gn2/wqflask/oauth2/data.py index 29d68be0..16e5f60c 100644 --- a/gn2/wqflask/oauth2/data.py +++ b/gn2/wqflask/oauth2/data.py @@ -69,8 +69,10 @@ def __search_phenotypes__(query, template, **kwargs): template, traits=[], per_page=per_page, query=query, selected_traits=selected_traits, search_results=search_results, search_endpoint=urljoin( - authserver_uri(), "auth/data/search"), - gn_server_url = authserver_uri(), + request.host_url, "oauth2/data/phenotype/search"), + auth_server_url=authserver_uri(), + pheno_results_template=urljoin( + authserver_uri(), "auth/data/search/phenotype/<jobid>"), results_endpoint=urljoin( authserver_uri(), f"auth/data/search/phenotype/{job_id}"), @@ -122,6 +124,7 @@ def json_search_mrna() -> Response: @data.route("/phenotype/search", methods=["POST"]) def json_search_phenotypes() -> Response: """Search for phenotypes.""" + from gn2.utility.tools import GN_SERVER_URL form = request.json def __handle_error__(err): error = process_error(err) @@ -136,6 +139,7 @@ def json_search_phenotypes() -> Response: "per_page": int(form.get("per_page", 50)), "page": int(form.get("page", 1)), "auth_server_uri": authserver_uri(), + "gn3_server_uri": GN_SERVER_URL, "selected_traits": form.get("selected_traits", []) }).either(__handle_error__, jsonify) diff --git a/gn2/wqflask/oauth2/groups.py b/gn2/wqflask/oauth2/groups.py index 3bc4bcb2..b3f1f54d 100644 --- a/gn2/wqflask/oauth2/groups.py +++ b/gn2/wqflask/oauth2/groups.py @@ -44,7 +44,7 @@ def create_group(): def __setup_group__(response): session["user_details"]["group"] = response - resp = oauth2_post("auth/group/create", data=dict(request.form)) + resp = oauth2_post("auth/group/create", json=dict(request.form)) return resp.either( handle_error("oauth2.group.join_or_create"), handle_success( @@ -116,7 +116,7 @@ def accept_join_request(): return redirect(url_for("oauth2.group.list_join_requests")) return oauth2_post( "auth/group/requests/join/accept", - data=request.form).either( + json=dict(request.form)).either( handle_error("oauth2.group.list_join_requests"), __success__) @@ -132,34 +132,10 @@ def reject_join_request(): return redirect(url_for("oauth2.group.list_join_requests")) return oauth2_post( "auth/group/requests/join/reject", - data=request.form).either( + json=dict(request.form)).either( handle_error("oauth2.group.list_join_requests"), __success__) -@groups.route("/role/<uuid:group_role_id>", methods=["GET"]) -@require_oauth2 -def group_role(group_role_id: uuid.UUID): - """View the details of a particular role.""" - def __render_error__(**kwargs): - return render_ui("oauth2/view-group-role.html", **kwargs) - - def __gprivs_success__(role, group_privileges): - return render_ui( - "oauth2/view-group-role.html", group_role=role, - group_privileges=tuple( - priv for priv in group_privileges - if priv not in role["role"]["privileges"])) - - def __role_success__(role): - return oauth2_get("auth/group/privileges").either( - lambda err: __render_error__( - group_role=group_role, - group_privileges_error=process_error(err)), - lambda privileges: __gprivs_success__(role, privileges)) - - return oauth2_get(f"auth/group/role/{group_role_id}").either( - lambda err: __render_error__(group_role_error=process_error(err)), - __role_success__) def add_delete_privilege_to_role( group_role_id: uuid.UUID, direction: str) -> Response: @@ -187,7 +163,7 @@ def add_delete_privilege_to_role( } return oauth2_post( uris[direction], - data={ + json={ "group_role_id": group_role_id, "privilege_id": privilege_id }).either(__error__, __success__) diff --git a/gn2/wqflask/oauth2/jwks.py b/gn2/wqflask/oauth2/jwks.py new file mode 100644 index 00000000..efd04997 --- /dev/null +++ b/gn2/wqflask/oauth2/jwks.py @@ -0,0 +1,86 @@ +"""Utilities dealing with JSON Web Keys (JWK)""" +import os +from pathlib import Path +from typing import Any, Union +from datetime import datetime, timedelta + +from flask import Flask +from authlib.jose import JsonWebKey +from pymonad.either import Left, Right, Either + +def jwks_directory(app: Flask, configname: str) -> Path: + """Compute the directory where the JWKs are stored.""" + appsecretsdir = Path(app.config[configname]).parent + if appsecretsdir.exists() and appsecretsdir.is_dir(): + jwksdir = Path(appsecretsdir, "jwks/") + if not jwksdir.exists(): + jwksdir.mkdir() + return jwksdir + raise ValueError( + "The `appsecretsdir` value should be a directory that actually exists.") + + +def generate_and_save_private_key( + storagedir: Path, + kty: str = "RSA", + crv_or_size: Union[str, int] = 2048, + options: tuple[tuple[str, Any]] = (("iat", datetime.now().timestamp()),) +) -> JsonWebKey: + """Generate a private key and save to `storagedir`.""" + privatejwk = JsonWebKey.generate_key( + kty, crv_or_size, dict(options), is_private=True) + keyname = f"{privatejwk.thumbprint()}.private.pem" + with open(Path(storagedir, keyname), "wb") as pemfile: + pemfile.write(privatejwk.as_pem(is_private=True)) + + return privatejwk + + +def pem_to_jwk(filepath: Path) -> JsonWebKey: + """Parse a PEM file into a JWK object.""" + with open(filepath, "rb") as pemfile: + return JsonWebKey.import_key(pemfile.read()) + + +def __sorted_jwks_paths__(storagedir: Path) -> tuple[tuple[float, Path], ...]: + """A sorted list of the JWK file paths with their creation timestamps.""" + return tuple(sorted(((os.stat(keypath).st_ctime, keypath) + for keypath in (Path(storagedir, keyfile) + for keyfile in os.listdir(storagedir) + if keyfile.endswith(".pem"))), + key=lambda tpl: tpl[0])) + + +def list_jwks(storagedir: Path) -> tuple[JsonWebKey, ...]: + """ + List all the JWKs in a particular directory in the order they were created. + """ + return tuple(pem_to_jwk(keypath) for ctime,keypath in + __sorted_jwks_paths__(storagedir)) + + +def newest_jwk(storagedir: Path) -> Either: + """ + Return an Either monad with the newest JWK or a message if none exists. + """ + existingkeys = __sorted_jwks_paths__(storagedir) + if len(existingkeys) > 0: + return Right(pem_to_jwk(existingkeys[-1][1])) + return Left("No JWKs exist") + + +def newest_jwk_with_rotation(jwksdir: Path, keyage: int) -> JsonWebKey: + """ + Retrieve the latests JWK, creating a new one if older than `keyage` days. + """ + def newer_than_days(jwkey): + filestat = os.stat(Path( + jwksdir, f"{jwkey.as_dict()['kid']}.private.pem")) + oldesttimeallowed = (datetime.now() - timedelta(days=keyage)) + if filestat.st_ctime < (oldesttimeallowed.timestamp()): + return Left("JWK is too old!") + return jwkey + + return newest_jwk(jwksdir).then(newer_than_days).either( + lambda _errmsg: generate_and_save_private_key(jwksdir), + lambda key: key) diff --git a/gn2/wqflask/oauth2/request_utils.py b/gn2/wqflask/oauth2/request_utils.py index 1cdc465f..456aba2b 100644 --- a/gn2/wqflask/oauth2/request_utils.py +++ b/gn2/wqflask/oauth2/request_utils.py @@ -36,7 +36,7 @@ def process_error(error: Response, try: err = error.json() msg = err.get( - "error", err.get("error_description", f"{error.reason}")) + "error_message", err.get("error_description", f"{error.reason}")) except simplejson.errors.JSONDecodeError as _jde: msg = message return { diff --git a/gn2/wqflask/oauth2/resources.py b/gn2/wqflask/oauth2/resources.py index 32efbd2a..7ea7fe38 100644 --- a/gn2/wqflask/oauth2/resources.py +++ b/gn2/wqflask/oauth2/resources.py @@ -1,17 +1,25 @@ -import uuid +from uuid import UUID from flask import ( flash, request, url_for, redirect, Response, Blueprint) from . import client -from .ui import render_ui +from . import session +from .ui import render_ui as _render_ui from .checks import require_oauth2 from .client import oauth2_get, oauth2_post -from .request_utils import ( - flash_error, flash_success, request_error, process_error) +from .request_utils import (flash_error, + flash_success, + request_error, + process_error, + with_flash_error, + with_flash_success) resources = Blueprint("resource", __name__) +def render_ui(template, **kwargs): + return _render_ui(template, uipages="resources", **kwargs) + @resources.route("/", methods=["GET"]) @require_oauth2 def user_resources(): @@ -51,7 +59,7 @@ def create_resource(): flash("Resource created successfully", "alert-success") return redirect(url_for("oauth2.resource.user_resources")) return oauth2_post( - "auth/resource/create", data=request.form).either( + "auth/resource/create", json=dict(request.form)).either( __perr__, __psuc__) def __compute_page__(submit, current_page): @@ -59,47 +67,48 @@ def __compute_page__(submit, current_page): return current_page + 1 return (current_page - 1) or 1 -@resources.route("/view/<uuid:resource_id>", methods=["GET"]) +@resources.route("/<uuid:resource_id>/view", methods=["GET"]) @require_oauth2 -def view_resource(resource_id: uuid.UUID): +def view_resource(resource_id: UUID): """View the given resource.""" page = __compute_page__(request.args.get("submit"), int(request.args.get("page", "1"), base=10)) count_per_page = int(request.args.get("count_per_page", "100"), base=10) def __users_success__( - resource, unlinked_data, users_n_roles, this_user, group_roles, + resource, unlinked_data, users_n_roles, this_user, resource_roles, users): return render_ui( "oauth2/view-resource.html", resource=resource, unlinked_data=unlinked_data, users_n_roles=users_n_roles, - this_user=this_user, group_roles=group_roles, users=users, + this_user=this_user, resource_roles=resource_roles, users=users, page=page, count_per_page=count_per_page) - def __group_roles_success__( - resource, unlinked_data, users_n_roles, this_user, group_roles): + def __resource_roles_success__( + resource, unlinked_data, users_n_roles, this_user, resource_roles): return oauth2_get("auth/user/list").either( lambda err: render_ui( "oauth2/view-resource.html", resource=resource, unlinked_data=unlinked_data, users_n_roles=users_n_roles, - this_user=this_user, group_roles=group_roles, + this_user=this_user, resource_roles=resource_roles, users_error=process_error(err), count_per_page=count_per_page), lambda users: __users_success__( - resource, unlinked_data, users_n_roles, this_user, group_roles, + resource, unlinked_data, users_n_roles, this_user, resource_roles, users)) def __this_user_success__(resource, unlinked_data, users_n_roles, this_user): - return oauth2_get("auth/group/roles").either( + return oauth2_get(f"auth/resource/{resource_id}/roles").either( lambda err: render_ui( - "oauth2/view-resources.html", resource=resource, + "oauth2/view-resource.html", resource=resource, unlinked_data=unlinked_data, users_n_roles=users_n_roles, - this_user=this_user, group_roles_error=process_error(err)), - lambda groles: __group_roles_success__( - resource, unlinked_data, users_n_roles, this_user, groles)) + this_user=this_user, resource_roles_error=process_error(err), + count_per_page=count_per_page), + lambda rroles: __resource_roles_success__( + resource, unlinked_data, users_n_roles, this_user, rroles)) def __users_n_roles_success__(resource, unlinked_data, users_n_roles): return oauth2_get("auth/user/").either( lambda err: render_ui( - "oauth2/view-resources.html", + "oauth2/view-resource.html", this_user_error=process_error(err)), lambda usr_dets: __this_user_success__( resource, unlinked_data, users_n_roles, usr_dets)) @@ -120,8 +129,10 @@ def view_resource(resource_id: uuid.UUID): dataset_type = resource["resource_category"]["resource_category_key"] return oauth2_get(f"auth/group/{dataset_type}/unlinked-data").either( lambda err: render_ui( - "oauth2/view-resource.html", resource=resource, - unlinked_error=process_error(err)), + "oauth2/view-resource.html", + resource=resource, + unlinked_error=process_error(err), + count_per_page=count_per_page), lambda unlinked: __unlinked_success__(resource, unlinked)) def __fetch_resource_data__(resource): @@ -164,7 +175,7 @@ def link_data_to_resource(): flash(f"Data linked to resource successfully", "alert-success") return redirect(url_for( "oauth2.resource.view_resource", resource_id=resource_id)) - return oauth2_post("auth/resource/data/link", data=dict(form)).either( + return oauth2_post("auth/resource/data/link", json=dict(form)).either( __error__, __success__) except AssertionError as aserr: @@ -193,7 +204,7 @@ def unlink_data_from_resource(): return redirect(url_for( "oauth2.resource.view_resource", resource_id=resource_id)) return oauth2_post( - "auth/resource/data/unlink", data=dict(form)).either( + "auth/resource/data/unlink", json=dict(form)).either( __error__, __success__) except AssertionError as aserr: flash(aserr.args[0], "alert-danger") @@ -202,12 +213,12 @@ def unlink_data_from_resource(): @resources.route("<uuid:resource_id>/user/assign", methods=["POST"]) @require_oauth2 -def assign_role(resource_id: uuid.UUID) -> Response: +def assign_role(resource_id: UUID) -> Response: form = request.form - group_role_id = form.get("group_role_id", "") + role_id = form.get("role_id", "") user_email = form.get("user_email", "") try: - assert bool(group_role_id), "The role must be provided." + assert bool(role_id), "The role must be provided." assert bool(user_email), "The user email must be provided." def __assign_error__(error): @@ -223,22 +234,22 @@ def assign_role(resource_id: uuid.UUID) -> Response: return oauth2_post( f"auth/resource/{resource_id}/user/assign", - data={ - "group_role_id": group_role_id, + json={ + "role_id": role_id, "user_email": user_email }).either(__assign_error__, __assign_success__) except AssertionError as aserr: flash(aserr.args[0], "alert-danger") - return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id)) + return redirect(url_for("oauth2.resource.view_resource", resource_id=resource_id)) @resources.route("<uuid:resource_id>/user/unassign", methods=["POST"]) @require_oauth2 -def unassign_role(resource_id: uuid.UUID) -> Response: +def unassign_role(resource_id: UUID) -> Response: form = request.form - group_role_id = form.get("group_role_id", "") + role_id = form.get("role_id", "") user_id = form.get("user_id", "") try: - assert bool(group_role_id), "The role must be provided." + assert bool(role_id), "The role must be provided." assert bool(user_id), "The user id must be provided." def __unassign_error__(error): @@ -254,17 +265,17 @@ def unassign_role(resource_id: uuid.UUID) -> Response: return oauth2_post( f"auth/resource/{resource_id}/user/unassign", - data={ - "group_role_id": group_role_id, + json={ + "role_id": role_id, "user_id": user_id }).either(__unassign_error__, __unassign_success__) except AssertionError as aserr: flash(aserr.args[0], "alert-danger") - return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id)) + return redirect(url_for("oauth2.resource.view_resource", resource_id=resource_id)) @resources.route("/toggle/<uuid:resource_id>", methods=["POST"]) @require_oauth2 -def toggle_public(resource_id: uuid.UUID): +def toggle_public(resource_id: UUID): """Toggle the given resource's public status.""" def __handle_error__(err): flash_error(process_error(err)) @@ -277,18 +288,169 @@ def toggle_public(resource_id: uuid.UUID): "oauth2.resource.view_resource", resource_id=resource_id)) return oauth2_post( - f"auth/resource/{resource_id}/toggle-public", data={}).either( + f"auth/resource/{resource_id}/toggle-public").either( lambda err: __handle_error__(err), lambda suc: __handle_success__(suc)) @resources.route("/edit/<uuid:resource_id>", methods=["GET"]) @require_oauth2 -def edit_resource(resource_id: uuid.UUID): +def edit_resource(resource_id: UUID): """Edit the given resource.""" return "WOULD Edit THE GIVEN RESOURCE'S DETAILS" @resources.route("/delete/<uuid:resource_id>", methods=["GET"]) @require_oauth2 -def delete_resource(resource_id: uuid.UUID): +def delete_resource(resource_id: UUID): """Delete the given resource.""" return "WOULD DELETE THE GIVEN RESOURCE" + +@resources.route("/<uuid:resource_id>/roles/<uuid:role_id>", methods=["GET"]) +@require_oauth2 +def view_resource_role(resource_id: UUID, role_id: UUID): + """View resource role page.""" + def __render_template__(**kwargs): + return render_ui("oauth2/view-resource-role.html", **kwargs) + + def __fetch_users__(resource, role, unassigned_privileges): + return oauth2_get( + f"auth/resource/{resource_id}/role/{role_id}/users").either( + lambda error: __render_template__( + resource=resource, + role=role, + unassigned_privileges=unassigned_privileges, + user_error=process_error(error)), + lambda users: __render_template__( + resource=resource, + role=role, + unassigned_privileges=unassigned_privileges, + users=users)) + + def __fetch_all_roles__(resource, role): + return oauth2_get(f"auth/resource/{resource_id}/roles").either( + lambda error: __render_template__( + all_roles_error=process_error(error)), + lambda all_roles: __fetch_users__( + resource=resource, + role=role, + unassigned_privileges=[ + priv for role in all_roles + for priv in role["privileges"] + if priv not in role["privileges"] + ])) + + def __fetch_resource_role__(resource): + return oauth2_get( + f"auth/resource/{resource_id}/role/{role_id}").either( + lambda error: __render_template__( + resource=resource, + role_id=role_id, + role_error=process_error(error)), + lambda role: __fetch_all_roles__(resource, role)) + + return oauth2_get( + f"auth/resource/view/{resource_id}").either( + lambda error: __render_template__( + resource_error=process_error(error)), + lambda resource: __fetch_resource_role__(resource=resource)) + +@resources.route("/<uuid:resource_id>/roles/<uuid:role_id>/unassign-privilege", + methods=["GET", "POST"]) +@require_oauth2 +def unassign_privilege_from_resource_role(resource_id: UUID, role_id: UUID): + """Remove a privilege from a resource role.""" + form = request.form + returnto = redirect(url_for("oauth2.resource.view_resource_role", + resource_id=resource_id, + role_id=role_id)) + privilege_id = (request.args.get("privilege_id") + or form.get("privilege_id")) + if not privilege_id: + flash("You need to specify a privilege to unassign.", "alert-danger") + return returnto + + if request.method=="POST" and form.get("confirm") == "Unassign": + return oauth2_post( + f"auth/resource/{resource_id}/role/{role_id}/unassign-privilege", + json={ + "privilege_id": form["privilege_id"] + }).either(with_flash_error(returnto), with_flash_success(returnto)) + + if form.get("confirm") == "Cancel": + flash("Cancelled the operation to unassign the privilege.", + "alert-info") + return returnto + + def __fetch_privilege__(resource, role): + return oauth2_get( + f"auth/privileges/{privilege_id}/view").either( + with_flash_error(returnto), + lambda privilege: render_ui( + "oauth2/confirm-resource-role-unassign-privilege.html", + resource=resource, + role=role, + privilege=privilege)) + + def __fetch_resource_role__(resource): + return oauth2_get( + f"auth/resource/{resource_id}/role/{role_id}").either( + with_flash_error(returnto), + lambda role: __fetch_privilege__(resource, role)) + + return oauth2_get( + f"auth/resource/view/{resource_id}").either( + with_flash_error(returnto), + __fetch_resource_role__) + + +@resources.route("/<uuid:resource_id>/roles/create-role", + methods=["GET", "POST"]) +@require_oauth2 +def create_resource_role(resource_id: UUID): + """Create new role for the resource.""" + def __render__(**kwargs): + return render_ui("oauth2/create-role.html", **kwargs) + + def __fetch_resource_roles__(resource): + user = session.session_info()["user"] + return oauth2_get( + f"auth/resource/{resource_id}/users/{user['user_id']}" + "/roles").either( + lambda error: { + "resource": resource, + "resource_role_error": process_error(error) + }, + lambda roles: {"resource": resource, "roles": roles}) + + if request.method == "GET": + return oauth2_get(f"auth/resource/view/{resource_id}").map( + __fetch_resource_roles__).either( + lambda error: __render__(resource_error=error), + lambda kwargs: __render__(**kwargs)) + + formdata = request.form + privileges = formdata.getlist("privileges[]") + if not bool(privileges): + flash( + "You must provide at least one privilege for creation of the new " + "role.", + "alert-danger") + return redirect(url_for("oauth2.resource.create_resource_role", + resource_id=resource_id)) + + def __handle_error__(error): + flash_error(process_error(error)) + return redirect(url_for( + "oauth2.resource.create_resource_role", resource_id=resource_id)) + + def __handle_success__(success): + flash("Role successfully created.", "alert-success") + return redirect(url_for( + "oauth2.resource.view_resource", resource_id=resource_id)) + + return oauth2_post( + f"auth/resource/{resource_id}/roles/create", + json={ + "role_name": formdata["role_name"], + "privileges": privileges + }).either( + __handle_error__, __handle_success__) diff --git a/gn2/wqflask/oauth2/roles.py b/gn2/wqflask/oauth2/roles.py index 2fe35f9b..2a21670e 100644 --- a/gn2/wqflask/oauth2/roles.py +++ b/gn2/wqflask/oauth2/roles.py @@ -10,31 +10,6 @@ from .request_utils import request_error, process_error roles = Blueprint("role", __name__) -@roles.route("/user", methods=["GET"]) -@require_oauth2 -def user_roles(): - def __grerror__(roles, user_privileges, error): - return render_ui( - "oauth2/list_roles.html", roles=roles, - user_privileges=user_privileges, - group_roles_error=process_error(error)) - - def __grsuccess__(roles, user_privileges, group_roles): - return render_ui( - "oauth2/list_roles.html", roles=roles, - user_privileges=user_privileges, group_roles=group_roles) - - def __role_success__(roles): - uprivs = tuple( - privilege["privilege_id"] for role in roles - for privilege in role["privileges"]) - return oauth2_get("auth/group/roles").either( - lambda err: __grerror__(roles, uprivs, err), - lambda groles: __grsuccess__(roles, uprivs, groles)) - - return oauth2_get("auth/system/roles").either( - request_error, __role_success__) - @roles.route("/role/<uuid:role_id>", methods=["GET"]) @require_oauth2 def role(role_id: uuid.UUID): @@ -46,54 +21,3 @@ def role(role_id: uuid.UUID): return oauth2_get(f"auth/role/view/{role_id}").either( request_error, __success__) -@roles.route("/create", methods=["GET", "POST"]) -@require_oauth2 -def create_role(): - """Create a new role.""" - def __roles_error__(error): - return render_ui( - "oauth2/create-role.html", roles_error=process_error(error)) - - def __gprivs_error__(roles, error): - return render_ui( - "oauth2/create-role.html", roles=roles, - group_privileges_error=process_error(error)) - - def __success__(roles, gprivs): - uprivs = tuple( - privilege["privilege_id"] for role in roles - for privilege in role["privileges"]) - return render_ui( - "oauth2/create-role.html", roles=roles, user_privileges=uprivs, - group_privileges=gprivs, - prev_role_name=request.args.get("role_name")) - - def __fetch_gprivs__(roles): - return oauth2_get("auth/group/privileges").either( - lambda err: __gprivs_error__(roles, err), - lambda gprivs: __success__(roles, gprivs)) - - if request.method == "GET": - return oauth2_get("auth/user/roles").either( - __roles_error__, __fetch_gprivs__) - - form = request.form - role_name = form.get("role_name") - privileges = form.getlist("privileges[]") - if len(privileges) == 0: - flash("You must assign at least one privilege to the role", - "alert-danger") - return redirect(url_for( - "oauth2.role.create_role", role_name=role_name)) - def __create_error__(error): - err = process_error(error) - flash(f"{err['error']}: {err['error_description']}", - "alert-danger") - return redirect(url_for("oauth2.role.create_role")) - def __create_success__(*args): - flash("Role created successfully.", "alert-success") - return redirect(url_for("oauth2.role.user_roles")) - return oauth2_post( - "auth/group/role/create",data={ - "role_name": role_name, "privileges[]": privileges}).either( - __create_error__,__create_success__) diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py index 2ef534e2..b91534b0 100644 --- a/gn2/wqflask/oauth2/session.py +++ b/gn2/wqflask/oauth2/session.py @@ -22,6 +22,8 @@ class SessionInfo(TypedDict): user_agent: str ip_addr: str masquerade: Optional[UserDetails] + refreshing_token: bool + auth_server_jwks: Optional[dict[str, Any]] __SESSION_KEY__ = "GN::2::session_info" # Do not use this outside this module!! @@ -61,16 +63,10 @@ def session_info() -> SessionInfo: "user_agent": request.headers.get("User-Agent"), "ip_addr": request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr), - "masquerading": None + "masquerading": None, + "token_refreshing": False })) -def expired(): - the_session = session_info() - def __expired__(token): - return datetime.now() > datetime.fromtimestamp(token["expires_at"]) - return the_session["user"]["token"].either( - lambda left: False, - __expired__) def set_user_token(token: str) -> SessionInfo: """Set the user's token.""" @@ -109,3 +105,16 @@ def unset_masquerading(): "user": the_session["masquerading"], "masquerading": None }) + + +def toggle_token_refreshing(): + """Toggle the state of the token_refreshing variable.""" + _session = session_info() + return save_session_info({ + **_session, + "token_refreshing": not _session.get("token_refreshing", False)}) + + +def is_token_refreshing(): + """Returns whether the token is being refreshed or not.""" + return session_info().get("token_refreshing", False) diff --git a/gn2/wqflask/oauth2/toplevel.py b/gn2/wqflask/oauth2/toplevel.py index 23965cc1..24d60311 100644 --- a/gn2/wqflask/oauth2/toplevel.py +++ b/gn2/wqflask/oauth2/toplevel.py @@ -3,11 +3,17 @@ import uuid import datetime from urllib.parse import urljoin, urlparse, urlunparse -from authlib.jose import jwt -from flask import ( - flash, request, Blueprint, url_for, redirect, render_template, - current_app as app) +from authlib.jose import jwt, KeySet +from flask import (flash, + request, + url_for, + jsonify, + redirect, + Blueprint, + render_template, + current_app as app) +from . import jwks from . import session from .checks import require_oauth2 from .request_utils import user_details, process_error @@ -29,7 +35,9 @@ def authorisation_code(): code = request.args.get("code", "") if bool(code): base_url = urlparse(request.base_url, scheme=request.scheme) - jwtkey = app.config["SSL_PRIVATE_KEY"] + jwtkey = jwks.newest_jwk_with_rotation( + jwks.jwks_directory(app, "GN2_SECRETS"), + int(app.config["JWKS_ROTATION_AGE_DAYS"])) issued = datetime.datetime.now() request_data = { "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", @@ -47,7 +55,7 @@ def authorisation_code(): "iss": str(oauth2_clientid()), "sub": request.args["user_id"], "aud": urljoin(authserver_uri(), "auth/token"), - "exp": (issued + datetime.timedelta(minutes=5)), + "exp": (issued + datetime.timedelta(minutes=5)).timestamp(), "nbf": int(issued.timestamp()), "iat": int(issued.timestamp()), "jti": str(uuid.uuid4())}, @@ -75,8 +83,17 @@ def authorisation_code(): }) return redirect("/") - return no_token_post( - "auth/token", data=request_data).either( - lambda err: __error__(process_error(err)), __success__) + return no_token_post("auth/token", json=request_data).either( + lambda err: __error__(process_error(err)), __success__) flash("AuthorisationError: No code was provided.", "alert-danger") return redirect("/") + + +@toplevel.route("/public-jwks", methods=["GET"]) +def public_jwks(): + """Provide endpoint that returns the public keys.""" + return jsonify({ + "documentation": "The keys are listed in order of creation.", + "jwks": KeySet(jwks.list_jwks( + jwks.jwks_directory(app, "GN2_SECRETS"))).as_dict().get("keys") + }) diff --git a/gn2/wqflask/oauth2/ui.py b/gn2/wqflask/oauth2/ui.py index cf2e9af7..04095420 100644 --- a/gn2/wqflask/oauth2/ui.py +++ b/gn2/wqflask/oauth2/ui.py @@ -1,22 +1,17 @@ """UI utilities""" -from flask import session, render_template +from flask import render_template from .client import oauth2_get -from .client import user_logged_in -from .request_utils import process_error def render_ui(templatepath: str, **kwargs): """Handle repetitive UI rendering stuff.""" - roles = kwargs.get("roles", tuple()) # Get roles if already provided - if user_logged_in() and not bool(roles): # If not, try fetching them - roles_results = oauth2_get("auth/system/roles").either( - lambda err: {"roles_error": process_error(err)}, - lambda roles: {"roles": roles}) - kwargs = {**kwargs, **roles_results} + roles = kwargs.get("roles", tuple()) # Get roles + if not roles: + roles = oauth2_get("auth/system/roles").either( + lambda _err: roles, lambda auth_roles: auth_roles) user_privileges = tuple( - privilege["privilege_id"] for role in roles - for privilege in role["privileges"]) + privilege["privilege_id"] for role in roles for privilege in role["privileges"]) kwargs = { - **kwargs, "roles": roles, "user_privileges": user_privileges + **kwargs, "roles": roles, "user_privileges": user_privileges, } return render_template(templatepath, **kwargs) diff --git a/gn2/wqflask/oauth2/users.py b/gn2/wqflask/oauth2/users.py index 8a935170..7d9186ab 100644 --- a/gn2/wqflask/oauth2/users.py +++ b/gn2/wqflask/oauth2/users.py @@ -1,6 +1,6 @@ import requests from uuid import UUID -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from authlib.integrations.base_client.errors import OAuthError from flask import ( @@ -11,10 +11,16 @@ from . import client from . import session from .ui import render_ui from .checks import require_oauth2 -from .client import (oauth2_get, oauth2_post, oauth2_client, - authserver_uri, user_logged_in) -from .request_utils import ( - user_details, request_error, process_error, with_flash_error) +from .client import (oauth2_get, + oauth2_post, + oauth2_client, + authserver_uri, + user_logged_in) +from .request_utils import (user_details, + request_error, + process_error, + with_flash_error, + authserver_authorise_uri) users = Blueprint("user", __name__) @@ -61,7 +67,7 @@ def request_add_to_group() -> Response: return redirect(url_for("oauth2.user.user_profile")) return oauth2_post(f"auth/group/requests/join/{group_id}", - data=form).either(__error__, __success__) + json=form).either(__error__, __success__) @users.route("/logout", methods=["GET", "POST"]) @@ -84,7 +90,8 @@ def logout(): f"{the_session['masquerading']['name']} " f"({the_session['masquerading']['email']})", "alert-success") - return redirect("/") + + return redirect("/") @users.route("/register", methods=["GET", "POST"]) def register_user(): @@ -101,11 +108,14 @@ def register_user(): form = request.form response = requests.post( urljoin(authserver_uri(), "auth/user/register"), - data = { + json = { "user_name": form.get("user_name"), "email": form.get("email_address"), "password": form.get("password"), - "confirm_password": form.get("confirm_password")}) + "confirm_password": form.get("confirm_password"), + **dict( + item.split("=") for item in + urlparse(authserver_authorise_uri()).query.split("&"))}) results = response.json() if "error" in results: error_messages = tuple( diff --git a/gn2/wqflask/parser.py b/gn2/wqflask/parser.py index ddf48d90..fdd5d164 100644 --- a/gn2/wqflask/parser.py +++ b/gn2/wqflask/parser.py @@ -30,7 +30,7 @@ def parse(pstring): pstring = re.split(r"""(?:(\w+\s*=\s*[\('"\[][^)'"]*[\)\]'"]) | # LRS=(1 2 3), cisLRS=[4 5 6], etc (\w+\s*[=:\>\<][\w\*]+) | # wiki=bar, GO:foobar, etc (".*?") | ('.*?') | # terms in quotes, i.e. "brain weight" - ([\w\*\?\-]+)) # shh, brain, etc """, pstring, + ([\w\*\?\-]+\@\.)) # shh, brain, etc """, pstring, flags=re.VERBOSE) pstring = [item.strip() for item in pstring if item and item.strip()] diff --git a/gn2/wqflask/requests.py b/gn2/wqflask/requests.py index 43c8001f..182201a5 100644 --- a/gn2/wqflask/requests.py +++ b/gn2/wqflask/requests.py @@ -14,3 +14,13 @@ def get(url, params=None, **kwargs) -> Either: def post(url, data=None, json=None, **kwargs) -> Either: """Wrap requests post method with Either monad""" return __wrap_response__(requests.post(url, data=data, json=json, **kwargs)) + + +def put(url, data=None, json=None, **kwargs) -> Either: + """Wrap requests put method with Either monad""" + return __wrap_response__(requests.put(url, data=data, json=json, **kwargs)) + + +def delete(url, **kwargs) -> Either: + """Wrap requests delete method with Either monad""" + return __wrap_response__(requests.delete(url, **kwargs)) diff --git a/gn2/wqflask/search_results.py b/gn2/wqflask/search_results.py index b0f08463..1d89e52a 100644 --- a/gn2/wqflask/search_results.py +++ b/gn2/wqflask/search_results.py @@ -2,12 +2,18 @@ import uuid from math import * import requests import unicodedata +from urllib.parse import urlencode, urljoin import re import json +from pymonad.maybe import Just, Maybe +from pymonad.tools import curry + from flask import g +from gn3.monads import MonadicDict + from gn2.base.data_set import create_dataset from gn2.base.webqtlConfig import PUBMEDLINK_URL from gn2.wqflask import parser @@ -15,11 +21,12 @@ from gn2.wqflask import do_search from gn2.wqflask.database import database_connection -from gn2.utility import hmac from gn2.utility.authentication_tools import check_resource_availability -from gn2.utility.tools import get_setting, GN2_BASE_URL +from gn2.utility.hmac import hmac_creation +from gn2.utility.tools import get_setting, GN2_BASE_URL, GN3_LOCAL_URL from gn2.utility.type_checking import is_str +MAX_SEARCH_RESULTS = 50000 # Max number of search results, passed to Xapian search (this needs to match the value in GN3!) class SearchResultPage: #maxReturn = 3000 @@ -36,6 +43,7 @@ class SearchResultPage: self.uc_id = uuid.uuid4() self.go_term = None + self.search_type = "sql" # Assume it's an SQL search by default, since all searches will work with SQL if kw['search_terms_or']: self.and_or = "or" @@ -89,7 +97,6 @@ class SearchResultPage: """ trait_list = [] - json_trait_list = [] # result_set represents the results for each search term; a search of # "shh grin2b" would have two sets of results, one for each term @@ -105,97 +112,137 @@ class SearchResultPage: if not result: continue - trait_dict = {} - trait_dict['index'] = index + 1 - - trait_dict['dataset'] = self.dataset.name - if self.dataset.type == "ProbeSet": - trait_dict['display_name'] = result[2] - trait_dict['hmac'] = hmac.data_hmac('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset'])) - trait_dict['symbol'] = "N/A" if result[3] is None else result[3].strip() - description_text = "" - if result[4] is not None and str(result[4]) != "": - description_text = unicodedata.normalize("NFKD", result[4].decode('latin1')) - - target_string = result[5].decode('utf-8') if result[5] else "" - description_display = description_text if target_string is None or str(target_string) == "" else description_text + "; " + str(target_string).strip() - trait_dict['description'] = description_display - - trait_dict['location'] = "N/A" - if (result[6] is not None) and (result[6] != "") and (result[6] != "Un") and (result[7] is not None) and (result[7] != 0): - trait_dict['location'] = f"Chr{result[6]}: {float(result[7]):.6f}" - - trait_dict['mean'] = "N/A" if result[8] is None or result[8] == "" else f"{result[8]:.3f}" - trait_dict['additive'] = "N/A" if result[12] is None or result[12] == "" else f"{result[12]:.3f}" - trait_dict['lod_score'] = "N/A" if result[9] is None or result[9] == "" else f"{float(result[9]) / 4.61:.1f}" - trait_dict['lrs_location'] = "N/A" if result[13] is None or result[13] == "" or result[14] is None else f"Chr{result[13]}: {float(result[14]):.6f}" - elif self.dataset.type == "Geno": - trait_dict['display_name'] = str(result[0]) - trait_dict['hmac'] = hmac.data_hmac('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset'])) - trait_dict['location'] = "N/A" - if (result[4] != "NULL" and result[4] != "") and (result[5] != 0): - trait_dict['location'] = f"Chr{result[4]}: {float(result[5]):.6f}" - elif self.dataset.type == "Publish": - # Check permissions on a trait-by-trait basis for phenotype traits - trait_dict['name'] = trait_dict['display_name'] = str(result[0]) - trait_dict['hmac'] = hmac.data_hmac('{}:{}'.format(trait_dict['name'], trait_dict['dataset'])) - permissions = check_resource_availability( - self.dataset, g.user_session.user_id, trait_dict['display_name']) - if not any(x in permissions['data'] for x in ["view", "edit"]): - continue - - if result[10]: - trait_dict['display_name'] = str(result[10]) + "_" + str(result[0]) - trait_dict['description'] = "N/A" - trait_dict['pubmed_id'] = "N/A" - trait_dict['pubmed_link'] = "N/A" - trait_dict['pubmed_text'] = "N/A" - trait_dict['mean'] = "N/A" - trait_dict['additive'] = "N/A" - pre_pub_description = "N/A" if result[1] is None else result[1].strip() - post_pub_description = "N/A" if result[2] is None else result[2].strip() - if result[5] != "NULL" and result[5] != None: - trait_dict['pubmed_id'] = result[5] - trait_dict['pubmed_link'] = PUBMEDLINK_URL % trait_dict['pubmed_id'] - trait_dict['description'] = post_pub_description - else: - trait_dict['description'] = pre_pub_description + if self.search_type == "xapian": + # These four lines are borrowed from gsearch.py; probably need to put them somewhere else to avoid duplicated code + chr_mb = curry(2, lambda chr, mb: f"Chr{chr}: {mb:.6f}") + format3f = lambda x: f"{x:.3f}" + hmac = curry(3, lambda trait_name, dataset, data_hmac: f"{trait_name}:{dataset}:{data_hmac}") + convert_lod = lambda x: x / 4.61 + + trait = MonadicDict(result) + trait["index"] = Just(index) + trait["display_name"] = trait["name"] + trait["location"] = (Maybe.apply(chr_mb) + .to_arguments(trait.pop("chr"), trait.pop("mb"))) + trait["lod_score"] = trait.pop("lrs").map(convert_lod).map(format3f) + trait["additive"] = trait["additive"].map(format3f) + trait["mean"] = trait["mean"].map(format3f) + trait["lrs_location"] = (Maybe.apply(chr_mb) + .to_arguments(trait.pop("geno_chr"), trait.pop("geno_mb"))) + + description_text = trait['description'].maybe("N/A", lambda a: a) + if len(description_text) > 200: + description_text = description_text[:200] + "..." + trait['description'] = Just(description_text) + + if self.dataset.type == "ProbeSet": + trait["hmac"] = (Maybe.apply(hmac) + .to_arguments(trait['name'], trait['dataset'], Just(hmac_creation(f"{trait['name']}:{trait['dataset']}")))) + elif self.dataset.type == "Publish": + inbredsetcode = trait.pop("inbredsetcode") + if inbredsetcode.map(len) == Just(3): + trait["display_name"] = (Maybe.apply( + curry(2, lambda inbredsetcode, name: f"{inbredsetcode}_{name}")) + .to_arguments(inbredsetcode, trait["name"])) + + trait["hmac"] = (Maybe.apply(hmac) + .to_arguments(trait['name'], trait['dataset'], Just(hmac_creation(f"{trait['name']}:{trait['dataset']}")))) + trait["authors"] = trait["authors_display"] = (trait.pop("authors").map( + lambda authors: + ", ".join(authors[:2] + ["et al."] if len(authors) >=2 else authors))) + trait["pubmed_text"] = trait["year"].map(str) + trait_list.append(trait.data) + else: + trait_dict = {} + trait_dict['index'] = index + 1 + trait_dict['dataset'] = self.dataset.name + if self.dataset.type == "ProbeSet": + trait_dict['display_name'] = result[2] + trait_dict['hmac'] = f"{trait_dict['display_name']}:{trait_dict['dataset']}:{hmac_creation('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))}" + trait_dict['symbol'] = "N/A" if result[3] is None else result[3].strip() + description_text = "" + if result[4] is not None and str(result[4]) != "": + description_text = unicodedata.normalize("NFKD", result[4].decode('latin1')) + + target_string = result[5].decode('utf-8') if result[5] else "" + description_display = description_text if target_string is None or str(target_string) == "" else description_text + "; " + str(target_string).strip() + trait_dict['description'] = description_display + + trait_dict['location'] = "N/A" + if (result[6] is not None) and (result[6] != "") and (result[6] != "Un") and (result[7] is not None) and (result[7] != 0): + trait_dict['location'] = f"Chr{result[6]}: {float(result[7]):.6f}" + + trait_dict['mean'] = "N/A" if result[8] is None or result[8] == "" else f"{result[8]:.3f}" + trait_dict['additive'] = "N/A" if result[12] is None or result[12] == "" else f"{result[12]:.3f}" + trait_dict['lod_score'] = "N/A" if result[9] is None or result[9] == "" else f"{float(result[9]) / 4.61:.1f}" + trait_dict['lrs_location'] = "N/A" if result[13] is None or result[13] == "" or result[14] is None else f"Chr{result[13]}: {float(result[14]):.6f}" + elif self.dataset.type == "Geno": + trait_dict['display_name'] = str(result[0]) + trait_dict['hmac'] = f"{trait_dict['display_name']}:{trait_dict['dataset']}:{hmac_creation('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))}" + trait_dict['location'] = "N/A" + if (result[4] != "NULL" and result[4] != "") and (result[5] != 0): + trait_dict['location'] = f"Chr{result[4]}: {float(result[5]):.6f}" + elif self.dataset.type == "Publish": + # Check permissions on a trait-by-trait basis for phenotype traits + trait_dict['name'] = trait_dict['display_name'] = str(result[0]) + trait_dict['hmac'] = f"{trait_dict['display_name']}:{trait_dict['dataset']}:{hmac_creation('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))}" + permissions = check_resource_availability( + self.dataset, g.user_session.user_id, trait_dict['display_name']) + if not any(x in permissions['data'] for x in ["view", "edit"]): + continue + + if result[10]: + trait_dict['display_name'] = str(result[10]) + "_" + str(result[0]) + trait_dict['description'] = "N/A" + trait_dict['pubmed_id'] = "N/A" + trait_dict['pubmed_link'] = "N/A" + trait_dict['pubmed_text'] = "N/A" + trait_dict['mean'] = "N/A" + trait_dict['additive'] = "N/A" + pre_pub_description = "N/A" if result[1] is None else result[1].strip() + post_pub_description = "N/A" if result[2] is None else result[2].strip() + if result[5] != "NULL" and result[5] != None: + trait_dict['pubmed_id'] = result[5] + trait_dict['pubmed_link'] = PUBMEDLINK_URL % trait_dict['pubmed_id'] + trait_dict['description'] = post_pub_description + else: + trait_dict['description'] = pre_pub_description - if result[4].isdigit(): - trait_dict['pubmed_text'] = result[4] + if result[4].isdigit(): + trait_dict['pubmed_text'] = result[4] - trait_dict['authors'] = result[3] - trait_dict['authors_display'] = trait_dict['authors'] - author_list = trait_dict['authors'].split(",") - if len(author_list) >= 2: - trait_dict['authors_display'] = (",").join(author_list[:2]) + ", et al." + trait_dict['authors'] = result[3] + trait_dict['authors_display'] = trait_dict['authors'] + author_list = trait_dict['authors'].split(",") + if len(author_list) >= 2: + trait_dict['authors_display'] = (",").join(author_list[:2]) + ", et al." - if result[6] != "" and result[6] != None: - trait_dict['mean'] = f"{result[6]:.3f}" + if result[6] != "" and result[6] != None: + trait_dict['mean'] = f"{result[6]:.3f}" - try: - trait_dict['lod_score'] = f"{float(result[7]) / 4.61:.1f}" - except: - trait_dict['lod_score'] = "N/A" + try: + trait_dict['lod_score'] = f"{float(result[7]) / 4.61:.1f}" + except: + trait_dict['lod_score'] = "N/A" - try: - trait_dict['lrs_location'] = f"Chr{result[11]}: {float(result[12]):.6f}" - except: - trait_dict['lrs_location'] = "N/A" + try: + trait_dict['lrs_location'] = f"Chr{result[11]}: {float(result[12]):.6f}" + except: + trait_dict['lrs_location'] = "N/A" - trait_dict['additive'] = "N/A" if not result[8] else f"{result[8]:.3f}" + trait_dict['additive'] = "N/A" if not result[8] else f"{result[8]:.3f}" - trait_dict['trait_info_str'] = trait_info_str(trait_dict, self.dataset.type) + trait_dict['trait_info_str'] = trait_info_str(trait_dict, self.dataset.type) - # Convert any bytes in dict to a normal utf-8 string - for key in trait_dict.keys(): - if isinstance(trait_dict[key], bytes): - try: - trait_dict[key] = trait_dict[key].decode('utf-8') - except UnicodeDecodeError: - trait_dict[key] = trait_dict[key].decode('latin-1') + # Convert any bytes in dict to a normal utf-8 string + for key in trait_dict.keys(): + if isinstance(trait_dict[key], bytes): + try: + trait_dict[key] = trait_dict[key].decode('utf-8') + except UnicodeDecodeError: + trait_dict[key] = trait_dict[key].decode('latin-1') - trait_list.append(trait_dict) + trait_list.append(trait_dict) if self.results: self.max_widths = {} @@ -229,6 +276,42 @@ class SearchResultPage: """ self.search_terms = parser.parse(self.search_terms) + # Set of terms compatible with Xapian currently (None is a search without a term) + xapian_terms = ["POSITION", "MEAN", "LRS", "LOD", "RIF", "WIKI"] + + if all([(the_term['key'] in xapian_terms) or (not the_term['key'] and self.dataset.type != "Publish") for the_term in self.search_terms]): + self.search_type = "xapian" + self.results = requests.get(generate_xapian_request(self.dataset, self.search_terms, self.and_or)).json() + if not len(self.results) or 'error' in self.results: + self.results = [] + self.sql_search() + else: + self.sql_search() + + def get_search_ob(self, a_search): + search_term = a_search['search_term'] + search_operator = a_search['separator'] + search_type = {} + search_type['dataset_type'] = self.dataset.type + if a_search['key']: + search_type['key'] = a_search['key'].upper() + else: + search_type['key'] = None + + search_ob = do_search.DoSearch.get_search(search_type) + if search_ob: + search_class = getattr(do_search, search_ob) + the_search = search_class(search_term, + search_operator, + self.dataset, + search_type['key'] + ) + return the_search + else: + return None + + def sql_search(self): + self.search_type = "sql" combined_from_clause = "" combined_where_clause = "" # The same table can't be referenced twice in the from clause @@ -313,27 +396,6 @@ class SearchResultPage: if the_search != None: self.header_fields = the_search.header_fields - def get_search_ob(self, a_search): - search_term = a_search['search_term'] - search_operator = a_search['separator'] - search_type = {} - search_type['dataset_type'] = self.dataset.type - if a_search['key']: - search_type['key'] = a_search['key'].upper() - else: - search_type['key'] = None - - search_ob = do_search.DoSearch.get_search(search_type) - if search_ob: - search_class = getattr(do_search, search_ob) - the_search = search_class(search_term, - search_operator, - self.dataset, - search_type['key'] - ) - return the_search - else: - return None def trait_info_str(trait, dataset_type): """Provide a string representation for given trait""" @@ -431,3 +493,50 @@ def get_alias_terms(symbol, species): alias_terms.append(the_search_term) return alias_terms + +def generate_xapian_request(dataset, search_terms, and_or): + """ Generate the resquest to GN3 which queries Xapian """ + match dataset.type: + case "ProbeSet": + search_type = "gene" + case "Publish": + search_type = "phenotype" + case "Geno": + search_type = "genotype" + case _: # This should never happen + raise ValueError(f"Dataset types should only be ProbeSet, Publish, or Geno, not '{dataset.type}'") + + xapian_terms = f" {and_or.upper()} ".join([create_xapian_term(dataset, term) for term in search_terms]) + + return urljoin(GN3_LOCAL_URL, "/api/search?" + urlencode({"query": xapian_terms, + "type": search_type, + "per_page": MAX_SEARCH_RESULTS})) + +def create_xapian_term(dataset, term): + """ Create Xapian term for each search term """ + search_term = term['search_term'] + xapian_term = f"dataset:{dataset.name.lower()} AND " + match term['key']: + case 'MEAN': + return xapian_term + f"mean:{search_term[0]}..{search_term[1]}" + case 'POSITION': + return xapian_term + f"chr:{search_term[0].lower().replace('chr', '')} AND position:{int(search_term[1])*10**6}..{int(search_term[2])*10**6}" + case 'AUTHOR': + return xapian_term + f"author:{search_term[0]}" + case 'RIF': + return xapian_term + f"rif:{search_term[0]}" + case 'WIKI': + return xapian_term + f"wiki:{search_term[0]}" + case 'LRS': + xapian_term += f"peak:{search_term[0]}..{search_term[1]}" + if len(search_term) == 5: + xapian_term += f" AND peakchr:{search_term[2].lower().replace('chr', '')} AND peakmb:{float(search_term[3])}..{float(search_term[4])}" + return xapian_term + case 'LOD': # Basically just LRS search but all values are multiplied by 4.61 + xapian_term += f"peak:{float(search_term[0]) * 4.61}..{float(search_term[1]) * 4.61}" + if len(search_term) == 5: + xapian_term += f" AND peakchr:{search_term[2].lower().replace('chr', '')}" + xapian_term += f" AND peakmb:{float(search_term[3])}..{float(search_term[4])}" + return xapian_term + case None: + return xapian_term + f"{search_term[0]}" diff --git a/gn2/wqflask/show_trait/SampleList.py b/gn2/wqflask/show_trait/SampleList.py index 64fc8fe6..200ff793 100644 --- a/gn2/wqflask/show_trait/SampleList.py +++ b/gn2/wqflask/show_trait/SampleList.py @@ -81,7 +81,8 @@ class SampleList: sample.extra_attributes['36'].append( webqtlConfig.RRID_MOUSE_URL % the_rrid) elif self.dataset.group.species == "rat": - if len(rrid_string): + # Check if it's a list just in case a parent/f1 strain also shows up in the .geno file, to avoid being added twice + if len(rrid_string) and not isinstance(sample.extra_attributes['36'], list): the_rrid = rrid_string.split("_")[1] sample.extra_attributes['36'] = [ rrid_string] @@ -131,7 +132,8 @@ class SampleList: "CaseAttribute, CaseAttributeXRefNew WHERE " "CaseAttributeXRefNew.CaseAttributeId = CaseAttribute.CaseAttributeId " "AND CaseAttributeXRefNew.InbredSetId = %s " - "ORDER BY CaseAttribute.CaseAttributeId", (str(self.dataset.group.id),) + "AND CaseAttribute.InbredSetId = %s " + "ORDER BY CaseAttribute.CaseAttributeId", (str(self.dataset.group.id),str(self.dataset.group.id)) ) self.attributes = {} diff --git a/gn2/wqflask/static/new/css/pills.css b/gn2/wqflask/static/new/css/pills.css new file mode 100644 index 00000000..57c84204 --- /dev/null +++ b/gn2/wqflask/static/new/css/pills.css @@ -0,0 +1,25 @@ +a.pill{ + border: 1px; + color: #3071A9; + text-decoration: none; + border: 1px solid #3071A9; + border-radius: 5px; + padding: 0.3em; + box-shadow: 3px 3px #DEDEDE; + text-align: center; +} + +a.pill:active { + box-shadow: 1px 1px aquamarine; + position: relative; + left: 2px; + top: 2px; +} + +a.pill:hover { + text-decoration: none; +} + +a.pill:focus { + text-decoration: none; +} diff --git a/gn2/wqflask/static/new/css/resource-roles.css b/gn2/wqflask/static/new/css/resource-roles.css new file mode 100644 index 00000000..da8d60d3 --- /dev/null +++ b/gn2/wqflask/static/new/css/resource-roles.css @@ -0,0 +1,5 @@ +.resource_roles { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr; + grid-gap: 5px; +} diff --git a/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js b/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js index 99ecb16e..8689af75 100644 --- a/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js +++ b/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js @@ -96,8 +96,8 @@ function display_search_results(data, textStatus, jqXHR) { * @param {UUID}: The job id to fetch data for */ function fetch_search_results(job_id, success, error=default_error_fn) { - host = $("#frm-search-traits").attr("data-gn-server-url"); - endpoint = host + "auth/data/search/phenotype/" + job_id + endpoint = $("#frm-search-traits").attr( + "data-pheno-results-template").replace("<jobid>", job_id); $("#txt-search").prop("disabled", true); $.ajax( endpoint, @@ -119,7 +119,7 @@ function search_phenotypes() { per_page = document.getElementById("txt-per-page").value search_table = new TableDataSource( "#tbl-phenotypes", "data-traits", search_checkbox); - endpoint = "/auth/data/phenotype/search" + endpoint = endpoint = $("#frm-search-traits").attr("data-search-endpoint"); $.ajax( endpoint, { diff --git a/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js b/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js index 44076c17..970a49a3 100644 --- a/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js +++ b/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js @@ -141,7 +141,7 @@ buildColumns = function() { columnList.push( { 'title': "<div title='" + js_data.attributes[attrKeys[i]].description + "' style='text-align: " + js_data.attributes[attrKeys[i]].alignment + "'>" + js_data.attributes[attrKeys[i]].name + "</div>", - 'type': "natural", + 'type': "natural-minus-na", 'data': null, 'targets': attrStart + i, 'render': function(data, type, row, meta) { diff --git a/gn2/wqflask/static/new/javascript/search_results.js b/gn2/wqflask/static/new/javascript/search_results.js index c263ef49..c89b4ce3 100644 --- a/gn2/wqflask/static/new/javascript/search_results.js +++ b/gn2/wqflask/static/new/javascript/search_results.js @@ -309,7 +309,7 @@ $(function() { return submit_special("/loading") }); - $("#send_to_webgestalt, #send_to_bnw, #send_to_geneweaver").on("click", function() { + $("#send_to_webgestalt, #send_to_bnw, #send_to_geneweaver, #send_to_genecup").on("click", function() { traits = getTraitsFromTable() $("#trait_list").val(traits) url = $(this).data("url") @@ -325,53 +325,6 @@ $(function() { $("#export_traits").click(exportTraits); $("#export_collection").click(exportCollection); - let naturalAsc = $.fn.dataTableExt.oSort["natural-ci-asc"] - let naturalDesc = $.fn.dataTableExt.oSort["natural-ci-desc"] - - let na_equivalent_vals = ["N/A", "--", ""]; //ZS: Since there are multiple values that should be treated the same as N/A - - function extractInnerText(the_string){ - var span = document.createElement('span'); - span.innerHTML = the_string; - return span.textContent || span.innerText; - } - - function sortNAs(a, b, sort_function){ - if ( na_equivalent_vals.includes(a) && na_equivalent_vals.includes(b)) { - return 0; - } - if (na_equivalent_vals.includes(a)){ - return 1 - } - if (na_equivalent_vals.includes(b)) { - return -1; - } - return sort_function(a, b) - } - - $.extend( $.fn.dataTableExt.oSort, { - "natural-minus-na-asc": function (a, b) { - return sortNAs(extractInnerText(a), extractInnerText(b), naturalAsc) - }, - "natural-minus-na-desc": function (a, b) { - return sortNAs(extractInnerText(a), extractInnerText(b), naturalDesc) - } - }); - - $.fn.dataTable.ext.order['dom-checkbox'] = function ( settings, col ) - { - return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) { - return $('input', td).prop('checked') ? '1' : '0'; - } ); - }; - - $.fn.dataTable.ext.order['dom-inner-text'] = function ( settings, col ) - { - return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) { - return $(td).text(); - } ); - } - applyDefault = function() { let default_collection_id = $.cookie('default_collection'); if (default_collection_id) { diff --git a/gn2/wqflask/static/new/javascript/table_functions.js b/gn2/wqflask/static/new/javascript/table_functions.js index 62888cd9..a648778e 100644 --- a/gn2/wqflask/static/new/javascript/table_functions.js +++ b/gn2/wqflask/static/new/javascript/table_functions.js @@ -88,3 +88,50 @@ function saveColumnSettings(tableId, traitTable) { // Save (or update) the settings in localStorage localStorage.setItem(tableId, JSON.stringify(userColumnDefs)); } + +let naturalAsc = $.fn.dataTableExt.oSort["natural-ci-asc"] +let naturalDesc = $.fn.dataTableExt.oSort["natural-ci-desc"] + +let na_equivalent_vals = ["N/A", "--", "", "NULL"]; //ZS: Since there are multiple values that should be treated the same as N/A + +function extractInnerText(the_string){ + var span = document.createElement('span'); + span.innerHTML = the_string; + return span.textContent || span.innerText; +} + +function sortNAs(a, b, sort_function){ + if ( na_equivalent_vals.includes(a) && na_equivalent_vals.includes(b)) { + return 0; + } + if (na_equivalent_vals.includes(a)){ + return 1 + } + if (na_equivalent_vals.includes(b)) { + return -1; + } + return sort_function(a, b) +} + +$.extend( $.fn.dataTableExt.oSort, { + "natural-minus-na-asc": function (a, b) { + return sortNAs(extractInnerText(a), extractInnerText(b), naturalAsc) + }, + "natural-minus-na-desc": function (a, b) { + return sortNAs(extractInnerText(a), extractInnerText(b), naturalDesc) + } +}); + +$.fn.dataTable.ext.order['dom-checkbox'] = function ( settings, col ) +{ + return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) { + return $('input', td).prop('checked') ? '1' : '0'; + } ); +}; + +$.fn.dataTable.ext.order['dom-inner-text'] = function ( settings, col ) +{ + return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) { + return $(td).text(); + } ); +}
\ No newline at end of file diff --git a/gn2/wqflask/templates/base.html b/gn2/wqflask/templates/base.html index 26b75230..6c545646 100644 --- a/gn2/wqflask/templates/base.html +++ b/gn2/wqflask/templates/base.html @@ -188,7 +188,7 @@ </span> <span style="padding: 5px;margin-left: 65px;" > - <a style="text-decoration: none" target="_blank" href="https://issues.genenetwork.org/topics/xapian/xapian-search-queries"> + <a style="text-decoration: none" target="_blank" href="/search-syntax"> <i style="text-align: center;color:#336699;;" class="fa fa-question-circle fa-2x" title="see more search hints" aria-hidden="true"></i> </a> </span> diff --git a/gn2/wqflask/templates/collections/list.html b/gn2/wqflask/templates/collections/list.html index c3d5d2a4..dc725fb6 100644 --- a/gn2/wqflask/templates/collections/list.html +++ b/gn2/wqflask/templates/collections/list.html @@ -57,8 +57,8 @@ <td align="center" style="padding: 0px;"><INPUT TYPE="checkbox" NAME="collection" class="checkbox trait_checkbox" VALUE="{{ uc.id }}"></td> <td align="right">{{ loop.index }} <td><a class="collection_name" href="{{ url_for('view_collection', uc_id=uc.id) }}">{{ uc.name }}</a></td> - <td>{{ uc.created_timestamp }}</td> - <td>{{ uc.changed_timestamp }}</td> + <td>{{ uc.created }}</td> + <td>{{ uc.changed }}</td> <td align="right">{{ uc.num_members }}</td> </tr> {% endfor %} @@ -88,8 +88,8 @@ <td align="center" style="padding: 0px;"><INPUT TYPE="checkbox" NAME="collection" class="checkbox trait_checkbox" VALUE="{{ uc.id }}"></td> <td align="right">{{ loop.index }} <td><a class="collection_name" href="{{ url_for('view_collection', uc_id=uc.id) }}">{{ uc.name }}</a></td> - <td>{{ uc.created_timestamp }}</td> - <td>{{ uc.changed_timestamp }}</td> + <td>{{ uc.created }}</td> + <td>{{ uc.changed }}</td> <td align="right">{{ uc.num_members }}</td> </tr> {% endfor %} diff --git a/gn2/wqflask/templates/collections/view.html b/gn2/wqflask/templates/collections/view.html index 7e74442f..55669f09 100644 --- a/gn2/wqflask/templates/collections/view.html +++ b/gn2/wqflask/templates/collections/view.html @@ -214,9 +214,9 @@ 'render': function(data) { if (Object.hasOwn(data, 'description')){ try { - return decodeURIComponent(escape(data.description)) + return decodeURIComponent(data.description) } catch(err){ - return escape(data.description) + return data.description } } else if (Object.hasOwn(data, 'location')){ return data.location diff --git a/gn2/wqflask/templates/correlation_page.html b/gn2/wqflask/templates/correlation_page.html index 24eaff1f..07118145 100644 --- a/gn2/wqflask/templates/correlation_page.html +++ b/gn2/wqflask/templates/correlation_page.html @@ -69,8 +69,8 @@ <input type="text" name="r_less_select" value="1.0" size="6" maxlength="10">, with mean > <input type="text" name="mean_greater_select" value="0" size="6" maxlength="10"> <select id="mean_and_or" size="1"> - <option value="and" selected>AND</option> - <option value="or">OR</option> + <option value="and">AND</option> + <option value="or" selected>OR</option> </select> mean < <input type="text" name="mean_less_select" value="100" size="6" maxlength="10"> diff --git a/gn2/wqflask/templates/dataset.html b/gn2/wqflask/templates/dataset.html index 096a3724..62fac650 100644 --- a/gn2/wqflask/templates/dataset.html +++ b/gn2/wqflask/templates/dataset.html @@ -40,31 +40,6 @@ .panel-metadata dt::after { content: ":"; } - - .search { - width: 50%; - margin: 1em auto; - } - .has-search .form-control-feedback { - right: initial; - left: 0; - color: #ccc; - } - - .has-search .form-control { - padding-right: 12px; - padding-left: 34px; - } - - .search { - transform: scale(1.5, 1.5); - } - .search input { - min-width: 17em; - } - .dataset-search { - padding: 0 17%; - } </style> {% endblock %} @@ -348,7 +323,7 @@ </div> {% else %} -<div class="container dataset-search"> +<div class="container"> <p class="lead">We appreciate your interest, but unfortunately, we don't have any additional information available for: <strong>{{ name }}</strong>. If you have other inquiries or need assistance with something else, please don't hesitate to get in touch with us. </div> diff --git a/gn2/wqflask/templates/gnqa.html b/gn2/wqflask/templates/gnqa.html index 8b50fe43..b3bc74fd 100644 --- a/gn2/wqflask/templates/gnqa.html +++ b/gn2/wqflask/templates/gnqa.html @@ -93,7 +93,7 @@ AI Search <small> <sup> - <button class="search-hist-btn" hx-get="/gnqna/hist/" hx-target="#swap" hx-swap="innerHTML" > + <button class="search-hist-btn" hx-get="/gnqna/hist" hx-target="#swap" hx-swap="innerHTML"> [Search History] </button> </sup> @@ -107,7 +107,7 @@ <button class="btn btn-default btn-sm col-xs-1 col-sm-1 col-sm-offset-3" hx-post="/gnqna" hx-target="#swap" - hx-swap="innerHTML" + hx-swap="innerHTML" hx-indicator="#indicator"> <i class="fa fa-search fa-3x" aria-hidden="true" title="Search"></i> <img id="indicator" class="htmx-indicator" src="/static/gif/loader.gif"/> diff --git a/gn2/wqflask/templates/gnqa_answer.html b/gn2/wqflask/templates/gnqa_answer.html index 0ddcfde7..41c1b338 100644 --- a/gn2/wqflask/templates/gnqa_answer.html +++ b/gn2/wqflask/templates/gnqa_answer.html @@ -3,10 +3,10 @@ <div class="row container gnqa-answer" style="margin-bottom: 1em"> <p class="row lead"> <mark style="font-family: 'Linux Libertine','Georgia','Times','Source Serif Pro',serif;"><b><i>{{ query }}</i></b></mark><br/> - {{ answer }} + {{ answer|safe }} </p> <div class="rating row" data-doc-id="{{query}}"> - <button class="btn" id="upvote" data-toggle="tooltip" data-placement="top" title="Vote Up"><i class="fa fa-thumbs-up fa-sm fa-1x" aria-hidden="true"></i></button> + <button class="btn" id="upvote" data-toggle="tooltip" data-placement="top" title="Vote Up"><i class="fa fa-thumbs-up fa-sm fa-1x" aria-hidden="true"></i></button> <button class="btn" id="downvote" data-toggle="tooltip" data-placement="top" title="Vote Down"><i class="fa fa-thumbs-down fa-sm fa-1x" aria-hidden="true"></i></button> <sub id="rate" class="text-info"> </sub> @@ -32,7 +32,7 @@ </div> <div id="collapse{{reference.doc_id}}" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="heading{{reference.doc_id}}"> <div class="panel-body"> - <p class="node-references">{{ reference.comboTxt }}</p> + <p class="node-references">{{ reference.comboTxt|safe }}</p> <div> {% if reference.pubmed %} <details open> @@ -60,7 +60,7 @@ </div> <div id="collapse{{reference.doc_id}}" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading{{reference.doc_id}}"> <div class="panel-body"> - <p class="node-references">{{reference.comboTxt}}</p> + <p class="node-references">{{ reference.comboTxt|safe }}</p> <div> {% if reference.pubmed %} <details > @@ -93,20 +93,27 @@ {% block js %} <script> + function updateRatingHandler(target, responseObj, args){ + let {status, response} = responseObj.xhr + if (status==200 && args == "upvote"){ + htmx.toggleClass(htmx.find('#upvote'), 'btn-success'); + htmx.removeClass(htmx.find("#downvote"), "btn-danger"); + } + else if(status == 200 && args == "downvote") { + htmx.toggleClass(htmx.find('#downvote'), 'btn-danger'); + htmx.removeClass(htmx.find("#upvote"), "btn-success"); + } + else { + alert(`Error occurred with status ${status} and Error ${response}` ) +}} var query = {{ query|tojson }}; var answer = {{ answer|tojson }} var {task_id} = {{ task_id|tojson }} - htmx.on("#upvote", "click", function(evt){ +htmx.on("#upvote", "click", function(evt){ vote_count = htmx.find(".btn-success") ? 0 : 1 - htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate", swap:"innerHTML",values: {'query': query, 'answer': answer}}).then(()=>{ - htmx.toggleClass(htmx.find('#upvote'), 'btn-success'); - htmx.removeClass(htmx.find("#downvote"), "btn-danger"); -})}); + htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate", handler: (target,obj)=> updateRatingHandler(target,obj,"upvote"), swap:"innerHTML",values: {'query': query, 'answer': answer}})}); htmx.on("#downvote", "click", function(evt){ vote_count = htmx.find(".btn-danger") ? 0 : -1 - htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate", swap:"innerHTML",values: {'query': query, 'answer': answer}}).then(()=>{ - htmx.toggleClass(htmx.find('#downvote'), 'btn-danger'); - htmx.removeClass(htmx.find("#upvote"), "btn-success") - })}); + htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate",handler: (target,obj)=> updateRatingHandler(target,obj,"downvote") , swap:"innerHTML",values: {'query': query, 'answer': answer}})}); </script> {% endblock %} diff --git a/gn2/wqflask/templates/gnqa_search_history.html b/gn2/wqflask/templates/gnqa_search_history.html index 2c07b8c0..976fd7fd 100644 --- a/gn2/wqflask/templates/gnqa_search_history.html +++ b/gn2/wqflask/templates/gnqa_search_history.html @@ -1,42 +1,52 @@ -<section class="container-fluid gnqa-copy"> +<section class="container-fluid gnqa-copy" id="search-hist"> <header class="row"> - <div class="panel panel default col-sm-6 col-sm-offset-3"> <div class="panel panel-default"> <div class="panel-heading"> <div> - <h4 class="text-primary">You search History </h4> + <h4 class="text-secondary" style="font-family: 'Linux Libertine','Georgia','Times','Source Serif Pro',serif;font-size:2.3rem">Your AI search History </h4> </div> </div> </div> </div> </header> <div class="container row"> - <div class="panel panel-default col-sm-6 col-sm-offset-3 "> - {% for record in prev_queries %} - <div class="panel-body"> - <div class="row"> - <input name="" type="checkbox" value="" class="col-sm-1"> + <div> + <div class="col-sm-6 col-sm-offset-3" style="margin-bottom:10px"> + <button type="button" class="btn btn-danger" id="delete-btn">Delete Selected </button> + </div> + <div > + <div class="panel panel-default col-sm-6 col-sm-offset-3 "> + <div> + <ul class="list-group list-group-flush" style="overflow-y:scroll"> + {% for item in prev_queries %} + <li class="row list-group-item"> + <input name="" type="checkbox" value="{{item['task_id']}}" class="col-sm-1" style="height: 20px; + width: 20px;"> <div class="col-sm-10"> - {% for id,val in record.items() %} <button - hx-get="/gnqna/hist/search/{{id}}" + hx-get="/gnqna/hist?query={{item['query']}}&search_term={{item['task_id']}}" hx-target="#swap" hx-swap="innerHTML" hx-trigger= "click" data-bs-toggle="tooltip" data-bs-placement="left" - title="/gnqna/hist/search?{{id}}" + title="/gnqna/hist?query={{item['query']}}&search_term={{item['task_id']}}" style="background:transparent;border:none;cursor:pointer" > - <b class="text-info">{{val}} </b> + <p class="text-info">{{item.get('query')}} </p> </button> - {% endfor %} - </div> - </div> - </div> - {% endfor %} + </div> + </li> + {% endfor %} + </ul> </div> </div> </div> - </section> +</section> +<script> + htmx.on("#delete-btn", "click", function(evt){ + htmx.ajax("DELETE","/gnqna/hist", {target: "#search-hist","swap" :"outerHTML", + values: Array.from(htmx.findAll("input[type=checkbox]:checked"), e => e.value)}) + }) +</script> diff --git a/gn2/wqflask/templates/gsearch_gene.html b/gn2/wqflask/templates/gsearch_gene.html index 50b48401..3432662d 100644 --- a/gn2/wqflask/templates/gsearch_gene.html +++ b/gn2/wqflask/templates/gsearch_gene.html @@ -258,8 +258,12 @@ "scroller": true {% endif %} } - - create_table(tableId, traitsJson, columnDefs, tableSettings); + + if (traitsJson.length > 0) { + create_table(tableId, traitsJson, columnDefs, tableSettings); + } else { + $("#" + tableId +" td").replaceWith("<td>No data</td>") + } }); diff --git a/gn2/wqflask/templates/oauth2/confirm-resource-role-unassign-privilege.html b/gn2/wqflask/templates/oauth2/confirm-resource-role-unassign-privilege.html new file mode 100644 index 00000000..988cf3b4 --- /dev/null +++ b/gn2/wqflask/templates/oauth2/confirm-resource-role-unassign-privilege.html @@ -0,0 +1,34 @@ +{%extends "base.html"%} +{%from "oauth2/profile_nav.html" import profile_nav%} +{%from "oauth2/display_error.html" import display_error%} +{%block title%}View User{%endblock%} +{%block content%} +<div class="container"> + {{profile_nav(uipages, user_privileges)}} + {{flash_me()}} + + <form id="frm_confirm_resource_role_unassign_privilege" + method="POST" + action="{{url_for('oauth2.resource.unassign_privilege_from_resource_role', + resource_id=resource.resource_id, + role_id=role.role_id)}}"> + <p> + Are you sure you want to unassign the privilege to + '{{privilege.privilege_description}}' from the role '{{role.role_name}}' + on resource '{{resource.resource_name}}'?</p> + <input type="hidden" + name="privilege_id" + value="{{privilege.privilege_id}}" /> + + <input type="submit" + name="confirm" + value="Cancel" + class="btn btn-success" /> + + <input type="submit" + name="confirm" + value="Unassign" + class="btn btn-danger" /> + </form> +</div> +{%endblock%} diff --git a/gn2/wqflask/templates/oauth2/create-role.html b/gn2/wqflask/templates/oauth2/create-role.html index f2bff7b4..6cf0bb78 100644 --- a/gn2/wqflask/templates/oauth2/create-role.html +++ b/gn2/wqflask/templates/oauth2/create-role.html @@ -7,31 +7,43 @@ {{profile_nav("roles", user_privileges)}} <h3>Create Role</h3> - {{flash_me()}} + <p>Create a new role to act on resource "{{resource.resource_name}}"</p> - {%if group_privileges_error is defined%} - {{display_error("Group Privileges", group_privileges_error)}} + {%if resource_role_error is defined%} + {{display_error("Resource Role", resource_role_error)}} {%else%} - {%if "group:role:create-role" in user_privileges%} - <form method="POST" action="{{url_for('oauth2.role.create_role')}}"> - <legend>Create Group Role</legend> + {%if "resource:role:create-role" in (user_privileges|map(attribute="privilege_id")) %} + <form method="POST" action="{{url_for('oauth2.resource.create_resource_role', + resource_id=resource.resource_id)}}"> + <legend>create resource role</legend> + + {{flash_me()}} + <div class="form-group"> <label for="role_name" class="form-label">Name</label> - <input type="text" id="role_name" name="role_name" required="required" - class="form-control" - {%if prev_role_name is defined and prev_role_name is not none%} - value="{{prev_role_name}}" - {%endif%} /> + <div class="input-group"> + <span class="input-group-addon"> + {{resource.resource_name|replace(" ", "_")}}:: + </span> + <input type="text" id="role_name" name="role_name" required="required" + class="form-control" + {%if prev_role_name is defined and prev_role_name is not none%} + value="{{prev_role_name}}" + {%endif%} /> + </div> + <span class="form-text text-muted"> + The name of the role will have the resource's name appended. + </span> </div> <label class="form-label">Privileges</label> - {%for priv in group_privileges%} + {%for priv in user_privileges%} <div class="checkbox"> - <label for="chk:{{priv.privilege_id}}"> - <input type="checkbox" id="chk:{{priv.privilege_id}}" + <label for="chk-{{priv.privilege_id}}"> + <input type="checkbox" id="chk-{{priv.privilege_id}}" name="privileges[]" value={{priv.privilege_id}} /> <span style="text-transform: capitalize;"> {{priv.privilege_description}} - </span> ({{priv.privilege_id}}) + </span> </label> </div> {%endfor%} diff --git a/gn2/wqflask/templates/oauth2/data-list-mrna.html b/gn2/wqflask/templates/oauth2/data-list-mrna.html index 728e95d4..c5c1c27e 100644 --- a/gn2/wqflask/templates/oauth2/data-list-mrna.html +++ b/gn2/wqflask/templates/oauth2/data-list-mrna.html @@ -92,7 +92,7 @@ <div class="row"> <span id="search-messages" class="alert-danger" style="display:none"></span> <form id="frm-search" - action="{{search_uri}}" + action="{{url_for('oauth2.data.json_search_mrna')}}" method="POST"> <legend>Search: mRNA Assay</legend> <input type="hidden" value="{{species_name}}" name="species" diff --git a/gn2/wqflask/templates/oauth2/data-list-phenotype.html b/gn2/wqflask/templates/oauth2/data-list-phenotype.html index e5172c70..d355f3f9 100644 --- a/gn2/wqflask/templates/oauth2/data-list-phenotype.html +++ b/gn2/wqflask/templates/oauth2/data-list-phenotype.html @@ -113,7 +113,8 @@ <form id="frm-search-traits" action="#" method="POST" - data-gn-server-url="{{gn_server_url}}"> + data-search-endpoint="{{search_endpoint}}" + data-pheno-results-template="{{pheno_results_template}}"> {%if dataset_type == "mrna"%} <legend>mRNA: Search</legend> {%else%} diff --git a/gn2/wqflask/templates/oauth2/profile_nav.html b/gn2/wqflask/templates/oauth2/profile_nav.html index aa752905..c79bccbc 100644 --- a/gn2/wqflask/templates/oauth2/profile_nav.html +++ b/gn2/wqflask/templates/oauth2/profile_nav.html @@ -17,13 +17,6 @@ </li> <li role="presentation" - {%if calling_page == "roles"%} - class="active" - {%endif%}> - <a href="{{url_for('oauth2.role.user_roles')}}">Roles</a> - </li> - - <li role="presentation" {%if calling_page == "resources"%} class="active" {%endif%}> diff --git a/gn2/wqflask/templates/oauth2/view-group-role.html b/gn2/wqflask/templates/oauth2/view-group-role.html deleted file mode 100644 index 5da023bf..00000000 --- a/gn2/wqflask/templates/oauth2/view-group-role.html +++ /dev/null @@ -1,102 +0,0 @@ -{%extends "base.html"%} -{%from "oauth2/profile_nav.html" import profile_nav%} -{%from "oauth2/display_error.html" import display_error%} -{%block title%}View User{%endblock%} -{%block content%} -<div class="container" style="min-width: 1250px;"> - {{profile_nav("roles", user_privileges)}} - <h3>View Group Role</h3> - - {{flash_me()}} - - <div class="container-fluid"> - <div class="row"> - <h3>Role Details</h3> - {%if group_role_error is defined%} - {{display_error("Group Role", group_role_error)}} - {%else%} - <table class="table"> - <caption>Details for '{{group_role.role.role_name}}' Role</caption> - <thead> - <tr> - <th>Privilege</th> - <th>Description</th> - <th>Action</th> - </tr> - </thead> - <tbody> - {%for privilege in group_role.role.privileges%} - <tr> - <td>{{privilege.privilege_id}}</td> - <td>{{privilege.privilege_description}}</td> - <td> - <form action="{{url_for( - 'oauth2.group.delete_privilege_from_role', - group_role_id=group_role.group_role_id)}}" - method="POST"> - <input type="hidden" name="privilege_id" - value="{{privilege.privilege_id}}" /> - <input type="submit" class="btn btn-danger" - value="Remove" - {%if not group_role.role.user_editable%} - disabled="disabled" - {%endif%} /> - </form> - </td> - </tr> - {%endfor%} - </tbody> - </table> - {%endif%} - </div> - - <div class="row"> - <h3>Other Privileges</h3> - <table class="table"> - <caption>Other Privileges not Assigned to this Role</caption> - <thead> - <tr> - <th>Privilege</th> - <th>Description</th> - <th>Action</th> - </tr> - </thead> - - <tbody> - {%for priv in group_privileges%} - <tr> - <td>{{priv.privilege_id}}</td> - <td>{{priv.privilege_description}}</td> - <td> - <form action="{{url_for( - 'oauth2.group.add_privilege_to_role', - group_role_id=group_role.group_role_id)}}" - method="POST"> - <input type="hidden" name="privilege_id" - value="{{priv.privilege_id}}" /> - <input type="submit" class="btn btn-warning" - value="Add to Role" - {%if not group_role.role.user_editable%} - disabled="disabled" - {%endif%} /> - </form> - </td> - </tr> - {%else%} - <tr> - <td colspan="3"> - <span class="glyphicon glyphicon-info-sign text-info"> - </span> - - <span class="text-info">All privileges assigned!</span> - </td> - </tr> - {%endfor%} - </tbody> - </table> - </div> - - </div> - -</div> -{%endblock%} diff --git a/gn2/wqflask/templates/oauth2/view-resource-role.html b/gn2/wqflask/templates/oauth2/view-resource-role.html new file mode 100644 index 00000000..4bd0ab45 --- /dev/null +++ b/gn2/wqflask/templates/oauth2/view-resource-role.html @@ -0,0 +1,149 @@ +{%extends "base.html"%} +{%from "oauth2/profile_nav.html" import profile_nav%} +{%from "oauth2/display_error.html" import display_error%} +{%block title%}View User{%endblock%} +{%block content%} + +{%macro unassign_button(resource_id, role_id, privilege_id)%} +<form method="GET" + action="{{url_for('oauth2.resource.unassign_privilege_from_resource_role', + resource_id=resource_id, + role_id=role_id)}}" + id="frm_unlink_privilege_{{privilege_id}}"> + <input type="hidden" name="resource_id" value="{{resource_id}}" /> + <input type="hidden" name="role_id" value="{{role_id}}" /> + <input type="hidden" name="privilege_id" value="{{privilege_id}}" /> + <input type="submit" value="Unassign" class="btn btn-danger" /> +</form> +{%endmacro%} + +<div class="container"> + <div class="row"> + {{profile_nav(uipages, user_privileges)}} + {{flash_me()}} + {%if resource_error is defined%} + {{display_error("Resource", resource_error)}} + {%else%} + <h3>Role for Resource '{{resource.resource_name}}'</h3> + {%if role_error is defined%} + {{display_error("Role", role_error)}} + {%else%} + <table class="table"> + <caption>Role '{{role.role_name}}' for resource '{{resource.resource_name}}'</caption> + <thead> + <tr> + <th>Role Name</th> + <th>Privilege</th> + <th>Action</th> + </tr> + </thead> + + <tbody> + {%for priv in role.privileges%} + {%if loop.index0 == 0%} + <tr> + <td rowspan="{{role.privileges | length}}" + style="text-align: center;vertical-align: middle;"> + {{role.role_name}}</td> + <td>{{priv.privilege_description}}</td> + <td>{{unassign_button(resource.resource_id, role.role_id, priv.privilege_id)}}</td> + </tr> + {%else%} + <tr> + <td>{{priv.privilege_description}}</td> + <td>{{unassign_button(resource.resource_id, role.role_id, priv.privilege_id)}}</td> + </tr> + {%endif%} + {%else%} + <tr> + <td colspan="3"> + <p class="text-info"> + <span class="glyphicon glyphicon-info-sign text-info"></span> + + This role has no privileges. + </p> + </td> + </tr> + {%endfor%} + </tbody> + </table> + </div> + + <div class="row"> + <form id="frm_assign_privileges" method="POST" action="#"> + <input type="hidden" name="resource_id" value="{{resource_id}}" /> + <input type="hidden" name="role_id" value="{{role_id}}" /> + {%if unassigned_privileges | length == 0%} + <p class="text-info"> + <strong>{{title}}</strong>: + <span class="glyphicon glyphicon-info-sign text-info"></span> + + There are no more privileges left to assign. + </p> + {%else%} + <fieldset> + <legend>Select privileges to assign to this role</legend> + {%for priv in unassigned_privileges%} + <div class="checkbox"> + <label for="rdo_{{priv.privilege_id}}"> + <input type="checkbox" value="{{priv.privilege_id}}" /> + {{priv.privilege_description}} + </label> + </div> + {%endfor%} + </fieldset> + + <input type="submit" class="btn btn-primary" value="Assign" /> + {%endif%} + </form> + </div> + + {%if user_error is defined%} + {{display_error("Users", user_error)}} + {%endif%} + + {%if users is defined and users | length > 0%} + <div class="row"> + <h3>Users</h3> + + <table class="table"> + <caption> + Users assigned role '{{role.role_name}}' on resource + '{{resource.resource_name}}' + </caption> + + <thead> + <tr> + <th>Email</th> + <th>Name</th> + </tr> + </thead> + + <tbody> + {%for user in users%} + <tr> + <td>{{user.email}}</td> + <td>{{user.name}}</td> + </tr> + {%endfor%} + </tbody> + </table> + </div> + {%endif%} + + {%if users is defined and users | length == 0%} + <div class="row"> + <h3>Delete this role</h3> + <p class="text-danger"> + <strong>Delete Role</strong>: + <span class="glyphicon glyphicon-exclamation-sign text-danger"></span> + + This will delete this role, and you will no longer have access to it. + </p> + </div> + {%endif%} + {%endif%} + {%endif%} +</div> + +{%endblock%} diff --git a/gn2/wqflask/templates/oauth2/view-resource.html b/gn2/wqflask/templates/oauth2/view-resource.html index 275fcb24..0788e30c 100644 --- a/gn2/wqflask/templates/oauth2/view-resource.html +++ b/gn2/wqflask/templates/oauth2/view-resource.html @@ -2,6 +2,10 @@ {%from "oauth2/profile_nav.html" import profile_nav%} {%from "oauth2/display_error.html" import display_error%} {%block title%}View User{%endblock%} +{%block css%} +<link rel="stylesheet" href="/static/new/css/pills.css" /> +<link rel="stylesheet" href="/static/new/css/resource-roles.css" /> +{%endblock%} {%block content%} <div class="container" style="min-width: 1250px;"> {{profile_nav("resources", user_privileges)}} @@ -230,7 +234,27 @@ </div> <div class="row"> - <h3>User Roles</h3> + <h3>Available Resource Roles</h3> + <div class="resource_roles"> + {%for role in resource_roles%} + <a class="pill" + href="{{url_for('oauth2.resource.view_resource_role', + resource_id=resource.resource_id, + role_id=role.role_id)}}" + title="Role page for role named '{{role.role_name}}'"> + {{role.role_name}} + </a> + {%endfor%} + </div> + <hr /> + <a title="create a new role for this resource" + href="{{url_for('oauth2.resource.create_resource_role', + resource_id=resource.resource_id)}}" + class="btn btn-info">New Role</a> + </div> + + <div class="row"> + <h3>Users: Assigned Roles</h3> {%if users_n_roles_error is defined%} {{display_error("Users and Roles", users_n_roles_error)}} {%else%} @@ -254,14 +278,14 @@ <th>Role</th> <th>Action</th> </tr> - {%for grole in user_row.roles%} + {%for role in user_row.roles%} <tr> <td> <a href="{{url_for( 'oauth2.role.role', - role_id=grole.role_id)}}" - title="Details for '{{grole.role_name}}' role"> - {{grole.role_name}} + role_id=role.role_id)}}" + title="Details for '{{role.role_name}}' role"> + {{role.role_name}} </a> </td> <td> @@ -270,8 +294,8 @@ method="POST"> <input type="hidden" name="user_id" value="{{user_row.user.user_id}}" /> - <input type="hidden" name="group_role_id" - value="{{grole.group_role_id}}"> + <input type="hidden" name="role_id" + value="{{role.role_id}}"> <input type="submit" value="Unassign" class="btn btn-danger" @@ -301,8 +325,8 @@ <div class="row"> <h3>Assign</h3> - {%if group_roles_error is defined%} - {{display_error("Group Roles", group_roles_error)}} + {%if resource_roles_error is defined%} + {{display_error("Resource Roles", resource_roles_error)}} {%elif users_error is defined%} {{display_error("Users", users_error)}} {%else%} @@ -312,13 +336,13 @@ method="POST" autocomplete="off"> <input type="hidden" name="resource_id" value="{{resource_id}}" /> <div class="form-group"> - <label for="group_role_id" class="form-label">Role</label> - <select class="form-control" name="group_role_id" - id="group_role_id" required="required"> - <option value="">Select role</option> - {%for grole in group_roles%} - <option value="{{grole.group_role_id}}"> - {{grole.role.role_name}} + <label for="role_id" class="form-label">Role</label> + <select class="form-control" name="role_id" + id="role_id" required="required"> + <option value="">Select role</option>> + {%for rrole in resource_roles%} + <option value="{{rrole.role_id}}"> + {{rrole.role_name}} </option> {%endfor%} </select> diff --git a/gn2/wqflask/templates/search-syntax.html b/gn2/wqflask/templates/search-syntax.html new file mode 100644 index 00000000..52538826 --- /dev/null +++ b/gn2/wqflask/templates/search-syntax.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Global Search Syntax{% endblock %} + +{% block css %} +<link rel="stylesheet" type="text/css" href="/static/new/css/markdown.css" /> +{% endblock %} + +{% block content %} + + <div class="github-btn-container"> + <div class="github-btn"> + <a href="https://github.com/genenetwork/gn-docs/blob/master/general/search/xapian_syntax.md"> + Edit Text + <img src="/static/images/edit.png"> + </a> + </div> +</div> +<div id="markdown" class="container"> + {{ rendered_markdown|safe }} + +</div> + +{% endblock %}
\ No newline at end of file diff --git a/gn2/wqflask/templates/search_result_page.html b/gn2/wqflask/templates/search_result_page.html index 934b6a9d..fccda1ae 100644 --- a/gn2/wqflask/templates/search_result_page.html +++ b/gn2/wqflask/templates/search_result_page.html @@ -224,7 +224,8 @@ 'type': "natural", 'width': "{{ max_widths.symbol * 8 }}px", 'targets': 3, - 'data': "symbol" + 'data': "symbol", + 'defaultContent': "N/A" }, { 'title': "Description", @@ -237,9 +238,9 @@ description = data.description.slice(0, 200) + '...' } try { - return decodeURIComponent(escape(description)) + return decodeURIComponent(description) } catch(err){ - return escape(description) + return description } } }, @@ -248,14 +249,16 @@ 'type': "natural-minus-na", 'width': "130px", 'targets': 5, - 'data': "location" + 'data': "location", + 'defaultContent': "N/A" }, { 'title': "<div style='text-align: right;'>Mean</div>", 'type': "natural-minus-na", 'width': "40px", - 'data': "mean", 'targets': 6, + 'data': "mean", + 'defaultContent': "N/A", 'orderSequence': [ "desc", "asc"] }, { @@ -279,6 +282,7 @@ 'data': "additive", 'width': "65px", 'targets': 9, + 'defaultContent': "N/A", 'orderSequence': [ "desc", "asc"] }{% elif dataset.type == 'Publish' %}, { @@ -294,6 +298,7 @@ { 'title': "Description", 'type': "natural", + 'width': "500px", 'data': null, 'targets': 3, 'render': function(data) { @@ -302,9 +307,9 @@ description = data.description.slice(0, 200) + '...' } try { - return decodeURIComponent(escape(description)) + return decodeURIComponent(description) } catch(err){ - return escape(description) + return description } } }, @@ -312,8 +317,9 @@ 'title': "<div style='text-align: right;'>Mean</div>", 'type': "natural-minus-na", 'width': "60px", - 'data': "mean", 'targets': 4, + 'data': "mean", + 'defaultContent': "N/A", 'orderSequence': [ "desc", "asc"] }, { diff --git a/gn2/wqflask/templates/show_trait_transform_and_filter.html b/gn2/wqflask/templates/show_trait_transform_and_filter.html index e61e1248..8a7cd8a5 100644 --- a/gn2/wqflask/templates/show_trait_transform_and_filter.html +++ b/gn2/wqflask/templates/show_trait_transform_and_filter.html @@ -5,7 +5,7 @@ needed. </p> <div id="blockMenuSpan" class="input-append block-div"> - <label for="remove_samples_field">Block samples by index:</label> + <label for="remove_samples_field">Filter samples by index:</label> <input type="text" id="remove_samples_field" placeholder="Example: 3, 5-10, 12"> <select id="block_group" size="1"> <option value="primary"> @@ -23,7 +23,7 @@ </div> {% if categorical_attr_exists == "true" %} <div class="input-append block-div-2"> - <label for="exclude_column">Block samples by group:</label> + <label for="exclude_column">Filter samples by group:</label> <select id="exclude_column" size=1> {% for attribute in sample_groups[0].attributes %} {% if sample_groups[0].attributes[attribute].distinct_values|length <= 500 and sample_groups[0].attributes[attribute].distinct_values|length > 1 %} diff --git a/gn2/wqflask/templates/tool_buttons.html b/gn2/wqflask/templates/tool_buttons.html index c6d1476c..3e716d70 100644 --- a/gn2/wqflask/templates/tool_buttons.html +++ b/gn2/wqflask/templates/tool_buttons.html @@ -18,6 +18,10 @@ BNW </button> +<button id="send_to_genecup" class="btn btn-primary submit_special" data-url="/genecup" > + GeneCup +</button> + <button id="wgcna_setup" class="btn btn-primary submit_special" data-url="/wgcna_setup" title="WGCNA Analysis" > WGCNA </button> diff --git a/gn2/wqflask/templates/tutorials.html b/gn2/wqflask/templates/tutorials.html index 870dba1a..6281291f 100644 --- a/gn2/wqflask/templates/tutorials.html +++ b/gn2/wqflask/templates/tutorials.html @@ -221,11 +221,12 @@ $('#myTable3').dataTable( { </tr> </thead> <tbody> - <tr><td><a href="https://www.biorxiv.org/content/10.1101/2020.12.23.424047v1">GeneNetwork: a continuously updated tool for systems genetics analyses</a></td></tr> - <tr><td><a href="https://doi.org/10.3390/genes13040614">New Insights on Gene by Environmental Effects of Drugs of Abuse in Animal Models Using GeneNetwork</a></td> - <tr><td><a href="https://www.opar.io/pdf/Rat_HRDP_Brain_Proteomics_Wang_WIlliams_08Oct2021.pdf">A Primer on Brain Proteomics and protein-QTL Analysis for Substance Use Disorders</a></td></tr> - - </tbody> + <tr><td><a href="https://www.opar.io/pdf/Ashbrook_ExperimentalPrecisionMedicineMouseModels_2023.pdf">Experimental precision medicine: Mouse models in which to test precision medicine</a></td> + <tr><td><a href="https://www.opar.io/pdf/Williams_ResourcesForSystemsGenetics_2017.pdf">Systems Genetics. Methods and Protocols</a></td></tr> + <tr><td><a href="https://www.biorxiv.org/content/10.1101/2020.12.23.424047v1">GeneNetwork: a continuously updated tool for systems genetics analyses</a></td></tr> + <tr><td><a href="https://doi.org/10.3390/genes13040614">New Insights on Gene by Environmental Effects of Drugs of Abuse in Animal Models Using GeneNetwork</a></td> + <tr><td><a href="https://www.opar.io/pdf/Rat_HRDP_Brain_Proteomics_Wang_WIlliams_08Oct2021.pdf">A Primer on Brain Proteomics and protein-QTL Analysis for Substance Use Disorders</a></td></tr> + </tbody> </table> <script> $('#myTable4').dataTable( { diff --git a/gn2/wqflask/views.py b/gn2/wqflask/views.py index 843ed07a..993c6f0c 100644 --- a/gn2/wqflask/views.py +++ b/gn2/wqflask/views.py @@ -46,12 +46,12 @@ from gn2.wqflask import search_results from gn2.wqflask import server_side # Used by YAML in marker_regression from gn2.base.data_set import create_dataset +from gn2.base.trait import fetch_symbols from gn2.wqflask.show_trait import show_trait from gn2.wqflask.show_trait import export_trait_data from gn2.wqflask.show_trait.show_trait import get_diff_of_vals from gn2.wqflask.heatmap import heatmap -from gn2.wqflask.external_tools import send_to_bnw -from gn2.wqflask.external_tools import send_to_webgestalt +from gn2.wqflask.external_tools import send_to_bnw, send_to_webgestalt from gn2.wqflask.external_tools import send_to_geneweaver from gn2.wqflask.comparison_bar_chart import comparison_bar_chart from gn2.wqflask.marker_regression import run_mapping @@ -88,8 +88,8 @@ from gn2.utility.redis_tools import get_redis_conn import gn2.utility.hmac as hmac -from gn2.base.webqtlConfig import TMPDIR -from gn2.base.webqtlConfig import GENERATED_IMAGE_DIR +from gn2.base.webqtlConfig import TMPDIR, GENERATED_IMAGE_DIR +from gn2.base.webqtlConfig import GENE_CUP_URL from gn2.wqflask.database import database_connection @@ -137,6 +137,10 @@ def handle_generic_exceptions(e): stack={formatted_lines}, error_image=animation, version=current_app.config.get("GN_VERSION"))) + try: + resp.status_code = exc_type.code + except AttributeError: + resp.status_code = 500 resp.set_cookie(err_msg[:32], animation) return resp @@ -258,28 +262,19 @@ def gsearchtable(): @app.route("/gnqna", methods=["POST", "GET"]) @require_oauth2 def gnqna(): - if request.method == "POST": try: - def __error__(resp): - return resp.json() - def error_page(resp): return render_template("gnqa_errors.html", **{"status_code": resp.status_code, **resp.json()}) def __success__(resp): return render_template("gnqa_answer.html", **{"gn_server_url": GN3_LOCAL_URL, **(resp.json())}) - """ - disable gn-auth currently not stable - if not user_logged_in(): - return error_page("Please Login/Register to Genenetwork to access this Service") - """ token = session_info()["user"]["token"].either( lambda err: err, lambda tok: tok["access_token"]) - return monad_requests.post( + return monad_requests.put( urljoin(GN3_LOCAL_URL, - "/api/llm/gnqna"), + "/api/llm/search"), json=dict(request.form), headers={ "Authorization": f"Bearer {token}" @@ -290,44 +285,39 @@ def gnqna(): error_page, __success__) except Exception as error: return flask.jsonify({"error": str(error)}) - prev_queries = (monad_requests.get( - urljoin(GN3_LOCAL_URL, - "/api/llm/get_hist_names") - ).then( - lambda resp: resp - ).either(lambda x: [], lambda x: x.json()["prev_queries"])) - - return render_template("gnqa.html", prev_queries=prev_queries) + return render_template("gnqa.html") -@app.route("/gnqna/hist/", methods=["GET"]) -@require_oauth2 -def get_hist_titles(): - token = session_info()["user"]["token"].either( - lambda err: err, lambda tok: tok["access_token"]) - response = monad_requests.get(urljoin(GN3_LOCAL_URL, - "/api/llm/hist/titles"), - headers={ - "Authorization": f"Bearer {token}" - } - ).then(lambda resp: resp).either( - lambda x: x.json(), lambda x: x.json()) - return render_template("gnqa_search_history.html", **response) - -@app.route("/gnqna/hist/search/<search_term>", methods=["GET"]) +@app.route("/gnqna/hist", methods=["GET", "DELETE"]) @require_oauth2 -def fetch_hist_records(search_term): +def get_gnqa_history(): + def _error_(resp): + return render_template("gnqa_errors.html", + **{"status_code": resp.status_code, + **resp.json()}) token = session_info()["user"]["token"].either( lambda err: err, lambda tok: tok["access_token"]) + if request.method == "DELETE": + monad_requests.delete(urljoin(GN3_LOCAL_URL, "/api/llm/history"), + json=dict(request.form), + headers={ + "Authorization": f"Bearer {token}" + } + ).either( + _error_, lambda x: x.json()) response = monad_requests.get(urljoin(GN3_LOCAL_URL, - f"/api/llm/history/{search_term}"), + (f"/api/llm/history?search_term={request.args.get('search_term')}" + if request.args.get("search_term") else "/api/llm/history")), headers={ "Authorization": f"Bearer {token}" } ).then(lambda resp: resp).either( - lambda x: x.json(), lambda x: x.json()) - return render_template("gnqa_answer.html", **response) + _error_, lambda x: x.json()) + if request.args.get("search_term"): + return render_template("gnqa_answer.html", **response) + return render_template("gnqa_search_history.html", + prev_queries=response) @app.route("/gnqna/rating/<task_id>/<int(signed=True):weight>", @@ -741,6 +731,22 @@ def geneweaver_page(): return rendered_template +@app.route("/genecup", methods=('POST',)) +def genecup_page(): + start_vars = request.form + + traits = [trait.strip() for trait in start_vars['trait_list'].split(',')] + + if traits[0] != "": + symbol_string = fetch_symbols(traits) + return redirect(GENE_CUP_URL % symbol_string) + else: + rendered_template = render_template( + "empty_collection.html", **{'tool': 'GeneWeaver'}) + + return rendered_template + + @app.route("/comparison_bar_chart", methods=('POST',)) def comp_bar_chart_page(): start_vars = request.form @@ -1230,8 +1236,8 @@ def get_dataset(name): lambda err: {"roles": []}, lambda val: val ) - - metadata["editable"] = "group:resource:edit-resource" in result["roles"] + if metadata: + metadata["editable"] = "group:resource:edit-resource" in result["roles"] return render_template( "dataset.html", name=name, @@ -1346,7 +1352,7 @@ def edit_case_attributes(inbredset_id: int) -> Response: return monad_requests.post( urljoin( current_app.config["GN_SERVER_URL"], - f"/api/case-attribute/{inbredset_id}/edit"), + f"case-attribute/{inbredset_id}/edit"), json={ "edit-data": reduce(__process_data__, form.items(), {}) }, @@ -1358,29 +1364,33 @@ def edit_case_attributes(inbredset_id: int) -> Response: def __fetch_strains__(inbredset_group): return monad_requests.get(urljoin( current_app.config["GN_SERVER_URL"], - f"/api/case-attribute/{inbredset_id}/strains")).then( + f"case-attribute/{inbredset_id}/strains")).then( lambda resp: {**inbredset_group, "strains": resp.json()}) def __fetch_names__(strains): return monad_requests.get(urljoin( current_app.config["GN_SERVER_URL"], - f"/api/case-attribute/{inbredset_id}/names")).then( + f"case-attribute/{inbredset_id}/names")).then( lambda resp: {**strains, "case_attribute_names": resp.json()}) def __fetch_values__(canames): return monad_requests.get(urljoin( current_app.config["GN_SERVER_URL"], - f"/api/case-attribute/{inbredset_id}/values")).then( + f"case-attribute/{inbredset_id}/values")).then( lambda resp: {**canames, "case_attribute_values": { value["StrainName"]: value for value in resp.json()}}) + def __view_error__(err): + current_app.logger.error("%s", err) + return "We experienced an error" + return monad_requests.get(urljoin( current_app.config["GN_SERVER_URL"], - f"/api/case-attribute/{inbredset_id}")).then( + f"case-attribute/{inbredset_id}")).then( lambda resp: {"inbredset_group": resp.json()}).then( __fetch_strains__).then(__fetch_names__).then( __fetch_values__).either( - lambda err: err, # TODO: Handle error better + __view_error__, lambda values: render_template( "edit_case_attributes.html", inbredset_id=inbredset_id, **values)) |