diff --git a/backend/sbs_server/app/views.py b/backend/sbs_server/app/views.py index 75c97a8..0b7cc1c 100644 --- a/backend/sbs_server/app/views.py +++ b/backend/sbs_server/app/views.py @@ -13,6 +13,89 @@ import sbol2 as sb2 import pudu import subprocess +import contextlib +import io +import traceback + + + +def classify_excel2sbol_error(exc): + if isinstance(exc, FileNotFoundError): + return { + "code": "E_INPUT_FILE", + "message": "The uploaded Excel file could not be read.", + "hint": "Check that the file was uploaded correctly.", + "status": 400, + } + + if isinstance(exc, KeyError): + return { + "code": "E_WORKBOOK_STRUCTURE", + "message": "The Excel file is missing a required sheet, column, or SBOL mapping.", + "hint": "Check the Init sheet and column_definitions sheet.", + "status": 400, + } + + if isinstance(exc, ValueError): + return { + "code": "E_WORKBOOK_VALUE", + "message": "A value in the Excel file could not be converted to SBOL.", + "hint": "Check the reported sheet, column, row, or pattern requirement.", + "status": 400, + } + + return { + "code": "E_CONVERSION_FAILED", + "message": "Excel2SBOL failed during conversion.", + "hint": "Open More details and send the technical report to the developers.", + "status": 500, + } + + +def redact_sensitive_values(text): + if text is None: + return "" + + sensitive_values = [ + os.getenv("SBOL_USERNAME"), + os.getenv("SBOL_PASSWORD"), + os.getenv("SBOL_URL"), + os.getenv("SYNBIOHUB_USERNAME"), + os.getenv("SYNBIOHUB_PASSWORD"), + os.getenv("SYNBIOHUB_URL"), + ] + + for value in sensitive_values: + if value: + text = text.replace(value, "[REDACTED]") + + return text + + +def excel2sbol_error_response(exc, stdout_buffer, stderr_buffer): + app.logger.exception("Excel2SBOL conversion failed") + classified = classify_excel2sbol_error(exc) + terminal_output = "\n".join([ + "STDOUT:", + stdout_buffer.getvalue(), + "", + "STDERR:", + stderr_buffer.getvalue(), + ]) + + return jsonify({ + "status": "error", + "error": { + "code": classified["code"], + "message": classified["message"], + "hint": classified["hint"], + "details": redact_sensitive_values(str(exc)), + "technical_details": { + "terminal_output": redact_sensitive_values(terminal_output), + "traceback": redact_sensitive_values(traceback.format_exc()), + }, + }, + }), classified["status"] #routes #check if the app is running @@ -129,26 +212,26 @@ def sbh_fj_upload(files): os.remove(data_filename) # instantiate the XDC class using the params_from_request dictionary + stdout_buffer = io.StringIO() + stderr_buffer = io.StringIO() try: - xdc = tricahue.XDC(input_excel_path = metadata_path, attachments=attachments) - # print(params_from_request['sbh_url'], params_from_request['collection_url'], params_from_request['sbh_overwrite'], params_from_request['sbh_user'],params_from_request['sbh_pass'], params_from_request['sbh_pass'],params_from_request['fj_url'], params_from_request['fj_overwrite'], params_from_request['fj_user'], params_from_request['fj_pass'],params_from_request['fj_token']) - sbh_url, fj_url = xdc.upload_to_existing_collection(sbh_url = params_from_request['sbh_url'], - collection_url = params_from_request['collection_url'], - sbh_overwrite = params_from_request['sbh_overwrite'], - sbh_user = params_from_request['sbh_user'], - sbh_pass = params_from_request['sbh_pass'], - sbh_token = params_from_request['sbh_token'], - fj_url = fj_url, - fj_overwrite = fj_overwrite, - fj_user = fj_user, - fj_pass = fj_pass, - fj_token = fj_token) - except AttributeError as e: - os.remove(metadata_path) - return jsonify({"error": str(e)}), 400 + with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer): + xdc = tricahue.XDC(input_excel_path = metadata_path, attachments=attachments) + # print(params_from_request['sbh_url'], params_from_request['collection_url'], params_from_request['sbh_overwrite'], params_from_request['sbh_user'],params_from_request['sbh_pass'], params_from_request['sbh_pass'],params_from_request['fj_url'], params_from_request['fj_overwrite'], params_from_request['fj_user'], params_from_request['fj_pass'],params_from_request['fj_token']) + sbh_url, fj_url = xdc.upload_to_existing_collection(sbh_url = params_from_request['sbh_url'], + collection_url = params_from_request['collection_url'], + sbh_overwrite = params_from_request['sbh_overwrite'], + sbh_user = params_from_request['sbh_user'], + sbh_pass = params_from_request['sbh_pass'], + sbh_token = params_from_request['sbh_token'], + fj_url = fj_url, + fj_overwrite = fj_overwrite, + fj_user = fj_user, + fj_pass = fj_pass, + fj_token = fj_token) except Exception as e: os.remove(metadata_path) - return jsonify({"error": str(e)}), 500 + return excel2sbol_error_response(e, stdout_buffer, stderr_buffer) sbs_upload_response_dict ={ "sbh_url": sbh_url, diff --git a/backend/tests/test_excel2sbol_errors.py b/backend/tests/test_excel2sbol_errors.py new file mode 100644 index 0000000..eb38831 --- /dev/null +++ b/backend/tests/test_excel2sbol_errors.py @@ -0,0 +1,84 @@ +import json +import sys +import types +from io import BytesIO +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] / "sbs_server" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +class _FakePartShop: + pass + + +def _install_optional_dependency_stubs(): + sys.modules.setdefault("tricahue", types.SimpleNamespace()) + sys.modules.setdefault("pudu", types.SimpleNamespace()) + sys.modules.setdefault("sbol2", types.SimpleNamespace(Document=object, PartShop=_FakePartShop)) + + if "flask_swagger_ui" not in sys.modules: + def get_swaggerui_blueprint(*args, **kwargs): + from flask import Blueprint + return Blueprint("swagger_ui_stub", __name__) + + sys.modules["flask_swagger_ui"] = types.SimpleNamespace( + get_swaggerui_blueprint=get_swaggerui_blueprint, + ) + + +_install_optional_dependency_stubs() +from app.main import app # noqa: E402 +from app import views # noqa: E402 + + +def test_excel2sbol_value_error_returns_friendly_error_with_technical_details(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SYNBIOHUB_PASSWORD", "super-secret") + + class FailingXDC: + def __init__(self, *args, **kwargs): + print("started conversion with super-secret") + raise ValueError("bad value contains super-secret") + + monkeypatch.setattr(views.tricahue, "XDC", FailingXDC, raising=False) + + params = { + "sbh_url": "https://synbiohub.example", + "sbh_token": "token", + "sbh_user": None, + "sbh_pass": None, + "fj_url": None, + "fj_token": None, + "fj_user": None, + "fj_pass": None, + "collection_url": "https://synbiohub.example/user/collection/1", + "sbh_overwrite": 0, + "fj_overwrite": 1, + "attachments": {}, + } + + response = app.test_client().post( + "/api/uploadResource", + data={ + "Metadata": (BytesIO(b"excel bytes"), "metadata.xlsx"), + "Params": (BytesIO(json.dumps(params).encode("utf-8")), "params.json"), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + payload = response.get_json() + assert payload["status"] == "error" + assert payload["error"]["code"] == "E_WORKBOOK_VALUE" + assert payload["error"]["message"] == "A value in the Excel file could not be converted to SBOL." + assert payload["error"]["hint"] == "Check the reported sheet, column, row, or pattern requirement." + assert payload["error"]["details"] == "bad value contains [REDACTED]" + assert "started conversion with [REDACTED]" in payload["error"]["technical_details"]["terminal_output"] + assert "Traceback" in payload["error"]["technical_details"]["traceback"] + assert "ValueError" in payload["error"]["technical_details"]["traceback"] + assert "super-secret" not in payload["error"]["details"] + assert "super-secret" not in payload["error"]["technical_details"]["terminal_output"] + assert "super-secret" not in payload["error"]["technical_details"]["traceback"] diff --git a/frontend/src/components/activities/explorer/ImportFile.jsx b/frontend/src/components/activities/explorer/ImportFile.jsx index 9811fbf..e493efb 100644 --- a/frontend/src/components/activities/explorer/ImportFile.jsx +++ b/frontend/src/components/activities/explorer/ImportFile.jsx @@ -9,7 +9,7 @@ import { writeToFileHandle } from "../../../redux/hooks/workingDirectoryHooks"; import { useOpenPanel } from "../../../redux/hooks/panelsHooks"; import { workingDirectorySlice } from "../../../redux/store"; import { useLocalStorage } from "@mantine/hooks"; -import { showErrorNotification } from "../../../modules/util"; +import { showErrorNotification, showUploadErrorNotification } from "../../../modules/util"; import { upload_resource } from "../../../API"; import { useUnifiedModal } from "../../../redux/hooks/useUnifiedModal"; import { loadOverlay, closeOverlay } from "../../../redux/slices/loadingOverlay"; @@ -206,6 +206,9 @@ export default function ImportFile({ onSelect, text, useSubdirectory = false }) onSelect?.(fileMetadata) } catch (err) { console.warn("File selection canceled or failed", err) + if (err?.response) { + showUploadErrorNotification("Upload failed", err, "Unable to upload the collection metadata.") + } } } diff --git a/frontend/src/components/panels/xdc/CollectionWizard.jsx b/frontend/src/components/panels/xdc/CollectionWizard.jsx index 1eff1f2..796dc58 100644 --- a/frontend/src/components/panels/xdc/CollectionWizard.jsx +++ b/frontend/src/components/panels/xdc/CollectionWizard.jsx @@ -6,7 +6,7 @@ import { useLocalStorage } from '@mantine/hooks' import { PanelContext } from './CollectionPanel' import { ObjectTypes } from '../../../objectTypes' import { uploadExperiment } from '../../../API' -import { showErrorNotification } from '../../../modules/util' +import { showErrorNotification, showUploadErrorNotification } from '../../../modules/util' import Dropzone from '../../Dropzone' export default function CollectionWizard() { @@ -73,7 +73,7 @@ export default function CollectionWizard() { setUploads((currentUploads) => [...(currentUploads || []), uploadEntry]) } catch (error) { - showErrorNotification('Upload failed', error?.response?.data?.error || error.message || 'Unable to upload the collection metadata.') + showUploadErrorNotification('Upload failed', error, 'Unable to upload the collection metadata.') } finally { setIsSubmitting(false) } diff --git a/frontend/src/modules/util.js b/frontend/src/modules/util.js index ccc2bb7..4bbf7fc 100644 --- a/frontend/src/modules/util.js +++ b/frontend/src/modules/util.js @@ -1,3 +1,4 @@ +import React, { useState } from "react" import { showNotification } from "@mantine/notifications" export function titleFromRunFileName(fileName) { @@ -16,6 +17,62 @@ export function betterMax(arr) { return arr.reduce((accum, current) => current > accum ? current : accum, arr[0]) } +function ConversionError({ error }) { + const [showDetails, setShowDetails] = useState(false) + + if (!error) { + return null + } + + const technicalDetails = [ + `Error code: ${error.code || ""}`, + "", + "Details:", + error.details || "", + "", + "Terminal output:", + error.technical_details?.terminal_output || "", + "", + "Python traceback:", + error.technical_details?.traceback || "", + ].join("\n") + + return React.createElement( + "div", + null, + React.createElement("strong", null, error.message || "Conversion failed"), + error.hint ? React.createElement("p", null, error.hint) : null, + React.createElement( + "button", + { + type: "button", + onClick: () => setShowDetails(!showDetails), + style: { + background: "transparent", + border: "none", + color: "inherit", + cursor: "pointer", + padding: 0, + textDecoration: "underline", + }, + }, + showDetails ? "Hide details" : "More" + ), + showDetails ? React.createElement( + "pre", + { + style: { + marginTop: 8, + maxHeight: 240, + overflow: "auto", + whiteSpace: "pre-wrap", + }, + }, + technicalDetails + ) : null + ) +} + export function showErrorNotification(title, message) { showNotification({ title, @@ -23,4 +80,24 @@ export function showErrorNotification(title, message) { message, autoClose: false, }); -} \ No newline at end of file +} + +export function showConversionErrorNotification(title, error) { + showNotification({ + title, + color: "red", + message: React.createElement(ConversionError, { error }), + autoClose: false, + }); +} + +export function showUploadErrorNotification(title, error, fallbackMessage) { + const responseError = error?.response?.data?.error + + if (responseError && typeof responseError === "object") { + showConversionErrorNotification(title, responseError) + return + } + + showErrorNotification(title, responseError || error.message || fallbackMessage) +}