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