diff --git a/biosimdb_interface/__init__.py b/biosimdb_interface/__init__.py index aaf60a5..b2dc37e 100644 --- a/biosimdb_interface/__init__.py +++ b/biosimdb_interface/__init__.py @@ -37,8 +37,11 @@ def create_app(test_config=None): instance_relative_config=True, ) # create flask instance - app.config["APPLICATION_BASE"] = os.getenv("APPLICATION_BASE", "") - + app.config["APPLICATION_BASE"] = APPLICATION_BASE + app.config["BIOSIM_SCHEMA_PATH"] = os.getenv("BIOSIM_SCHEMA_PATH", "") + app.config["ENGINE_MAPPING_SCHEMA_PATH"] = os.getenv( + "ENGINE_MAPPING_SCHEMA_PATH", "" + ) # App and Invenio OAuth2 configuration — values loaded from .env app.config.from_mapping( SECRET_KEY=os.getenv("SECRET_KEY", "dev"), @@ -46,6 +49,7 @@ def create_app(test_config=None): UPLOAD_FOLDER=os.getenv("UPLOAD_FOLDER", "/tmp"), # App specific CLIENT_ID=os.getenv("CLIENT_ID", ""), CLIENT_SECRET=os.getenv("CLIENT_SECRET", ""), + BIOSIMDB_SERVICE_TOKEN=os.getenv("BIOSIMDB_SERVICE_TOKEN", ""), AUTH_URL=os.getenv("AUTH_URL", ""), TOKEN_URL=os.getenv("TOKEN_URL", ""), BASE_URL=os.getenv("BASE_URL", ""), diff --git a/biosimdb_interface/form/__init__.py b/biosimdb_interface/form/__init__.py index 9489b44..b084a76 100644 --- a/biosimdb_interface/form/__init__.py +++ b/biosimdb_interface/form/__init__.py @@ -3,4 +3,4 @@ form_bp = Blueprint("form", __name__) -from . import extract, webform # noqa: E402, F401 +from . import errors, extract, submission, webform # noqa: E402, F401 diff --git a/biosimdb_interface/form/errors.py b/biosimdb_interface/form/errors.py new file mode 100644 index 0000000..9984008 --- /dev/null +++ b/biosimdb_interface/form/errors.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python + +from flask import ( + current_app, + flash, + jsonify, + redirect, + request, + url_for, +) +from werkzeug.exceptions import HTTPException + +from . import form_bp +from .workflows import WorkflowNotFound + + +class WebformError(Exception): + status_code = 400 + + def __init__(self, message, status_code=None, payload=None): + super().__init__() + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv["error"] = self.message + return rv + + +@form_bp.errorhandler(WebformError) +def handle_metadata_extraction_error(exc): + current_app.logger.exception(f"Metadata extraction Error: {exc}") + return jsonify(exc.to_dict()), exc.status_code + + +class ExternalAPIError(Exception): + """An external API call failed or returned an unexpected response.""" + + +class FormConversionError(ValueError): + """Raised when form values can't be parsed; holds every message in ``errors``.""" + + def __init__(self, errors, heading="Form conversion failed:"): + self.errors = list(errors) + self.heading = heading + super().__init__(heading + "\n" + "\n".join(self.errors)) + + +@form_bp.errorhandler(ExternalAPIError) +def handle_external_api_error(exc): + """Show a friendly message when an external service fails.""" + current_app.logger.error("External API error: %s", exc) + flash( + "A remote service is currently unavailable. Please try again later.", "danger" + ) + return redirect(url_for("form.webform")) + + +@form_bp.errorhandler(Exception) +def handle_unexpected_error(exc): + """Log an unexpected blueprint error and return a JSON error response.""" + if isinstance(exc, HTTPException): + return exc + + current_app.logger.exception(f"Unexpected error in form blueprint: {exc}") + message = ( + f"{type(exc).__name__}: {exc}" + if current_app.debug + else "An unexpected server error occurred. Please try again." + ) + + # extract_metadata is called via fetch, which expects JSON rather than a redirect + if request.endpoint == "form.extract_metadata": + try: + current_app.extensions["workflow_store"].delete( + request.form.get("workflow_id") + ) + except WorkflowNotFound: + pass + return jsonify({"error": f"Extraction failed: {message}"}), 500 + + flash(f"{message}", "danger") + return redirect(url_for("form.webform")) diff --git a/biosimdb_interface/form/extract.py b/biosimdb_interface/form/extract.py index 2a98f97..03f50b0 100644 --- a/biosimdb_interface/form/extract.py +++ b/biosimdb_interface/form/extract.py @@ -13,6 +13,8 @@ from flask import current_app, jsonify, request from werkzeug.utils import secure_filename +from biosimdb_interface.form.errors import WebformError + from . import form_bp from .upload import cache_extracted_files from .workflows import WorkflowNotFound @@ -28,25 +30,19 @@ def extract_files_validate(top_file, traj_file): Returns: tuple: A tuple containing: - result (dict): The extracted and populated metadata dictionary. - - validation_errors (list[str]): A list of validation error messages, - empty if validation succeeded. """ populator = MetadataPopulator( - schema_path=os.getenv("ENGINE_MAPPING_SCHEMA_PATH", ""), + schema_path=current_app.config["ENGINE_MAPPING_SCHEMA_PATH"], top_file=top_file, traj_file=traj_file, store_file_metadata=False, # not on webform so exclude ) result = populator.populate() - biosimschema_path = os.getenv("BIOSIM_SCHEMA_PATH", "") - validation_errors = [] - try: - populator.validate(result, biosimschema_path, strict=True) - except ValueError as e: - validation_errors = str(e).splitlines() + biosimschema_path = current_app.config["BIOSIM_SCHEMA_PATH"] + populator.validate(result, biosimschema_path, strict=True) - return result, validation_errors + return result @form_bp.route("/extract_metadata", methods=["POST"]) @@ -62,68 +58,43 @@ def extract_metadata(): and the result is validated against the schema at ``BIOSIM_SCHEMA_PATH``. Returns: - JSON response with one of: - - ``{"simulation_metadata": ..., "message": "..."}`` on success. - - ``{"simulation_metadata": ..., "validation_errors": [...]}`` if schema validation fails. - - ``{"error": "..."}`` with status 400 if files are missing, or 500 on unexpected error. + JSON response: + - ``{"simulation_metadata": ..., "message": "..."}`` """ workflow_id = request.form.get("workflow_id") topology = request.files.get("topology") trajectories = request.files.getlist("trajectory[]") if not topology or not trajectories: - return jsonify({"error": "Simulation files are missing."}), 400 + raise WebformError("Simulation files are missing!", status_code=404) try: workflow = current_app.extensions["workflow_store"].reset(workflow_id) except WorkflowNotFound as exc: - return jsonify({"error": str(exc)}), 400 + return jsonify({"Workflow error": str(exc)}), 400 tmpdir = workflow.tmpdir - try: - topology = request.files.get("topology") - trajectories = request.files.getlist("trajectory[]") - - if not topology or not trajectories: - return jsonify({"error": "Simulation files are missing."}), 400 - - topo_path = os.path.join(tmpdir, secure_filename(topology.filename)) - topology.save(topo_path, buffer_size=16 * 1024 * 1024) # 16 MB chunks - - traj_files = [] - for traj in trajectories: - traj_path = os.path.join(tmpdir, secure_filename(traj.filename)) - traj.save(traj_path, buffer_size=16 * 1024 * 1024) - traj_files.append(traj_path) - - saved_files = {"topology": [topo_path], "trajectory": traj_files} - cache_extracted_files(tmpdir, saved_files) - - result, validation_errors = extract_files_validate(topo_path, traj_files) - - if validation_errors: - return jsonify( - { - "simulation_metadata": result, - "validation_errors": validation_errors, - } - ) - else: - return jsonify( - { - "simulation_metadata": result, - "message": "Metadata extracted successfully.", - } - ) - - except Exception as e: - current_app.extensions["workflow_store"].delete(workflow_id) - print(f"ERROR: {e}") - import traceback - - traceback.print_exc() - return jsonify({"error": str(e)}), 500 + topo_path = os.path.join(tmpdir, secure_filename(topology.filename)) + topology.save(topo_path, buffer_size=16 * 1024 * 1024) # 16 MB chunks + + traj_files = [] + for traj in trajectories: + traj_path = os.path.join(tmpdir, secure_filename(traj.filename)) + traj.save(traj_path, buffer_size=16 * 1024 * 1024) + traj_files.append(traj_path) + + saved_files = {"topology": [topo_path], "trajectory": traj_files} + cache_extracted_files(tmpdir, saved_files) + + result = extract_files_validate(topo_path, traj_files) + + return jsonify( + { + "simulation_metadata": result, + "message": "Metadata extracted successfully.", + } + ) @form_bp.route("/clear_extraction", methods=["POST"]) diff --git a/biosimdb_interface/form/submission.py b/biosimdb_interface/form/submission.py new file mode 100644 index 0000000..0467e04 --- /dev/null +++ b/biosimdb_interface/form/submission.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python + +import json +import os +import shutil + +import requests +from flask import ( + current_app, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) + +from biosimdb_interface.login.community_invite import ( + INVITE_FAILED, + INVITE_PENDING, + INVITE_SENT, + invite_user, +) + +from . import form_bp +from .upload import ( + SIM_METADATA_FILENAME, + is_submission_cancelled, + mark_submission_cancelled, + prepare_for_invenio, +) +from .webform import _get_workflow +from .workflows import WorkflowNotFound + + +def _discard_workflow(): + try: + current_app.extensions["workflow_store"].delete(request.form.get("workflow_id")) + except WorkflowNotFound: + pass + + +@form_bp.route("/resume_submit") +def resume_submit(): + """Resume a pending submission after successful login. + + Redirects to login if unauthenticated, or to the webform if no pending + submission is found in the session. + """ + if not session.get("access_token"): + return redirect(url_for("login.login")) + + try: + workflow = _get_workflow() + except WorkflowNotFound as exc: + return jsonify({"validation_errors": [str(exc)]}), 400 + + tmpdir = workflow.tmpdir + + pending_form_path = ( + os.path.join(tmpdir, "pending_form_data.json") if tmpdir else None + ) + if not tmpdir or not pending_form_path or not os.path.isfile(pending_form_path): + current_app.logger.exception( + f"No pending submission found: tmpdir {tmpdir}, pending_form_path {pending_form_path}" + ) + flash("No pending submission found.", "warning") + return redirect(url_for("form.webform")) + + return render_template( + "form/loading.html", + workflow_id=request.args["workflow_id"], + ) + + +@form_bp.route("/do_submit", methods=["POST"]) +def do_submit(): + """Execute the deferred Invenio upload using session-stored form data. + Called automatically by the loading page after login. + Automatically invite user to Invenio instance community, then submit. + Clears pending session data after upload and renders the success page + with the record URL. + """ + try: + workflow = current_app.extensions["workflow_store"].get( + request.args.get("workflow_id") + ) + except WorkflowNotFound as exc: + return jsonify({"validation_errors": [str(exc)]}), 400 + + tmpdir = workflow.tmpdir + + if not tmpdir: + current_app.logger.exception(f"No pending submission found: tmpdir {tmpdir}") + + flash("No pending submission found. Please submit again.", "warning") + return redirect(url_for("form.webform")) + + if is_submission_cancelled(tmpdir): + _discard_workflow() + return redirect(url_for("form.webform")) + + sim_metadata_path = os.path.join(tmpdir, SIM_METADATA_FILENAME) + if not os.path.isfile(sim_metadata_path): + current_app.logger.exception( + f"No pending submission found: sim_metadata_path {sim_metadata_path}" + ) + flash("No pending submission found. Please submit again.", "warning") + return redirect(url_for("form.webform")) + + with open(sim_metadata_path) as f: + sim_metadata = json.load(f) + + try: + token = session.get("access_token") + invite_status = invite_user("biosimdb", token) + + if is_submission_cancelled(tmpdir): + _discard_workflow() + return redirect(url_for("form.webform")) + + draft_id = prepare_for_invenio(sim_metadata, tmpdir) + + if draft_id: + current_app.logger.debug( + f"Draft ID {draft_id} created and submitted, tmpdir being deleted" + ) + shutil.rmtree(tmpdir, ignore_errors=True) + + else: + _discard_workflow() + flash("Upload failed. Please try again.", "danger") + return redirect(url_for("form.webform")) + except requests.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + current_app.logger.error(exc) + if status in (401, 403): + session.pop("access_token", None) # force fresh login + session["post_login_redirect"] = url_for( + "form.resume_submit", + workflow_id=request.form["workflow_id"], + ) + flash( + "Your login session is no longer valid for upload. " + "Please sign in again and the submission will resume automatically.", + "warning", + ) + return redirect(url_for("login.login")) + + session.pop("access_token", None) # force fresh login + _discard_workflow() + flash("Upload failed unexpectedly. Please try again.", "danger") + return redirect(url_for("form.webform")) + + except requests.exceptions.JSONDecodeError: + current_app.logger.exception("Invenio returned a non-JSON response") + _discard_workflow() + flash( + "Upload failed: the repository returned an unexpected response. " + "The file may be too large for the server to accept.", + "danger", + ) + return redirect(url_for("form.webform")) + + # success: now clear submission data and logout user. + _discard_workflow() + session.pop("access_token", None) + session.pop("user_email", None) + session.pop("post_login_redirect", None) + + BASE_URL = current_app.config["BASE_URL"] + record_url = f"{BASE_URL}/uploads/{draft_id}" + session["submitted_record_url"] = record_url + if invite_status == INVITE_SENT: + flash( + "You are not yet a member of the BioSimDB community. " + "Please check your email for an invitation and accept it " + "to complete your submission.", + "info", + ) + if invite_status == INVITE_PENDING: + flash( + "You have already been invited to the BioSimDB community. " + "Please check your email for the invitation and accept it " + "to complete your submission. If you cannot find your invite, " + 'please check "My dashboard > Requests" in BioSimDB.', + "info", + ) + if invite_status == INVITE_FAILED: + flash( + "Invitation to the BioSimDB community failed. " + "Please contact the BioSimDB team for help with your submission.", + "warning", + ) + return redirect(url_for("form.submit_success")) + + +@form_bp.route("/cancel_submit", methods=["POST"]) +def cancel_submit(): + """Signal an in-progress submission to stop and reset client-facing session state. + + Does not delete the tmpdir directly, since the in-flight do_submit request + still owns those files; do_submit checks the cancellation flag itself and + performs its own cleanup once it is safe to do so. + """ + try: + workflow = current_app.extensions["workflow_store"].get( + request.args.get("workflow_id") + ) + except WorkflowNotFound as exc: + return jsonify({"validation_errors": [str(exc)]}), 400 + + tmpdir = workflow.tmpdir + mark_submission_cancelled(tmpdir) + + for key in ( + "post_login_redirect", + "access_token", + "user_email", + ): + session.pop(key, None) + session["force_clear_client_state"] = True + return jsonify({"success": True}) + + +@form_bp.route("/submit_success") +def submit_success(): + """Render the successful Invenio submission page.""" + record_url = session.get("submitted_record_url") + if not record_url: + flash("No completed submission found.", "warning") + return redirect(url_for("form.webform")) + return render_template("form/submit_success.html", record_url=record_url) diff --git a/biosimdb_interface/form/utils.py b/biosimdb_interface/form/utils.py index d564bbe..2d6bcec 100644 --- a/biosimdb_interface/form/utils.py +++ b/biosimdb_interface/form/utils.py @@ -7,19 +7,11 @@ from flask import current_app +from biosimdb_interface.form.errors import FormConversionError from biosimdb_interface.schema.invenio import INVENIO_FORM_EMPTY from biosimdb_interface.schema.webform import get_simulation_metadata -class FormConversionError(ValueError): - """Raised when form values can't be parsed; holds every message in ``errors``.""" - - def __init__(self, errors, heading="Form conversion failed:"): - self.errors = list(errors) - self.heading = heading - super().__init__(heading + "\n" + "\n".join(self.errors)) - - def make_upload_tmpdir(prefix): upload_root = current_app.config["UPLOAD_FOLDER"] os.makedirs(upload_root, exist_ok=True) diff --git a/biosimdb_interface/form/webform.py b/biosimdb_interface/form/webform.py index 5c34baa..00b6d48 100644 --- a/biosimdb_interface/form/webform.py +++ b/biosimdb_interface/form/webform.py @@ -1,8 +1,5 @@ #!/usr/bin/env python -import json -import os -import requests from biosim_extractor.metadata.convertpopulated import ( UnitConversionError, convert_populated_metadata_units, @@ -13,7 +10,6 @@ ) from flask import ( current_app, - flash, jsonify, redirect, render_template, @@ -21,19 +17,13 @@ session, url_for, ) -from werkzeug.exceptions import HTTPException -from biosimdb_interface.login.community_invite import invite_user from biosimdb_interface.schema.webform import WEBFORM_SCHEMA, get_simulation_metadata from . import form_bp from .upload import ( - SIM_METADATA_FILENAME, extract_uploaded_file_metadata, - is_submission_cancelled, load_extracted_files, - mark_submission_cancelled, - prepare_for_invenio, save_pending_submission, verify_cached_file_meta, ) @@ -68,21 +58,6 @@ def _handle_submit(json_form, tmpdir): _HANDLERS = {"save": _handle_save, "submit": _handle_submit} -@form_bp.errorhandler(Exception) -def handle_unexpected_error(exc): - """Log an unexpected blueprint error and return a JSON error response.""" - if isinstance(exc, HTTPException): - return exc - - current_app.logger.exception("Unhandled error in form blueprint") - message = ( - f"{type(exc).__name__}: {exc}" - if current_app.debug - else "An unexpected server error occurred. Please try again." - ) - return jsonify({"validation_errors": [message]}), 500 - - def _errors_response(errors, status=200): """JSON response in the shape the front end expects.""" return jsonify({"validation_errors": errors}), status @@ -123,7 +98,8 @@ def _build_metadata(form, tmpdir, action): try: # validate metadata against linkml schema - validate_metadata(json_form, os.getenv("BIOSIM_SCHEMA_PATH", ""), strict=True) + BIOSIM_SCHEMA_PATH = current_app.config["BIOSIM_SCHEMA_PATH"] + validate_metadata(json_form, BIOSIM_SCHEMA_PATH, strict=True) except MetadataValidationError as e: groups.append(_group(e)) return json_form, groups @@ -186,172 +162,3 @@ def webform_post(): return _errors_response(groups) return _HANDLERS[action](json_form, tmpdir) - - -@form_bp.route("/resume_submit") -def resume_submit(): - """Resume a pending submission after successful login. - - Redirects to login if unauthenticated, or to the webform if no pending - submission is found in the session. - """ - if not session.get("access_token"): - return redirect(url_for("login.login")) - - try: - workflow = _get_workflow() - except WorkflowNotFound as exc: - return jsonify({"validation_errors": [str(exc)]}), 400 - - tmpdir = workflow.tmpdir - - pending_form_path = ( - os.path.join(tmpdir, "pending_form_data.json") if tmpdir else None - ) - if not tmpdir or not pending_form_path or not os.path.isfile(pending_form_path): - flash("No pending submission found.", "warning") - return redirect(url_for("form.webform")) - - return render_template( - "form/loading.html", - workflow_id=request.args["workflow_id"], - ) - - -@form_bp.route("/do_submit", methods=["POST"]) -def do_submit(): - """Execute the deferred Invenio upload using session-stored form data. - Called automatically by the loading page after login. - Automatically invite user to Invenio instance community, then submit. - Clears pending session data after upload and renders the success page - with the record URL. - """ - try: - workflow = current_app.extensions["workflow_store"].get( - request.args.get("workflow_id") - ) - except WorkflowNotFound as exc: - return jsonify({"validation_errors": [str(exc)]}), 400 - - tmpdir = workflow.tmpdir - - if not tmpdir: - flash("No pending submission found. Please submit again.", "warning") - return redirect(url_for("form.webform")) - - if is_submission_cancelled(tmpdir): - current_app.extensions["workflow_store"].delete( - request.values.get("workflow_id") - ) - return redirect(url_for("form.webform")) - - sim_metadata_path = os.path.join(tmpdir, SIM_METADATA_FILENAME) - if not os.path.isfile(sim_metadata_path): - flash("No pending submission found. Please submit again.", "warning") - return redirect(url_for("form.webform")) - - with open(sim_metadata_path) as f: - sim_metadata = json.load(f) - - try: - token = session.get("access_token") - invite_user("biosimdb", token) - - if is_submission_cancelled(tmpdir): - current_app.extensions["workflow_store"].delete( - request.values.get("workflow_id") - ) - return redirect(url_for("form.webform")) - - draft_id = prepare_for_invenio(sim_metadata, tmpdir) - - if not draft_id: - current_app.extensions["workflow_store"].delete( - request.values.get("workflow_id") - ) - flash("Upload failed. Please try again.", "danger") - return redirect(url_for("form.webform")) - - except requests.HTTPError as exc: - status = exc.response.status_code if exc.response is not None else None - - if status in (401, 403): - session.pop("access_token", None) # force fresh login - session["post_login_redirect"] = url_for( - "form.resume_submit", - workflow_id=request.form["workflow_id"], - ) - flash( - "Your login session is no longer valid for upload. " - "Please sign in again and the submission will resume automatically.", - "warning", - ) - return redirect(url_for("login.login")) - - session.pop("access_token", None) # force fresh login - current_app.extensions["workflow_store"].delete( - request.values.get("workflow_id") - ) - flash("Upload failed unexpectedly. Please try again.", "danger") - return redirect(url_for("form.webform")) - - except requests.exceptions.JSONDecodeError: - current_app.logger.exception("Invenio returned a non-JSON response") - current_app.extensions["workflow_store"].delete( - request.values.get("workflow_id") - ) - flash( - "Upload failed: the repository returned an unexpected response. " - "The file may be too large for the server to accept.", - "danger", - ) - return redirect(url_for("form.webform")) - - # success: now clear submission data and logout user. - current_app.extensions["workflow_store"].delete(request.values.get("workflow_id")) - session.pop("access_token", None) - session.pop("user_email", None) - session.pop("post_login_redirect", None) - - BASE_URL = current_app.config["BASE_URL"] - record_url = f"{BASE_URL}/uploads/{draft_id}" - session["submitted_record_url"] = record_url - return redirect(url_for("form.submit_success")) - - -@form_bp.route("/cancel_submit", methods=["POST"]) -def cancel_submit(): - """Signal an in-progress submission to stop and reset client-facing session state. - - Does not delete the tmpdir directly, since the in-flight do_submit request - still owns those files; do_submit checks the cancellation flag itself and - performs its own cleanup once it is safe to do so. - """ - try: - workflow = current_app.extensions["workflow_store"].get( - request.args.get("workflow_id") - ) - except WorkflowNotFound as exc: - return jsonify({"validation_errors": [str(exc)]}), 400 - - tmpdir = workflow.tmpdir - mark_submission_cancelled(tmpdir) - - for key in ( - "post_login_redirect", - "access_token", - "user_email", - ): - session.pop(key, None) - session["force_clear_client_state"] = True - return jsonify({"success": True}) - - -@form_bp.route("/submit_success") -def submit_success(): - """Render the successful Invenio submission page.""" - record_url = session.get("submitted_record_url") - if not record_url: - flash("No completed submission found.", "warning") - return redirect(url_for("form.webform")) - return render_template("form/submit_success.html", record_url=record_url) diff --git a/biosimdb_interface/login/community_invite.py b/biosimdb_interface/login/community_invite.py index 8989be2..550e409 100644 --- a/biosimdb_interface/login/community_invite.py +++ b/biosimdb_interface/login/community_invite.py @@ -4,6 +4,13 @@ import requests from flask import current_app +from biosimdb_interface.form.errors import ExternalAPIError + +INVITE_ALREADY_MEMBER = "already_member" +INVITE_SENT = "invited" +INVITE_FAILED = "invite_failed" +INVITE_PENDING = "invite_pending" + def _fetch_user_id(access_token: str): """ @@ -18,8 +25,9 @@ def _fetch_user_id(access_token: str): api_base = current_app.config.get("API_BASE", "").rstrip("/") url = f"{api_base}/me" headers = {"Authorization": f"Bearer {access_token}"} - resp = requests.get(url, headers=headers, timeout=10) - data = resp.json() + r = requests.get(url, headers=headers, timeout=10) + r.raise_for_status() + data = r.json() if isinstance(data, dict): if data.get("id"): @@ -38,28 +46,56 @@ def invite_user(slug: str, access_token: str): slug (str): Name of the community in the Invenio instance. access_token (str): OAuth2 bearer token for the authenticated user. """ - user_id = _fetch_user_id(access_token) - api_base = current_app.config.get("API_BASE", "").rstrip("/") - headers = {"Authorization": f"Bearer {access_token}"} + try: + user_id = _fetch_user_id(access_token) + api_base = current_app.config.get("API_BASE", "").rstrip("/") - r = requests.get(api_base + "/communities/" + slug, headers=headers) - community_id = r.json()["id"] - - r = requests.get( - api_base + "/communities/" + community_id + "/members", - params={"size": 1000}, - headers=headers, - ) - found = False - for m in r.json()["hits"]["hits"]: - if m["member"]["id"] == str(user_id): - found = True - if found: - pass - else: - data = {"members": [{"id": user_id, "type": "user"}], "role": "reader"} - requests.post( + service_headers = { + "Authorization": f"Bearer {current_app.config['BIOSIMDB_SERVICE_TOKEN']}" + } + headers = {"Authorization": f"Bearer {access_token}"} + + r = requests.get(api_base + "/communities/" + slug, headers=headers, timeout=10) + r.raise_for_status() + community_id = r.json()["id"] + + r = requests.get( + api_base + "/communities/" + community_id + "/members", + params={"size": 10000}, + headers=service_headers, + timeout=10, + ) + r.raise_for_status() + + found = any(m["member"]["id"] == str(user_id) for m in r.json()["hits"]["hits"]) + if found: + return INVITE_ALREADY_MEMBER + + data = {"members": [{"id": str(user_id), "type": "user"}], "role": "reader"} + r = requests.post( api_base + "/communities/" + community_id + "/invitations", json=data, - headers=headers, + headers=service_headers, + timeout=10, + ) + if r.ok: + return INVITE_SENT + if r.status_code == 400 and "already added or invited" in r.text: + current_app.logger.info("User %s already invited to %s", user_id, slug) + return INVITE_PENDING + + current_app.logger.error( + "Community invitation failed: %s %s", r.status_code, r.text + ) + return INVITE_FAILED + + except requests.HTTPError as exc: + current_app.logger.error( + "Request failed: %s %s", exc.response.status_code, exc.response.text ) + raise ExternalAPIError( + f"Community service returned {exc.response.status_code}" + ) from exc + except (requests.RequestException, ValueError, KeyError, TypeError) as exc: + current_app.logger.exception("Community service request failed") + raise ExternalAPIError("Could not reach the community service") from exc diff --git a/biosimdb_interface/static/js/form/metadata.js b/biosimdb_interface/static/js/form/metadata.js index 3b30a72..ac36ca6 100644 --- a/biosimdb_interface/static/js/form/metadata.js +++ b/biosimdb_interface/static/js/form/metadata.js @@ -44,10 +44,17 @@ document.addEventListener('click', function(e) { method: 'POST', body: formData }) - .then(r => r.json()) + .then(async r => { + const ct = r.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + throw new Error(`Unexpected server response (${r.status}).`); + } + return r.json(); + }) .then(data => { if (data.error) { - alert(data.error); + // error in metadata extraction occurred + showAlert(`${escapeHtml(data.error)}`, 'danger', 5000); } else if (data.simulation_metadata) { try { populateFields(data.simulation_metadata); @@ -388,6 +395,23 @@ document.addEventListener('change', function(e) { if (e.target.closest('#simulationForm') && e.target.type !== 'file') saveFormState(); }); +/** + * Fade out and remove an existing Bootstrap alert after a delay. + * + * @param {HTMLElement} alertEl - Rendered Bootstrap alert element. + * @param {number} timeout - Delay in milliseconds. + */ +function dismissAlertAfter(alertEl, timeout = 5000) { + setTimeout(() => { + alertEl.classList.remove('show'); + setTimeout(() => alertEl.remove(), 150); + }, timeout); +} + +document.querySelectorAll('.alert').forEach((alertEl) => { + dismissAlertAfter(alertEl, 10000); +}); + /** * Shows a dismissible Bootstrap alert fixed at the top of the page. * @param {string} html - HTML content for the alert body. @@ -400,12 +424,7 @@ function showAlert(html, type = 'warning', timeout = 50000) { alertEl.style.cssText = 'position:fixed; top:1rem; left:50%; transform:translateX(-50%); z-index:1055; min-width:400px; max-width:700px; box-shadow:0 2px 8px rgba(0,0,0,.15);'; alertEl.innerHTML = `${html}`; document.body.appendChild(alertEl); - if (timeout) { - setTimeout(() => { - alertEl.classList.remove('show'); - setTimeout(() => alertEl.remove(), 150); - }, timeout); - } + if (timeout) dismissAlertAfter(alertEl, timeout); } function escapeHtml(s) { @@ -545,23 +564,6 @@ function highlightValidationFields(validationErrors) { firstInvalid?.focus({ preventScroll: true }); } -/** - * Fade out and remove an existing Bootstrap alert after a delay. - * - * @param {HTMLElement} alertEl - Rendered Bootstrap alert element. - * @param {number} timeout - Delay in milliseconds. - */ -function dismissAlertAfter(alertEl, timeout = 5000) { - setTimeout(() => { - alertEl.classList.remove('show'); - setTimeout(() => alertEl.remove(), 150); - }, timeout); -} - -document.querySelectorAll('.alert').forEach((alertEl) => { - dismissAlertAfter(alertEl, 5000); -}); - /** * Guards save/submit actions by checking that metadata has been extracted. * Uses native browser validation UI (`reportValidity`) to surface the error on the topology input. @@ -592,7 +594,13 @@ function validateAndSubmit(form, onSuccess) { formData.delete('trajectory[]'); formData.append('save', '1'); return fetch(window.APPLICATION_BASE + '/webform', { method: 'POST', body: formData }) - .then(r => r.json()) + .then(async r => { + const ct = r.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + throw new Error(`Unexpected server response (${r.status}).`); + } + return r.json(); + }) .then(data => { if (data.validation_errors && data.validation_errors.length > 0) { highlightValidationFields(data.validation_errors); diff --git a/tests/test_form/test_extract.py b/tests/test_form/test_extract.py index 7ae93dd..e3355e2 100644 --- a/tests/test_form/test_extract.py +++ b/tests/test_form/test_extract.py @@ -5,7 +5,7 @@ def test_extract_missing_files_returns_400(client): response = client.post("/extract_metadata", data={}) - assert response.status_code == 400 + assert response.status_code == 404 assert "missing" in response.get_json()["error"].lower() @@ -36,6 +36,7 @@ def test_extract_exception_returns_500(client): "/extract_metadata", data=data, content_type="multipart/form-data" ) assert response.status_code == 500 + assert "Extraction failed" in response.get_json()["error"] def test_two_tabs_keep_separate_extraction_directories(client, app): diff --git a/tests/test_form/test_submission.py b/tests/test_form/test_submission.py new file mode 100644 index 0000000..67a6dde --- /dev/null +++ b/tests/test_form/test_submission.py @@ -0,0 +1,177 @@ +import json +import os +from unittest.mock import Mock, patch + +import requests + + +def test_resume_submit_with_pending_data(client, workflow): + """Resume resolves the workflow ID passed in the query string.""" + with open(os.path.join(workflow.tmpdir, "pending_form_data.json"), "w") as f: + json.dump({"x": ["y"]}, f) + + with client.session_transaction() as sess: + sess["access_token"] = "tok" + + response = client.get(f"/resume_submit?workflow_id={workflow.workflow_id}") + + assert response.status_code == 200 + + +def test_do_submit_cancelled(client, workflow): + """Stops cancelled submissions.""" + with patch( + "biosimdb_interface.form.submission.is_submission_cancelled", + return_value=True, + ): + response = client.post(f"/do_submit?workflow_id={workflow.workflow_id}") + assert response.status_code == 302 + + +def test_do_submit_upload_failure(client, workflow): + """Redirects when upload returns no draft.""" + metadata = os.path.join(workflow.tmpdir, "simulation_metadata.json") + with open(metadata, "w") as file: + json.dump({}, file) + + with ( + patch("biosimdb_interface.form.submission.invite_user"), + patch( + "biosimdb_interface.form.submission.prepare_for_invenio", + return_value=None, + ), + ): + response = client.post(f"/do_submit?workflow_id={workflow.workflow_id}") + + assert response.status_code == 302 + + +def _write_metadata(workflow): + metadata = os.path.join(workflow.tmpdir, "simulation_metadata.json") + with open(metadata, "w") as file: + json.dump({}, file) + + +def test_do_submit_flashes_invite_message(client, workflow): + """Tells a newly invited user to check their email.""" + _write_metadata(workflow) + + with ( + patch("biosimdb_interface.form.submission.invite_user", return_value="invited"), + patch( + "biosimdb_interface.form.submission.prepare_for_invenio", + return_value="draft-1", + ), + ): + response = client.post( + f"/do_submit?workflow_id={workflow.workflow_id}", + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"check your email" in response.data + + +def test_do_submit_no_invite_message_for_existing_member(client, workflow): + """Does not show the invite message to existing members.""" + _write_metadata(workflow) + + with ( + patch( + "biosimdb_interface.form.submission.invite_user", + return_value="already_member", + ), + patch( + "biosimdb_interface.form.submission.prepare_for_invenio", + return_value="draft-1", + ), + ): + response = client.post( + f"/do_submit?workflow_id={workflow.workflow_id}", + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"check your email" not in response.data + + +def test_do_submit_no_invite_message_if_upload_fails(client, workflow): + """Does not mention an invitation when the upload fails.""" + _write_metadata(workflow) + + with ( + patch("biosimdb_interface.form.submission.invite_user", return_value="invited"), + patch( + "biosimdb_interface.form.submission.prepare_for_invenio", + return_value=None, + ), + ): + response = client.post( + f"/do_submit?workflow_id={workflow.workflow_id}", + follow_redirects=True, + ) + + assert b"check your email" not in response.data + + +def test_do_submit_http_error(client, workflow): + """Redirects after an upload HTTP failure.""" + metadata = os.path.join(workflow.tmpdir, "simulation_metadata.json") + with open(metadata, "w") as file: + json.dump({}, file) + + error = requests.HTTPError(response=Mock(status_code=500)) + with ( + patch("biosimdb_interface.form.submission.invite_user"), + patch( + "biosimdb_interface.form.submission.prepare_for_invenio", + side_effect=error, + ), + ): + response = client.post(f"/do_submit?workflow_id={workflow.workflow_id}") + + assert response.status_code == 302 + + +def test_cancel_submit(client, workflow): + """Marks a submission as cancelled and clears session state.""" + with client.session_transaction() as sess: + sess["access_token"] = "token" + sess["user_email"] = "user" + + with patch( + "biosimdb_interface.form.submission.mark_submission_cancelled" + ) as cancel: + response = client.post(f"/cancel_submit?workflow_id={workflow.workflow_id}") + + assert response.status_code == 200 + cancel.assert_called_once_with(workflow.tmpdir) + + +def test_submit_success_without_record(client): + """Redirects when no submitted record exists.""" + response = client.get("/submit_success") + assert response.status_code == 302 + + +def test_do_submit_http_unauthorized(client, workflow): + """Forces login after an expired upload session.""" + metadata = os.path.join(workflow.tmpdir, "simulation_metadata.json") + with open(metadata, "w") as file: + json.dump({}, file) + + error = requests.HTTPError(response=Mock(status_code=401)) + with ( + patch("biosimdb_interface.form.submission.invite_user"), + patch( + "biosimdb_interface.form.submission.prepare_for_invenio", + side_effect=error, + ), + ): + response = client.post( + f"/do_submit?workflow_id={workflow.workflow_id}", + data={"workflow_id": workflow.workflow_id}, + ) + + assert response.status_code == 302 + assert "/login" in response.headers["Location"] diff --git a/tests/test_form/test_upload.py b/tests/test_form/test_upload.py index ec37192..9b27050 100644 --- a/tests/test_form/test_upload.py +++ b/tests/test_form/test_upload.py @@ -92,8 +92,8 @@ def test_do_submit_calls_invenio(client, workflow): sess["access_token"] = "fake-token" with ( - patch("biosimdb_interface.form.webform.invite_user") as mock_invite, - patch("biosimdb_interface.form.webform.prepare_for_invenio") as mock_prepare, + patch("biosimdb_interface.form.submission.invite_user") as mock_invite, + patch("biosimdb_interface.form.submission.prepare_for_invenio") as mock_prepare, ): mock_prepare.return_value = "draft-123" diff --git a/tests/test_form/test_webform.py b/tests/test_form/test_webform.py index 50f89d3..7b652f5 100644 --- a/tests/test_form/test_webform.py +++ b/tests/test_form/test_webform.py @@ -1,8 +1,5 @@ -import json -import os -from unittest.mock import Mock, patch +from unittest.mock import patch -import requests from biosim_extractor.metadata.validatemetadata import MetadataValidationError from biosimdb_interface.form.workflows import WorkflowNotFound @@ -104,19 +101,6 @@ def test_submit_with_token_renders_loading(client, extracted_workflow): assert b"Submitting" in response.data -def test_resume_submit_with_pending_data(client, workflow): - """Resume resolves the workflow ID passed in the query string.""" - with open(os.path.join(workflow.tmpdir, "pending_form_data.json"), "w") as f: - json.dump({"x": ["y"]}, f) - - with client.session_transaction() as sess: - sess["access_token"] = "tok" - - response = client.get(f"/resume_submit?workflow_id={workflow.workflow_id}") - - assert response.status_code == 200 - - def test_webform_missing_workflow(client): """Rejects an unknown workflow.""" with patch( @@ -214,66 +198,6 @@ def test_do_submit_missing_metadata(client, workflow): assert response.status_code == 302 -def test_do_submit_cancelled(client, workflow): - """Stops cancelled submissions.""" - with patch( - "biosimdb_interface.form.webform.is_submission_cancelled", - return_value=True, - ): - response = client.post(f"/do_submit?workflow_id={workflow.workflow_id}") - assert response.status_code == 302 - - -def test_do_submit_upload_failure(client, workflow): - """Redirects when upload returns no draft.""" - metadata = os.path.join(workflow.tmpdir, "simulation_metadata.json") - with open(metadata, "w") as file: - json.dump({}, file) - - with ( - patch("biosimdb_interface.form.webform.invite_user"), - patch( - "biosimdb_interface.form.webform.prepare_for_invenio", - return_value=None, - ), - ): - response = client.post(f"/do_submit?workflow_id={workflow.workflow_id}") - - assert response.status_code == 302 - - -def test_do_submit_http_error(client, workflow): - """Redirects after an upload HTTP failure.""" - metadata = os.path.join(workflow.tmpdir, "simulation_metadata.json") - with open(metadata, "w") as file: - json.dump({}, file) - - error = requests.HTTPError(response=Mock(status_code=500)) - with ( - patch("biosimdb_interface.form.webform.invite_user"), - patch( - "biosimdb_interface.form.webform.prepare_for_invenio", - side_effect=error, - ), - ): - response = client.post(f"/do_submit?workflow_id={workflow.workflow_id}") - - assert response.status_code == 302 - - -def test_cancel_submit(client, workflow): - """Marks a submission as cancelled and clears session state.""" - with client.session_transaction() as sess: - sess["access_token"] = "token" - sess["user_email"] = "user" - - with patch("biosimdb_interface.form.webform.mark_submission_cancelled") as cancel: - response = client.post(f"/cancel_submit?workflow_id={workflow.workflow_id}") - - assert response.status_code == 200 - cancel.assert_called_once_with(workflow.tmpdir) - - def test_cancel_submit_missing_workflow(client): """Rejects cancellation for an unknown workflow.""" with patch( @@ -282,32 +206,3 @@ def test_cancel_submit_missing_workflow(client): ): response = client.post("/cancel_submit?workflow_id=missing") assert response.status_code == 400 - - -def test_submit_success_without_record(client): - """Redirects when no submitted record exists.""" - response = client.get("/submit_success") - assert response.status_code == 302 - - -def test_do_submit_http_unauthorized(client, workflow): - """Forces login after an expired upload session.""" - metadata = os.path.join(workflow.tmpdir, "simulation_metadata.json") - with open(metadata, "w") as file: - json.dump({}, file) - - error = requests.HTTPError(response=Mock(status_code=401)) - with ( - patch("biosimdb_interface.form.webform.invite_user"), - patch( - "biosimdb_interface.form.webform.prepare_for_invenio", - side_effect=error, - ), - ): - response = client.post( - f"/do_submit?workflow_id={workflow.workflow_id}", - data={"workflow_id": workflow.workflow_id}, - ) - - assert response.status_code == 302 - assert "/login" in response.headers["Location"] diff --git a/tests/test_login/test_community_invite.py b/tests/test_login/test_community_invite.py index 6adc57c..f826760 100644 --- a/tests/test_login/test_community_invite.py +++ b/tests/test_login/test_community_invite.py @@ -49,12 +49,14 @@ def test_invite_user_does_not_post_if_member_exists(app): _resp({"hits": {"hits": [{"member": {"id": "42"}}]}}), ] - mod.invite_user("biosimdb", "tok") + result = mod.invite_user("biosimdb", "tok") post.assert_not_called() + assert result == mod.INVITE_ALREADY_MEMBER def test_invite_user_posts_if_member_missing(app): + app.config["BIOSIMDB_SERVICE_TOKEN"] = "svc-token" with ( app.app_context(), patch( @@ -67,11 +69,56 @@ def test_invite_user_posts_if_member_missing(app): _resp({"id": "comm-1"}), _resp({"hits": {"hits": [{"member": {"id": "99"}}]}}), ] + post.return_value.ok = True - mod.invite_user("biosimdb", "tok") + result = mod.invite_user("biosimdb", "tok") post.assert_called_once_with( "http://localhost/api/communities/comm-1/invitations", - json={"members": [{"id": 42, "type": "user"}], "role": "reader"}, - headers={"Authorization": "Bearer tok"}, + json={"members": [{"id": "42", "type": "user"}], "role": "reader"}, + headers={"Authorization": "Bearer svc-token"}, + timeout=10, ) + assert result == mod.INVITE_SENT + + +def test_invite_user_returns_failed_if_post_fails(app): + with ( + app.app_context(), + patch( + "biosimdb_interface.login.community_invite._fetch_user_id", return_value=42 + ), + patch("biosimdb_interface.login.community_invite.requests.get") as get, + patch("biosimdb_interface.login.community_invite.requests.post") as post, + ): + get.side_effect = [ + _resp({"id": "comm-1"}), + _resp({"hits": {"hits": []}}), + ] + post.return_value.ok = False + post.return_value.status_code = 500 + post.return_value.text = "error" + + assert mod.invite_user("biosimdb", "tok") == mod.INVITE_FAILED + + +def test_invite_user_returns_pending_if_already_invited(app): + with ( + app.app_context(), + patch( + "biosimdb_interface.login.community_invite._fetch_user_id", return_value=42 + ), + patch("biosimdb_interface.login.community_invite.requests.get") as get, + patch("biosimdb_interface.login.community_invite.requests.post") as post, + ): + get.side_effect = [ + _resp({"id": "comm-1"}), + _resp({"hits": {"hits": []}}), + ] + post.return_value.ok = False + post.return_value.status_code = 400 + post.return_value.text = ( + '{"status": 400, "message": "A member was already added or invited."}' + ) + + assert mod.invite_user("biosimdb", "tok") == mod.INVITE_PENDING