diff --git a/biosimdb_interface/__init__.py b/biosimdb_interface/__init__.py index 29919b0..d9e35ba 100644 --- a/biosimdb_interface/__init__.py +++ b/biosimdb_interface/__init__.py @@ -12,6 +12,8 @@ from dotenv import load_dotenv from flask import Flask +from .form.workflows import WorkflowStore + load_dotenv() # UPLOAD_FOLDER = "/tmp" @@ -45,6 +47,7 @@ def create_app(test_config=None): # App and Invenio OAuth2 configuration — values loaded from .env app.config.from_mapping( + WORKFLOW_TTL_SECONDS=int(os.getenv("WORKFLOW_TTL_SECONDS", "14400")), UPLOAD_FOLDER=os.getenv("UPLOAD_FOLDER", "/tmp"), # App specific CLIENT_ID=os.getenv("CLIENT_ID", ""), CLIENT_SECRET=os.getenv("CLIENT_SECRET", ""), @@ -55,7 +58,7 @@ def create_app(test_config=None): API_BASE=os.getenv("API_BASE", ""), REDIRECT_URI=os.getenv("REDIRECT_URI", ""), SCOPES=os.getenv("SCOPES", "").strip(), - ) # invenio app configs + ) if test_config is None: # load the instance config, if it exists, when not testing @@ -64,6 +67,10 @@ def create_app(test_config=None): # load the test config if passed in app.config.from_mapping(test_config) + app.extensions["workflow_store"] = WorkflowStore( + ttl_seconds=app.config["WORKFLOW_TTL_SECONDS"] + ) + @app.context_processor def inject_base_url(): return { diff --git a/biosimdb_interface/form/extract.py b/biosimdb_interface/form/extract.py index 1b516f1..2a98f97 100644 --- a/biosimdb_interface/form/extract.py +++ b/biosimdb_interface/form/extract.py @@ -10,12 +10,12 @@ import os from biosim_extractor.metadata.populatemetadata import MetadataPopulator -from flask import jsonify, request, session +from flask import current_app, jsonify, request from werkzeug.utils import secure_filename from . import form_bp -from .upload import cache_extracted_files, cleanup_tmpdir -from .utils import make_upload_tmpdir +from .upload import cache_extracted_files +from .workflows import WorkflowNotFound def extract_files_validate(top_file, traj_file): @@ -67,13 +67,19 @@ def extract_metadata(): - ``{"simulation_metadata": ..., "validation_errors": [...]}`` if schema validation fails. - ``{"error": "..."}`` with status 400 if files are missing, or 500 on unexpected error. """ - # clear the existing tmpdir from previous extraction - tmpdir = session.get("submission_tmpdir") - if tmpdir: - cleanup_tmpdir(tmpdir) - session.pop("submission_tmpdir", None) - tmpdir = make_upload_tmpdir("biosimdb_submission_") - session["submission_tmpdir"] = tmpdir + 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 + + try: + workflow = current_app.extensions["workflow_store"].reset(workflow_id) + except WorkflowNotFound as exc: + return jsonify({"error": str(exc)}), 400 + + tmpdir = workflow.tmpdir try: topology = request.files.get("topology") @@ -96,9 +102,7 @@ def extract_metadata(): result, validation_errors = extract_files_validate(topo_path, traj_files) - if len(validation_errors) > 0: - cleanup_tmpdir(tmpdir) - session.pop("submission_tmpdir", None) + if validation_errors: return jsonify( { "simulation_metadata": result, @@ -114,6 +118,7 @@ def extract_metadata(): ) except Exception as e: + current_app.extensions["workflow_store"].delete(workflow_id) print(f"ERROR: {e}") import traceback @@ -123,9 +128,6 @@ def extract_metadata(): @form_bp.route("/clear_extraction", methods=["POST"]) def clear_extraction(): - """Discard extracted files and reset the pending submission tmpdir.""" - tmpdir = session.get("submission_tmpdir") - cleanup_tmpdir(tmpdir) - for key in ("submission_tmpdir", "topo_path", "traj_files"): - session.pop(key, None) + """Delete only the current tab's extracted workflow files.""" + current_app.extensions["workflow_store"].delete(request.form.get("workflow_id")) return jsonify({"success": True}) diff --git a/biosimdb_interface/form/upload.py b/biosimdb_interface/form/upload.py index 4e73559..7359bfa 100644 --- a/biosimdb_interface/form/upload.py +++ b/biosimdb_interface/form/upload.py @@ -36,18 +36,28 @@ def _load_pending_file_meta(tmpdir): return json.load(f) +def load_extracted_files(tmpdir): + """Return topology and trajectory paths cached during extraction.""" + with open(_pending_uploads_path(tmpdir)) as f: + saved_files = json.load(f) + + topology = saved_files.get("topology", []) + trajectories = saved_files.get("trajectory", []) + + return ( + topology[0] if topology else None, + trajectories, + ) + + def cache_extracted_files(tmpdir, saved_files): - """Persist saved file paths and computed file metadata for later reuse.""" + """Persist extracted file paths and file metadata inside one workflow directory.""" file_meta = files_metadata(saved_files) with open(_pending_uploads_path(tmpdir), "w") as f: json.dump(saved_files, f) _save_pending_file_meta(tmpdir, file_meta) - session["topo_path"] = ( - saved_files["topology"][0] if saved_files["topology"] else None - ) - session["traj_files"] = saved_files["trajectory"] return file_meta @@ -191,8 +201,10 @@ def _save_request_files(tmpdir): Returns: dict[str, list[str]]: Mapping of file role to saved file paths. """ - topo_path = session.get("topo_path") - traj_files = session.get("traj_files") or [] + # topo_path = session.get("topo_path") + # traj_files = session.get("traj_files") or [] + + topo_path, traj_files = load_extracted_files(tmpdir) if _paths_are_reusable(tmpdir, topo_path, traj_files): return { @@ -209,10 +221,6 @@ def _save_request_files(tmpdir): file.save(path) saved_files.setdefault(role, []).append(path) - if saved_files["topology"]: - session["topo_path"] = saved_files["topology"][0] - session["traj_files"] = saved_files["trajectory"] - return saved_files @@ -291,50 +299,19 @@ def cleanup_tmpdir(tmpdir): shutil.rmtree(tmpdir, ignore_errors=True) -def save_pending_submission(json_form=None): - """Persist uploaded files and form payload for post-login submission resume. - - Saves uploaded request files into a temp directory, writes a manifest of - allowed upload paths, optionally writes simulation_metadata.json, and stores - the form payload as pending_form_data.json. +def save_pending_submission(json_form, tmpdir): + """Persist validated submission data in the given workflow directory.""" + file_meta = _load_pending_file_meta(tmpdir) or [] - Args: - json_form (dict | None): Validated BioSim metadata to persist. When - provided, file metadata is attached at json_form["files"] before - writing simulation_metadata.json. - - Side Effects: - Writes JSON artifacts under tmpdir. - Sets session["pending_files_dir"]. - """ - tmpdir = session.get("submission_tmpdir") - # saved_files, file_meta = _save_files_and_extract_metadata(tmpdir) - - topo_path = session.get("topo_path") - traj_files = session.get("traj_files") or [] - saved_files = { - "topology": [topo_path] if topo_path else [], - "trajectory": traj_files, - } - - file_meta = _load_pending_file_meta(tmpdir) - if file_meta is None: - # Fallback only if cache missing - file_meta = files_metadata(saved_files) - _save_pending_file_meta(tmpdir, file_meta) - - # Persist exact user-uploaded paths for later allowlist upload - with open(_pending_uploads_path(tmpdir), "w") as f: - json.dump(saved_files, f) - - if json_form is not None: - json_form["files"] = file_meta - json_path = os.path.join(tmpdir, SIM_METADATA_FILENAME) - with open(json_path, "w") as f: - json.dump(json_form, f, indent=2) + json_form["files"] = file_meta + with open(os.path.join(tmpdir, SIM_METADATA_FILENAME), "w") as f: + json.dump(json_form, f, indent=2) with open(_pending_form_path(tmpdir), "w") as f: - json.dump(request.form.to_dict(flat=False), f) + form_values = request.form.to_dict(flat=False) + form_values.pop("workflow_id", None) + + json.dump(form_values, f) def prepare_for_invenio(form_data, tmpdir): diff --git a/biosimdb_interface/form/webform.py b/biosimdb_interface/form/webform.py index 64b3760..773d0a7 100644 --- a/biosimdb_interface/form/webform.py +++ b/biosimdb_interface/form/webform.py @@ -22,9 +22,9 @@ from . import form_bp from .upload import ( - cleanup_tmpdir, extract_uploaded_file_metadata, is_submission_cancelled, + load_extracted_files, mark_submission_cancelled, prepare_for_invenio, save_pending_submission, @@ -32,6 +32,13 @@ ) from .utils import form_to_json, remove_empty_fields from .validation import validate_with_mdanalysis +from .workflows import WorkflowNotFound + + +def _get_workflow(): + """Resolve the tab-scoped workflow sent with the current request.""" + workflow_id = request.values.get("workflow_id") + return current_app.extensions["workflow_store"].get(workflow_id) @form_bp.route("/webform", methods=["GET", "POST"]) @@ -46,30 +53,20 @@ def webform(): """ clear_client_state = False token = session.get("access_token") - tmpdir = session.get("submission_tmpdir") # an abandoned/failed login leaves a pending submit; discard it on return - if request.method == "GET": - if session.pop("force_clear_client_state", False): - clear_client_state = True - elif tmpdir and session.get("post_login_redirect") and not token: - cleanup_tmpdir(tmpdir) - for key in ( - "submission_tmpdir", - "post_login_redirect", - "topo_path", - "traj_files", - ): - session.pop(key, None) - tmpdir = None - clear_client_state = True - if session.pop("last_error", None): - flash( - "Login failed. Please extract and submit your files again.", - "warning", - ) + if request.method == "GET" and session.pop("force_clear_client_state", False): + clear_client_state = True if request.method == "POST": + try: + workflow = _get_workflow() + except WorkflowNotFound as exc: + return jsonify({"validation_errors": [str(exc)]}), 400 + + tmpdir = workflow.tmpdir + topo_path, traj_files = load_extracted_files(tmpdir) + action = ( "save" if "save" in request.form @@ -79,11 +76,7 @@ def webform(): ) if action == "submit": - if ( - not tmpdir - or not session.get("topo_path") - or not session.get("traj_files") - ): + if not tmpdir or not topo_path or not traj_files: return jsonify( { "validation_errors": [ @@ -97,11 +90,7 @@ def webform(): return jsonify({"validation_errors": [err]}), 400 if action in ["save", "submit"]: - if ( - not tmpdir - or not session.get("topo_path") - or not session.get("traj_files") - ): + if not tmpdir or not topo_path or not traj_files: return jsonify( { "validation_errors": [ @@ -111,15 +100,16 @@ def webform(): ), 400 # check files can be read with mda - topo_path = session.get("topo_path") - traj_files = session.get("traj_files") mda_error = validate_with_mdanalysis(topo_path, traj_files) if mda_error: return jsonify({"validation_errors": [mda_error]}) if action in ["save", "submit"]: # include file info in output, ro-crate? - json_form = form_to_json(request.form) + form_values = request.form.copy() + # remove workflow_id from form + form_values.pop("workflow_id", None) + json_form = form_to_json(form_values) json_form = remove_empty_fields(json_form) # convert to standard units @@ -144,11 +134,18 @@ def webform(): ) if action == "submit": - save_pending_submission(json_form) + save_pending_submission(json_form, tmpdir) if not token: - session["post_login_redirect"] = url_for("form.resume_submit") + session["post_login_redirect"] = url_for( + "form.resume_submit", + workflow_id=request.form["workflow_id"], + ) return redirect(url_for("login.login")) - return render_template("form/loading.html") + + return render_template( + "form/loading.html", + workflow_id=request.form["workflow_id"], + ) if action == "save": return jsonify({"success": True, "data": json_form}) @@ -176,14 +173,25 @@ def resume_submit(): """ if not session.get("access_token"): return redirect(url_for("login.login")) - tmpdir = session.get("submission_tmpdir") + + 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") + + return render_template( + "form/loading.html", + workflow_id=request.args["workflow_id"], + ) @form_bp.route("/do_submit", methods=["POST"]) @@ -194,15 +202,23 @@ def do_submit(): Clears pending session data after upload and renders the success page with the record URL. """ - tmpdir = session.get("submission_tmpdir") + 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): - cleanup_tmpdir(tmpdir) - session.pop("submission_tmpdir", None) + current_app.extensions["workflow_store"].delete( + request.values.get("workflow_id") + ) return redirect(url_for("form.webform")) pending_form_path = os.path.join(tmpdir, "pending_form_data.json") @@ -222,15 +238,17 @@ def do_submit(): invite_user("biosimdb", token) if is_submission_cancelled(tmpdir): - cleanup_tmpdir(tmpdir) - session.pop("submission_tmpdir", None) + current_app.extensions["workflow_store"].delete( + request.values.get("workflow_id") + ) return redirect(url_for("form.webform")) draft_id = prepare_for_invenio(flat_form, tmpdir) if not draft_id: - cleanup_tmpdir(tmpdir) - session.pop("submission_tmpdir", None) + current_app.extensions["workflow_store"].delete( + request.values.get("workflow_id") + ) flash("Upload failed. Please try again.", "danger") return redirect(url_for("form.webform")) @@ -239,7 +257,10 @@ def do_submit(): if status in (401, 403): session.pop("access_token", None) # force fresh login - session["post_login_redirect"] = url_for("form.resume_submit") + 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.", @@ -248,14 +269,14 @@ def do_submit(): return redirect(url_for("login.login")) session.pop("access_token", None) # force fresh login - cleanup_tmpdir(tmpdir) - session.pop("submission_tmpdir", None) + 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")) # success: now clear submission data and logout user. - cleanup_tmpdir(tmpdir) - session.pop("submission_tmpdir", None) + 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) @@ -264,7 +285,6 @@ def do_submit(): record_url = f"{BASE_URL}/uploads/{draft_id}" session["submitted_record_url"] = record_url return redirect(url_for("form.submit_success")) - # return render_template("form/submit_success.html", record_url=record_url) @form_bp.route("/cancel_submit", methods=["POST"]) @@ -275,14 +295,18 @@ def cancel_submit(): still owns those files; do_submit checks the cancellation flag itself and performs its own cleanup once it is safe to do so. """ - tmpdir = session.get("submission_tmpdir") + 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 ( - "submission_tmpdir", "post_login_redirect", - "topo_path", - "traj_files", "access_token", "user_email", ): diff --git a/biosimdb_interface/form/workflows.py b/biosimdb_interface/form/workflows.py new file mode 100644 index 0000000..0a68ca3 --- /dev/null +++ b/biosimdb_interface/form/workflows.py @@ -0,0 +1,106 @@ +"""Server-side, tab-scoped temporary workflow management.""" + +from __future__ import annotations + +import shutil +import threading +import time +import uuid +from dataclasses import dataclass, field + +from .utils import make_upload_tmpdir + + +class WorkflowNotFound(Exception): + """Raised when a workflow ID is missing, invalid, or expired.""" + + +@dataclass +class Workflow: + """State for one browser-tab extraction/submission workflow.""" + + workflow_id: str + tmpdir: str + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + status: str = "extracted" + + +class WorkflowStore: + """Maintain isolated temporary directories keyed by browser workflow ID.""" + + def __init__(self, ttl_seconds: int): + self.ttl_seconds = ttl_seconds + self._workflows: dict[str, Workflow] = {} + self._lock = threading.RLock() + + @staticmethod + def _validate_id(workflow_id: str | None) -> str: + """Validate and normalize a client-generated UUID workflow ID.""" + try: + return str(uuid.UUID(str(workflow_id))) + except (ValueError, TypeError, AttributeError) as exc: + raise WorkflowNotFound("Invalid upload session.") from exc + + def reset(self, workflow_id: str) -> Workflow: + """Delete only this workflow's old directory and create a new one.""" + workflow_id = self._validate_id(workflow_id) + + with self._lock: + old = self._workflows.pop(workflow_id, None) + if old: + shutil.rmtree(old.tmpdir, ignore_errors=True) + + workflow = Workflow( + workflow_id=workflow_id, + tmpdir=make_upload_tmpdir("biosimdb_submission_"), + ) + self._workflows[workflow_id] = workflow + return workflow + + def get(self, workflow_id: str | None) -> Workflow: + """Return an active workflow or raise when it is absent/expired.""" + workflow_id = self._validate_id(workflow_id) + + with self._lock: + workflow = self._workflows.get(workflow_id) + if not workflow: + raise WorkflowNotFound( + "This upload session no longer exists. Please extract again." + ) + + if time.time() - workflow.updated_at > self.ttl_seconds: + self.delete(workflow_id) + raise WorkflowNotFound( + "This upload session expired. Please extract again." + ) + + workflow.updated_at = time.time() + return workflow + + def delete(self, workflow_id: str | None) -> None: + """Delete only the directory belonging to one workflow ID.""" + try: + workflow_id = self._validate_id(workflow_id) + except WorkflowNotFound: + return + + with self._lock: + workflow = self._workflows.pop(workflow_id, None) + if workflow: + shutil.rmtree(workflow.tmpdir, ignore_errors=True) + + def cleanup_expired(self) -> int: + """Delete abandoned workflow directories and return the count removed.""" + now = time.time() + with self._lock: + expired = [ + workflow_id + for workflow_id, workflow in self._workflows.items() + if now - workflow.updated_at > self.ttl_seconds + ] + + for workflow_id in expired: + self.delete(workflow_id) + + return len(expired) diff --git a/biosimdb_interface/static/js/form/metadata.js b/biosimdb_interface/static/js/form/metadata.js index bba7808..3b0d553 100644 --- a/biosimdb_interface/static/js/form/metadata.js +++ b/biosimdb_interface/static/js/form/metadata.js @@ -1,3 +1,25 @@ +/** + * Return a UUID unique to this browser tab. + * sessionStorage is intentionally tab-scoped, unlike localStorage. + */ +function getWorkflowId() { + const key = 'biosimdbWorkflowId'; + let workflowId = sessionStorage.getItem(key); + + if (!workflowId) { + workflowId = crypto.randomUUID(); + sessionStorage.setItem(key, workflowId); + } + + return workflowId; +} + +/** Add the current tab's workflow ID to a request payload. */ +function addWorkflowId(formData) { + formData.set('workflow_id', getWorkflowId()); + return formData; +} + // Handle click events for extract, save, submit, add/remove molecule instances, and clear document.addEventListener('click', function(e) { if (e.target.classList.contains('extract-metadata-btn')) { @@ -5,7 +27,7 @@ document.addEventListener('click', function(e) { showLoadingOverlay(e.target, 'Extracting...'); setFieldsDisabled(true); - const formData = new FormData(); + const formData = addWorkflowId(new FormData()); // Get uploaded files const topology = document.querySelector(`input[name="topology"]`).files[0]; @@ -52,9 +74,19 @@ document.addEventListener('click', function(e) { validateAndSubmit(form, () => { sessionStorage.removeItem('formState'); sessionStorage.removeItem('extractedMetadata'); - const hidden = document.createElement('input'); - hidden.type = 'hidden'; hidden.name = 'submit'; hidden.value = '1'; - form.appendChild(hidden); + + const submitInput = document.createElement('input'); + submitInput.type = 'hidden'; + submitInput.name = 'submit'; + submitInput.value = '1'; + form.appendChild(submitInput); + + const workflowInput = document.createElement('input'); + workflowInput.type = 'hidden'; + workflowInput.name = 'workflow_id'; + workflowInput.value = getWorkflowId(); + form.appendChild(workflowInput); + HTMLFormElement.prototype.submit.call(form); }).then(success => { if (!success) hideLoadingOverlay(e.target); @@ -115,7 +147,7 @@ document.addEventListener('click', function(e) { if (e.target.classList.contains('clear-metadata-btn')) { const form = document.getElementById('simulationForm'); - + clearWorkflow(); // Clear all non-button inputs/selects/textareas lockForm(); } @@ -412,7 +444,7 @@ function requireExtraction(form) { * @param {function} onSuccess - Callback invoked with the server response data on success. */ function validateAndSubmit(form, onSuccess) { - const formData = new FormData(form); + const formData = addWorkflowId(new FormData(form)); formData.append('save', '1'); return fetch(window.APPLICATION_BASE + '/webform', { method: 'POST', body: formData }) .then(r => r.json()) @@ -464,7 +496,6 @@ function lockForm() { sessionStorage.removeItem('formState'); setFieldsDisabled(true); - fetch(window.APPLICATION_BASE + '/clear_extraction', { method: 'POST' }); } /** @@ -494,3 +525,21 @@ function hideLoadingOverlay(button) { delete button.dataset.originalText; document.getElementById('loading-overlay')?.remove(); } + +/** + * Delete the current tab's server-side workflow without affecting new work. + */ +function clearWorkflow() { + const workflowId = sessionStorage.getItem('biosimdbWorkflowId'); + if (!workflowId) return; + + sessionStorage.removeItem('biosimdbWorkflowId'); + + const formData = new FormData(); + formData.set('workflow_id', workflowId); + + fetch(window.APPLICATION_BASE + '/clear_extraction', { + method: 'POST', + body: formData, + }); +} diff --git a/biosimdb_interface/templates/form/loading.html b/biosimdb_interface/templates/form/loading.html index 480e0b5..511a8ac 100644 --- a/biosimdb_interface/templates/form/loading.html +++ b/biosimdb_interface/templates/form/loading.html @@ -22,7 +22,7 @@ const controller = new AbortController(); let cancelled = false; - fetch("{{ url_for('form.do_submit') }}", { + fetch("{{ url_for('form.do_submit', workflow_id=workflow_id) }}", { method: "POST", signal: controller.signal, }) @@ -38,7 +38,7 @@ document.getElementById('cancel-submit-btn').addEventListener('click', () => { cancelled = true; controller.abort(); - fetch("{{ url_for('form.cancel_submit') }}", { method: 'POST' }) + fetch("{{ url_for('form.cancel_submit', workflow_id=workflow_id) }}", { method: 'POST' }) .finally(() => window.location.replace("{{ url_for('form.webform') }}")); }); diff --git a/tests/conftest.py b/tests/conftest.py index 421de93..75ac44b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,44 @@ +import os +import uuid + import pytest from biosimdb_interface import create_app +from biosimdb_interface.form.upload import cache_extracted_files + + +@pytest.fixture +def workflow(app): + """Create and clean up one server-side tab workflow.""" + workflow_id = str(uuid.uuid4()) + + with app.app_context(): + item = app.extensions["workflow_store"].reset(workflow_id) + + yield item + + with app.app_context(): + app.extensions["workflow_store"].delete(workflow_id) + + +@pytest.fixture +def extracted_workflow(app, workflow): + """Create a workflow containing cached topology and trajectory files.""" + topology = os.path.join(workflow.tmpdir, "topol.gro") + trajectory = os.path.join(workflow.tmpdir, "traj.xtc") + + with open(topology, "wb") as f: + f.write(b"topology") + with open(trajectory, "wb") as f: + f.write(b"trajectory") + + with app.app_context(): + cache_extracted_files( + workflow.tmpdir, + {"topology": [topology], "trajectory": [trajectory]}, + ) + + return workflow @pytest.fixture diff --git a/tests/test_form/test_extract.py b/tests/test_form/test_extract.py index 8c9f07b..7ae93dd 100644 --- a/tests/test_form/test_extract.py +++ b/tests/test_form/test_extract.py @@ -1,4 +1,5 @@ import io +import os from unittest.mock import patch @@ -12,6 +13,7 @@ def test_extract_success(client): with patch("biosimdb_interface.form.extract.MetadataPopulator") as MockSP: MockSP.return_value.populate.return_value = {"engine": "GROMACS"} data = { + "workflow_id": "11111111-1111-4111-8111-111111111111", "topology": (io.BytesIO(b"fake"), "topol.gro"), "trajectory[]": (io.BytesIO(b"fake"), "traj.xtc"), } @@ -26,6 +28,7 @@ def test_extract_exception_returns_500(client): with patch("biosimdb_interface.form.extract.MetadataPopulator") as MockSP: MockSP.return_value.populate.side_effect = RuntimeError("bad file") data = { + "workflow_id": "11111111-1111-4111-8111-111111111111", "topology": (io.BytesIO(b"fake"), "topol.gro"), "trajectory[]": (io.BytesIO(b"fake"), "traj.xtc"), } @@ -33,3 +36,38 @@ def test_extract_exception_returns_500(client): "/extract_metadata", data=data, content_type="multipart/form-data" ) assert response.status_code == 500 + + +def test_two_tabs_keep_separate_extraction_directories(client, app): + """Separate workflow IDs must never share or delete extracted files.""" + with patch("biosimdb_interface.form.extract.MetadataPopulator") as populator: + populator.return_value.populate.return_value = {"engine": "GROMACS"} + + def extract(workflow_id, filename): + return client.post( + "/extract_metadata", + data={ + "workflow_id": workflow_id, + "topology": (io.BytesIO(b"topology"), f"{filename}.gro"), + "trajectory[]": (io.BytesIO(b"trajectory"), f"{filename}.xtc"), + }, + content_type="multipart/form-data", + ) + + first_id = "11111111-1111-4111-8111-111111111111" + second_id = "22222222-2222-4222-8222-222222222222" + + assert extract(first_id, "first").status_code == 200 + first_dir = app.extensions["workflow_store"].get(first_id).tmpdir + + assert extract(second_id, "second").status_code == 200 + second_dir = app.extensions["workflow_store"].get(second_id).tmpdir + + assert first_dir != second_dir + assert os.path.isfile(os.path.join(first_dir, "first.gro")) + assert os.path.isfile(os.path.join(second_dir, "second.gro")) + + client.post("/clear_extraction", data={"workflow_id": second_id}) + + assert os.path.isdir(first_dir) + assert not os.path.exists(second_dir) diff --git a/tests/test_form/test_upload.py b/tests/test_form/test_upload.py index d17f9d9..5a5d91b 100644 --- a/tests/test_form/test_upload.py +++ b/tests/test_form/test_upload.py @@ -53,58 +53,53 @@ def test_load_pending_upload_paths_uses_manifest_and_includes_sim_metadata(app): assert sim_meta_path in files -def test_save_pending_submission(client): - """A validated submission with extracted files is persisted.""" - with tempfile.TemporaryDirectory() as tmpdir: - with client.session_transaction() as sess: - sess["submission_tmpdir"] = tmpdir - sess["topo_path"] = f"{tmpdir}/topology.pdb" - sess["traj_files"] = [f"{tmpdir}/trajectory.xtc"] - - with ( - patch( - "biosimdb_interface.form.webform.validate_with_mdanalysis", - return_value=None, - ), - patch("biosimdb_interface.form.webform.validate_metadata"), - patch( - "biosimdb_interface.form.webform.verify_cached_file_meta", - return_value=(True, None), - ), - patch( - "biosimdb_interface.form.webform.save_pending_submission" - ) as mock_save, - ): - response = client.post("/webform", data={"submit": "1"}) - - assert response.status_code == 302 - mock_save.assert_called_once() - - -def test_do_submit_calls_invenio(client): - """Submission triggers Invenio upload with correct args.""" - tmpdir = tempfile.mkdtemp() - with open(os.path.join(tmpdir, "pending_form_data.json"), "w") as f: +def test_save_pending_submission(client, extracted_workflow): + """A validated workflow submission is persisted.""" + with ( + patch( + "biosimdb_interface.form.webform.validate_with_mdanalysis", + return_value=None, + ), + patch("biosimdb_interface.form.webform.validate_metadata"), + patch( + "biosimdb_interface.form.webform.verify_cached_file_meta", + return_value=(True, None), + ), + patch("biosimdb_interface.form.webform.save_pending_submission") as mock_save, + ): + response = client.post( + "/webform", + data={ + "workflow_id": extracted_workflow.workflow_id, + "submit": "1", + }, + ) + + assert response.status_code == 302 + mock_save.assert_called_once() + + +def test_do_submit_calls_invenio(client, workflow): + """Submission uploads files from the specified workflow.""" + with open(os.path.join(workflow.tmpdir, "pending_form_data.json"), "w") as f: json.dump({"simulation_name": ["test"]}, f) with client.session_transaction() as sess: sess["access_token"] = "fake-token" - sess["submission_tmpdir"] = tmpdir with ( patch("biosimdb_interface.form.webform.invite_user") as mock_invite, patch("biosimdb_interface.form.webform.prepare_for_invenio") as mock_prepare, ): mock_prepare.return_value = "draft-123" - response = client.post("/do_submit", follow_redirects=True) - assert response.status_code == 200 - assert b"View Record" in response.data - assert b"Return to Webform" in response.data - assert mock_invite.called - assert mock_prepare.called - with client.session_transaction() as sess: - assert "submission_tmpdir" not in sess - assert "access_token" not in sess - assert "user_email" not in sess - assert "post_login_redirect" not in sess + response = client.post( + f"/do_submit?workflow_id={workflow.workflow_id}", + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"View Record" in response.data + assert b"Return to Webform" in response.data + assert mock_invite.called + assert mock_prepare.called diff --git a/tests/test_form/test_validation.py b/tests/test_form/test_validation.py index cc610b2..878fd9f 100644 --- a/tests/test_form/test_validation.py +++ b/tests/test_form/test_validation.py @@ -1,61 +1,57 @@ from unittest.mock import patch -def _set_extracted_files(client, tmp_path): - """Seed the session with files produced by metadata extraction.""" - topology = tmp_path / "topol.gro" - trajectory = tmp_path / "traj.xtc" - topology.write_bytes(b"fake") - trajectory.write_bytes(b"fake") - - with client.session_transaction() as sess: - sess["submission_tmpdir"] = str(tmp_path) - sess["topo_path"] = str(topology) - sess["traj_files"] = [str(trajectory)] - - -def test_validate_direct_valid_files(client, tmp_path): - _set_extracted_files(client, tmp_path) +def test_validate_direct_valid_files(client, extracted_workflow): with patch("biosimdb_interface.form.validation.Universe"): response = client.post( "/webform", - data={"save": "1"}, + data={"workflow_id": extracted_workflow.workflow_id, "save": "1"}, ) assert response.status_code == 200 -def test_validate_direct_mda_error(client, tmp_path): - _set_extracted_files(client, tmp_path) +def test_validate_direct_mda_error(client, extracted_workflow): with patch( "biosimdb_interface.form.validation.Universe", side_effect=Exception("bad format"), ): response = client.post( "/webform", - data={"save": "1"}, + data={"workflow_id": extracted_workflow.workflow_id, "save": "1"}, ) assert b"bad format" in response.data -def test_valid_topology_trajectory(client, tmp_path): - """Validation passes when MDAnalysis accepts the files.""" - _set_extracted_files(client, tmp_path) +def test_valid_topology_trajectory(client, extracted_workflow): + """Validation accepts files held by the tab workflow.""" with patch( - "biosimdb_interface.form.webform.validate_with_mdanalysis", return_value=None + "biosimdb_interface.form.webform.validate_with_mdanalysis", + return_value=None, ): - response = client.post("/webform", data={"save": "1"}) - assert response.status_code == 200 + response = client.post( + "/webform", + data={ + "workflow_id": extracted_workflow.workflow_id, + "save": "1", + }, + ) + + assert response.status_code == 200 -def test_incompatible_files_returns_error(client, tmp_path): - """Unreadable extracted files return a validation error.""" - _set_extracted_files(client, tmp_path) +def test_incompatible_files_returns_error(client, extracted_workflow): """Invalid files re-render the form with a flash error, not a redirect.""" with patch( "biosimdb_interface.form.webform.validate_with_mdanalysis", return_value="invalid file format", ): - response = client.post("/webform", data={"save": "1"}) + response = client.post( + "/webform", + data={ + "workflow_id": extracted_workflow.workflow_id, + "save": "1", + }, + ) assert response.status_code == 200 assert b"invalid file format" in response.data @@ -69,21 +65,31 @@ def test_validation_no_files_returns_error(): assert "topology" in result.lower() -def test_validation_mda_success(client, tmp_path): - _set_extracted_files(client, tmp_path) +def test_validation_mda_success(client, extracted_workflow): with patch( "biosimdb_interface.form.webform.validate_with_mdanalysis", return_value=None ): - response = client.post("/webform", data={"save": "1"}) + response = client.post( + "/webform", + data={ + "workflow_id": extracted_workflow.workflow_id, + "save": "1", + }, + ) assert response.status_code == 200 -def test_validation_mda_failure_returns_error(client, tmp_path): +def test_validation_mda_failure_returns_error(client, extracted_workflow): """MDAnalysis failures are returned as JSON validation errors.""" - _set_extracted_files(client, tmp_path) with patch( "biosimdb_interface.form.webform.validate_with_mdanalysis", return_value="cannot read file", ): - response = client.post("/webform", data={"save": "1"}) + response = client.post( + "/webform", + data={ + "workflow_id": extracted_workflow.workflow_id, + "save": "1", + }, + ) assert b"cannot read file" in response.data diff --git a/tests/test_form/test_webform.py b/tests/test_form/test_webform.py index 65558bf..e3c87b3 100644 --- a/tests/test_form/test_webform.py +++ b/tests/test_form/test_webform.py @@ -1,6 +1,5 @@ import json import os -import tempfile from unittest.mock import patch @@ -12,9 +11,6 @@ def _set_extracted_files(client, tmp_path, token=None): trajectory.write_bytes(b"fake") with client.session_transaction() as sess: - sess["submission_tmpdir"] = str(tmp_path) - sess["topo_path"] = str(topology) - sess["traj_files"] = [str(trajectory)] if token: sess["access_token"] = token @@ -25,10 +21,8 @@ def test_webform_get(client): assert b"Extract Metadata" in response.data -def test_submit_without_login_redirects(client, tmp_path): - """Submitting extracted files without a token redirects to login.""" - _set_extracted_files(client, tmp_path) - +def test_submit_without_login_redirects(client, extracted_workflow): + """Submitting an extracted workflow without login redirects to login.""" with ( patch( "biosimdb_interface.form.webform.validate_with_mdanalysis", @@ -40,16 +34,20 @@ def test_submit_without_login_redirects(client, tmp_path): ), patch("biosimdb_interface.form.webform.validate_metadata"), ): - response = client.post("/webform", data={"submit": "submit"}) + response = client.post( + "/webform", + data={ + "workflow_id": extracted_workflow.workflow_id, + "submit": "submit", + }, + ) assert response.status_code == 302 assert "/login" in response.headers["Location"] -def test_metadata_download(client, tmp_path): - """Save returns JSON after files have been extracted.""" - _set_extracted_files(client, tmp_path) - +def test_metadata_download(client, extracted_workflow): + """Save returns metadata from the extracted tab workflow.""" with ( patch( "biosimdb_interface.form.webform.validate_with_mdanalysis", @@ -63,14 +61,19 @@ def test_metadata_download(client, tmp_path): ): response = client.post( "/webform", - data={"save": "1", "simulation[1][simulation_name]": "test_sim"}, + data={ + "workflow_id": extracted_workflow.workflow_id, + "save": "1", + "simulation[1][simulation_name]": "test_sim", + }, ) assert response.status_code == 200 -def test_submit_with_token_renders_loading(client, tmp_path): - _set_extracted_files(client, tmp_path, token="tok") +def test_submit_with_token_renders_loading(client, extracted_workflow): + with client.session_transaction() as sess: + sess["access_token"] = "tok" with ( patch( @@ -84,20 +87,26 @@ def test_submit_with_token_renders_loading(client, tmp_path): patch("biosimdb_interface.form.webform.validate_metadata"), patch("biosimdb_interface.form.webform.save_pending_submission"), ): - response = client.post("/webform", data={"submit": "1"}) + response = client.post( + "/webform", + data={ + "workflow_id": extracted_workflow.workflow_id, + "submit": "1", + }, + ) assert response.status_code == 200 assert b"Submitting" in response.data -def test_resume_submit_with_pending_data(client): - """resume_submit renders loading page when session has pending submission.""" - tmpdir = tempfile.mkdtemp() - with open(os.path.join(tmpdir, "pending_form_data.json"), "w") as f: +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" - sess["submission_tmpdir"] = tmpdir - response = client.get("/resume_submit") + + response = client.get(f"/resume_submit?workflow_id={workflow.workflow_id}") + assert response.status_code == 200