diff --git a/.gitignore b/.gitignore index 3a6c3d3..5e1c74f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ gdpr_consent/node_modules/ *~ .streamlit/secrets.toml docs/superpowers/ +.venv/ \ No newline at end of file diff --git a/README.md b/README.md index 9f955c8..a82c6fa 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,20 @@ After it has been built you can run the image with: `docker run -p 8501:8501 flashapp:latest` Navigate to `http://localhost:8501` in your browser. + +## Legal pages (Impressum, Privacy Policy, Terms of Use) + +Every page shows **Impressum**, **Privacy Policy** and **Terms of Use** links in the +sidebar footer, and the GDPR consent banner links to the privacy policy. By default +these point to the official OpenMS pages (`https://openms.de/impressum`, `/privacy`, +`/terms`). To override them — for example when self-hosting or deploying FLASHApp +under a different operator — set `legal_links` in `settings.json`: + + "legal_links": { + "impressum": "https://your-domain.example/impressum", + "privacy": "https://your-domain.example/privacy", + "terms": "https://your-domain.example/terms" + } + +Any link you omit falls back to its OpenMS default. The `privacy` URL is reused for the +consent banner's privacy-policy link, so consent and policy stay in sync. diff --git a/gdpr_consent/dist/bundle.js b/gdpr_consent/dist/bundle.js index 8614457..0a48bfd 100644 --- a/gdpr_consent/dist/bundle.js +++ b/gdpr_consent/dist/bundle.js @@ -235,7 +235,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! streamlit-component-lib */ \"./node_modules/streamlit-component-lib/dist/index.js\");\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nvar __generator = (undefined && undefined.__generator) || function (thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;\n return g = { next: verb(0), \"throw\": verb(1), \"return\": verb(2) }, typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n};\n\n// Defines the configuration for Klaro\nvar klaroConfig = {\n mustConsent: true,\n acceptAll: true,\n services: []\n};\n// This will make klaroConfig globally accessible\nwindow.klaroConfig = klaroConfig;\n// Function to safely access the Klaro manager\nfunction getKlaroManager() {\n var _a;\n return ((_a = window.klaro) === null || _a === void 0 ? void 0 : _a.getManager) ? window.klaro.getManager() : null;\n}\n// Waits until Klaro Manager is available\nfunction waitForKlaroManager() {\n return __awaiter(this, arguments, void 0, function (maxWaitTime, interval) {\n var startTime, klaroManager;\n if (maxWaitTime === void 0) { maxWaitTime = 5000; }\n if (interval === void 0) { interval = 100; }\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0:\n startTime = Date.now();\n _a.label = 1;\n case 1:\n if (!(Date.now() - startTime < maxWaitTime)) return [3 /*break*/, 3];\n klaroManager = getKlaroManager();\n if (klaroManager) {\n return [2 /*return*/, klaroManager];\n }\n return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, interval); })];\n case 2:\n _a.sent();\n return [3 /*break*/, 1];\n case 3: throw new Error(\"Klaro manager did not become available within the allowed time.\");\n }\n });\n });\n}\n// Helper function to handle unknown errors\nfunction handleError(error) {\n if (error instanceof Error) {\n console.error(\"Error:\", error.message);\n }\n else {\n console.error(\"Unknown error:\", error);\n }\n}\n// Tracking was accepted\nfunction callback() {\n return __awaiter(this, void 0, void 0, function () {\n var manager, return_vals, _i, _a, service, error_1;\n return __generator(this, function (_b) {\n switch (_b.label) {\n case 0:\n _b.trys.push([0, 2, , 3]);\n return [4 /*yield*/, waitForKlaroManager()];\n case 1:\n manager = _b.sent();\n if (manager.confirmed) {\n return_vals = {};\n for (_i = 0, _a = klaroConfig.services; _i < _a.length; _i++) {\n service = _a[_i];\n return_vals[service.name] = manager.getConsent(service.name);\n }\n streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentValue(return_vals);\n }\n return [3 /*break*/, 3];\n case 2:\n error_1 = _b.sent();\n handleError(error_1);\n return [3 /*break*/, 3];\n case 3: return [2 /*return*/];\n }\n });\n });\n}\n// Stores if the component has been rendered before\nvar rendered = false;\nfunction onRender(event) {\n // Klaro does not work if embedded multiple times\n if (rendered) {\n return;\n }\n rendered = true;\n var data = event.detail;\n if (data.args['google_analytics']) {\n klaroConfig.services.push({\n name: 'google-analytics',\n cookies: [\n /^_ga(_.*)?/ // we delete the Google Analytics cookies if the user declines its use\n ],\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n if (data.args['piwik_pro']) {\n klaroConfig.services.push({\n name: 'piwik-pro',\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n if (data.args['matomo']) {\n klaroConfig.services.push({\n name: 'matomo',\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n // Create a new script element\n var script = document.createElement('script');\n // Set the necessary attributes\n script.defer = true;\n script.type = 'application/javascript';\n script.src = 'https://cdn.kiprotect.com/klaro/v0.7/klaro.js';\n // Set the klaro config\n script.setAttribute('data-config', 'klaroConfig');\n // Append the script to the head or body\n document.head.appendChild(script);\n}\n// Attach our `onRender` handler to Streamlit's render event.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.events.addEventListener(streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.RENDER_EVENT, onRender);\n// Tell Streamlit we're ready to start receiving data. We won't get our\n// first RENDER_EVENT until we call this function.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentReady();\n// Finally, tell Streamlit to update the initial height.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setFrameHeight(1000);\n\n\n//# sourceURL=webpack://gdpr_consent/./src/main.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! streamlit-component-lib */ \"./node_modules/streamlit-component-lib/dist/index.js\");\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nvar __generator = (undefined && undefined.__generator) || function (thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;\n return g = { next: verb(0), \"throw\": verb(1), \"return\": verb(2) }, typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n};\n\n// Defines the configuration for Klaro\nvar klaroConfig = {\n mustConsent: true,\n acceptAll: true,\n services: []\n};\n// This will make klaroConfig globally accessible\nwindow.klaroConfig = klaroConfig;\n// Function to safely access the Klaro manager\nfunction getKlaroManager() {\n var _a;\n return ((_a = window.klaro) === null || _a === void 0 ? void 0 : _a.getManager) ? window.klaro.getManager() : null;\n}\n// Waits until Klaro Manager is available\nfunction waitForKlaroManager() {\n return __awaiter(this, arguments, void 0, function (maxWaitTime, interval) {\n var startTime, klaroManager;\n if (maxWaitTime === void 0) { maxWaitTime = 5000; }\n if (interval === void 0) { interval = 100; }\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0:\n startTime = Date.now();\n _a.label = 1;\n case 1:\n if (!(Date.now() - startTime < maxWaitTime)) return [3 /*break*/, 3];\n klaroManager = getKlaroManager();\n if (klaroManager) {\n return [2 /*return*/, klaroManager];\n }\n return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, interval); })];\n case 2:\n _a.sent();\n return [3 /*break*/, 1];\n case 3: throw new Error(\"Klaro manager did not become available within the allowed time.\");\n }\n });\n });\n}\n// Helper function to handle unknown errors\nfunction handleError(error) {\n if (error instanceof Error) {\n console.error(\"Error:\", error.message);\n }\n else {\n console.error(\"Unknown error:\", error);\n }\n}\n// Tracking was accepted\nfunction callback() {\n return __awaiter(this, void 0, void 0, function () {\n var manager, return_vals, _i, _a, service, error_1;\n return __generator(this, function (_b) {\n switch (_b.label) {\n case 0:\n _b.trys.push([0, 2, , 3]);\n return [4 /*yield*/, waitForKlaroManager()];\n case 1:\n manager = _b.sent();\n if (manager.confirmed) {\n return_vals = {};\n for (_i = 0, _a = klaroConfig.services; _i < _a.length; _i++) {\n service = _a[_i];\n return_vals[service.name] = manager.getConsent(service.name);\n }\n streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentValue(return_vals);\n }\n return [3 /*break*/, 3];\n case 2:\n error_1 = _b.sent();\n handleError(error_1);\n return [3 /*break*/, 3];\n case 3: return [2 /*return*/];\n }\n });\n });\n}\n// Stores if the component has been rendered before\nvar rendered = false;\nfunction onRender(event) {\n // Klaro does not work if embedded multiple times\n if (rendered) {\n return;\n }\n rendered = true;\n var data = event.detail;\n if (data.args['google_analytics']) {\n klaroConfig.services.push({\n name: 'google-analytics',\n cookies: [\n /^_ga(_.*)?/ // we delete the Google Analytics cookies if the user declines its use\n ],\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n if (data.args['piwik_pro']) {\n klaroConfig.services.push({\n name: 'piwik-pro',\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n if (data.args['matomo']) {\n klaroConfig.services.push({\n name: 'matomo',\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n // Link the consent banner to the privacy policy. Setting privacyPolicyUrl\n // on the 'zz' fallback language makes Klaro render its default\n // \"To learn more, please read our privacy policy.\" text with the URL,\n // regardless of the browser locale.\n if (data.args['privacy_policy']) {\n klaroConfig.translations = {\n zz: {\n privacyPolicyUrl: data.args['privacy_policy']\n }\n };\n }\n // Create a new script element\n var script = document.createElement('script');\n // Set the necessary attributes\n script.defer = true;\n script.type = 'application/javascript';\n script.src = 'https://cdn.kiprotect.com/klaro/v0.7/klaro.js';\n // Set the klaro config\n script.setAttribute('data-config', 'klaroConfig');\n // Append the script to the head or body\n document.head.appendChild(script);\n}\n// Attach our `onRender` handler to Streamlit's render event.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.events.addEventListener(streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.RENDER_EVENT, onRender);\n// Tell Streamlit we're ready to start receiving data. We won't get our\n// first RENDER_EVENT until we call this function.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentReady();\n// Finally, tell Streamlit to update the initial height.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setFrameHeight(1000);\n\n\n//# sourceURL=webpack://gdpr_consent/./src/main.ts?"); /***/ }), diff --git a/gdpr_consent/src/main.ts b/gdpr_consent/src/main.ts index 059fef8..408f4a4 100644 --- a/gdpr_consent/src/main.ts +++ b/gdpr_consent/src/main.ts @@ -14,6 +14,7 @@ let klaroConfig: { mustConsent: boolean; acceptAll: boolean; services: Service[]; + translations?: Record; } = { mustConsent: true, acceptAll: true, @@ -125,6 +126,18 @@ function onRender(event: Event): void { ) } + // Link the consent banner to the privacy policy. Setting privacyPolicyUrl + // on the 'zz' fallback language makes Klaro render its default + // "To learn more, please read our privacy policy." text with the URL, + // regardless of the browser locale. + if (data.args['privacy_policy']) { + klaroConfig.translations = { + zz: { + privacyPolicyUrl: data.args['privacy_policy'] + } + } + } + // Create a new script element var script = document.createElement('script') diff --git a/k8s/base/traefik-ingressroute.yaml b/k8s/base/traefik-ingressroute.yaml index b202891..857314f 100644 --- a/k8s/base/traefik-ingressroute.yaml +++ b/k8s/base/traefik-ingressroute.yaml @@ -4,7 +4,8 @@ metadata: name: streamlit-traefik spec: entryPoints: - - web + - web # 301-redirects to websecure + - websecure routes: - match: PathPrefix(`/`) kind: Rule @@ -16,3 +17,4 @@ spec: name: stroute httpOnly: true sameSite: lax + secure: true # only send the affinity cookie over HTTPS \ No newline at end of file diff --git a/settings.json b/settings.json index 52e8063..c2f9eca 100644 --- a/settings.json +++ b/settings.json @@ -3,6 +3,11 @@ "github-user": "OpenMS", "version": "1.0.0", "repository-name": "FLASHApp", + "legal_links": { + "impressum": "https://openms.de/impressum", + "privacy": "https://openms.de/privacy", + "terms": "https://openms.de/terms" + }, "analytics": { "google-analytics": { "enabled": false, diff --git a/src/common/captcha_.py b/src/common/captcha_.py index 498b133..282e124 100644 --- a/src/common/captcha_.py +++ b/src/common/captcha_.py @@ -186,7 +186,7 @@ def add_page(main_script_path_str: str, page_name: str) -> None: # define the function for the captcha control -def captcha_control(): +def captcha_control(privacy_policy_url: str = ""): """ Control and verification of a CAPTCHA to ensure the user is not a robot. @@ -199,6 +199,10 @@ def captcha_control(): The CAPTCHA text is generated as a session state and should not change during refreshes. + Args: + privacy_policy_url (str, optional): URL shown as the privacy policy link + in the GDPR consent banner. Defaults to "". + Returns: None """ @@ -214,7 +218,10 @@ def captcha_control(): with st.spinner(): # Ask for consent st.session_state.tracking_consent = consent_component( - google_analytics=ga, piwik_pro=pp, matomo=mt + google_analytics=ga, + piwik_pro=pp, + matomo=mt, + privacy_policy=privacy_policy_url, ) if st.session_state.tracking_consent is None: # No response by user yet diff --git a/src/common/common.py b/src/common/common.py index c7fb511..411b4d8 100644 --- a/src/common/common.py +++ b/src/common/common.py @@ -31,6 +31,37 @@ # Detect system platform OS_PLATFORM = sys.platform +# Default legal/GDPR page links. These point to the centrally maintained +# official OpenMS pages. Forks that self-host should override them via the +# "legal_links" key in settings.json (an Impressum must name the actual +# operator). The defaults live here too — not only in settings.json — so that +# downstream apps built from an older settings.json without a "legal_links" +# key still inherit working legal links by default. +DEFAULT_LEGAL_LINKS = { + "impressum": "https://openms.de/impressum", + "privacy": "https://openms.de/privacy", + "terms": "https://openms.de/terms", +} + + +def get_legal_links() -> dict[str, str]: + """ + Return the legal page URLs (Impressum, Privacy Policy, Terms of Use). + + Values from the "legal_links" object in settings.json override the + built-in OpenMS defaults. Empty override values are ignored so a blank + entry can't erase a default. + + Returns: + dict[str, str]: Mapping of "impressum", "privacy" and "terms" to URLs. + """ + overrides = ( + st.session_state.settings.get("legal_links", {}) + if "settings" in st.session_state + else {} + ) + return {**DEFAULT_LEGAL_LINKS, **{k: v for k, v in overrides.items() if v}} + def is_safe_workspace_name(name: str) -> bool: """ @@ -549,7 +580,7 @@ def page_setup(page: str = "") -> dict[str, Any]: # Render the sidebar params = render_sidebar(page) - captcha_control() + captcha_control(privacy_policy_url=get_legal_links()["privacy"]) # If run in hosted mode, show captcha as long as it has not been solved # if not "local" in sys.argv: @@ -562,7 +593,7 @@ def page_setup(page: str = "") -> dict[str, Any]: "controllo" in params.keys() and params["controllo"] == False ): # Apply captcha by calling the captcha_control function - captcha_control() + captcha_control(privacy_policy_url=get_legal_links()["privacy"]) return params @@ -794,6 +825,19 @@ def change_workspace(): f'
{app_name}
Version: {version_info}
', unsafe_allow_html=True, ) + + # Legal links (Impressum, Privacy Policy, Terms of Use), shown on every + # page. URLs are configurable via "legal_links" in settings.json. + links = get_legal_links() + st.markdown( + '
' + f'Impressum · ' + f'Privacy Policy · ' + f'Terms of Use' + "
", + unsafe_allow_html=True, + ) return params diff --git a/src/workflow/CommandExecutor.py b/src/workflow/CommandExecutor.py index b1c0079..9b4f898 100644 --- a/src/workflow/CommandExecutor.py +++ b/src/workflow/CommandExecutor.py @@ -5,7 +5,7 @@ import threading from pathlib import Path from .Logger import Logger -from .ParameterManager import ParameterManager +from .ParameterManager import ParameterManager, bool_param_paths_from_param_xml_ini import sys import importlib.util import json @@ -216,7 +216,7 @@ def read_stderr(): stdout_thread.join() stderr_thread.join() - def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> bool: + def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}, tool_instance_name: str = None) -> bool: """ Constructs and executes commands for the specified tool OpenMS TOPP tool based on the given input and output configurations. Ensures that all input/output file lists @@ -234,6 +234,10 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> b tool (str): The executable name or path of the tool. input_output (dict): A dictionary specifying the input/output parameter names (as key) and their corresponding file paths (as value). custom_params (dict): A dictionary of custom parameters to pass to the tool. + tool_instance_name (str, optional): Key for ``params.json`` when it differs + from ``tool`` (e.g. multiple instances). Defaults to ``tool``. + Custom parameters whose keys appear in the tool's ParamXML ``type="bool"`` + entries are passed as valueless CLI flags (``-name`` only when enabled). Returns: bool: True if all commands succeeded, False if any failed. @@ -261,8 +265,15 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> b commands = [] - # Load parameters for non-defaults params = self.parameter_manager.get_parameters_from_json() + + topp_tool_ini_path = Path(self.parameter_manager.ini_dir, f"{tool}.ini") + # Keys of type="bool" in the .ini: TOPP treats these as on/off flags (omit value when off) + topp_bool_flag_param_keys = ( + bool_param_paths_from_param_xml_ini(topp_tool_ini_path, tool) + if topp_tool_ini_path.exists() + else set() + ) # Construct commands for each process for i in range(n_processes): command = [tool] @@ -284,17 +295,32 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> b # Add non-default TOPP tool parameters if tool in params.keys(): for k, v in params[tool].items(): - # Boolean flag handling: 'true' -> flag only, 'false' -> skip entirely - if isinstance(v, str) and v == 'true': - command += [f"-{k}"] - elif isinstance(v, str) and v == 'false': - pass - elif v == "" or v is None: - # Empty string or None: flag only (no value) - command += [f"-{k}"] - else: - command += [f"-{k}"] - # Note: 0 and 0.0 are valid values, so use explicit check above + # Boolean flag handling. A parameter is emitted as a + # valueless TOPP on/off flag when EITHER the tool's ParamXML + # .ini marks the key type="bool" (upstream key-based + # detection via topp_bool_flag_param_keys) OR the stored + # value is itself boolean. The value-based branch is the + # fallback FLASHApp relies on: checkbox widgets persist a + # Python bool (True/False) to params.json, and pyOpenMS / + # presets may surface the 'true'/'false' string form. This + # keeps flags correct even when the .ini bool set is empty + # (e.g. the .ini was not written). + is_bool_value = isinstance(v, bool) or ( + isinstance(v, str) and v.lower() in ("true", "false") + ) + if (k in topp_bool_flag_param_keys and v != "") or is_bool_value: + # CLI flag: include "-k" only when enabled, never a value. + if isinstance(v, str): + is_enabled = v.lower() == "true" + else: + is_enabled = bool(v) + if is_enabled: + command += [f"-{k}"] + continue + command += [f"-{k}"] + # Skip only empty strings (pass flag with no value) + # Note: 0 and 0.0 are valid values, so use explicit check + if v != "" and v is not None: if isinstance(v, str) and "\n" in v: command += v.split("\n") else: @@ -302,6 +328,7 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> b # Add custom parameters for k, v in custom_params.items(): command += [f"-{k}"] + # Skip only empty strings (pass flag with no value) # Note: 0 and 0.0 are valid values, so use explicit check if v != "" and v is not None: diff --git a/src/workflow/ParameterManager.py b/src/workflow/ParameterManager.py index 3b761af..14ec526 100644 --- a/src/workflow/ParameterManager.py +++ b/src/workflow/ParameterManager.py @@ -3,8 +3,52 @@ import shutil import subprocess import streamlit as st +import xml.etree.ElementTree as ET from pathlib import Path + +def bool_param_paths_from_param_xml_ini(ini_path: Path, tool_stem: str) -> set[str]: + """ + Return short parameter paths for every ```` in a ParamXML .ini file. + + Paths match the suffix after ``Tool:1:`` in pyOpenMS (e.g. ``algorithm:epd:masstrace_snr_filtering``). + """ + try: + root = ET.parse(ini_path).getroot() + except (ET.ParseError, OSError): + return set() + + def local_tag(el: ET.Element) -> str: + t = el.tag + return t.rsplit("}", 1)[-1] if isinstance(t, str) and "}" in t else str(t) + + out: set[str] = set() + + def walk(el: ET.Element, parts: tuple[str, ...]) -> None: + for ch in el: + lt = local_tag(ch) + if lt == "NODE": + nm = ch.get("name") or "" + walk(ch, parts + (nm,)) + elif lt == "ITEM" and (ch.get("type") or "").lower() == "bool": + nm = ch.get("name") or "" + segs = [p for p in parts if p] + if nm: + segs.append(nm) + if not segs: + continue + # Strip tool root NODE name and instance NODE "1" (not part of pyOpenMS short keys) + while segs and segs[0] in (tool_stem, "1"): + segs.pop(0) + if segs: + out.add(":".join(segs)) + + for ch in root: + if local_tag(ch) == "NODE": + walk(ch, ()) + return out + + class ParameterManager: """ Manages the parameters for a workflow, including saving parameters to a JSON file, @@ -29,6 +73,29 @@ def __init__(self, workflow_dir: Path, workflow_name: str = None): # Store workflow name for preset loading; default to directory stem if not provided self.workflow_name = workflow_name or workflow_dir.stem + def bool_pairs_session_key(self) -> str: + """Session state key holding a set of (tool name, param path) for bool TOPP params.""" + return f"{self.ini_dir.parent.stem}-topp-bool-pairs" + + def get_bool_param_pairs(self) -> set: + """Return the cached set of (tool, param path) bool params; empty set if none.""" + return st.session_state.get(self.bool_pairs_session_key(), set()) + + def _merge_bool_params_from_ini(self, tool: str) -> None: + """Load tool.ini (XML) and merge type=bool parameter paths into session_state.""" + ini_path = Path(self.ini_dir, f"{tool}.ini") + if not ini_path.exists(): + return + try: + sk = self.bool_pairs_session_key() + if sk not in st.session_state: + st.session_state[sk] = set() + for short in bool_param_paths_from_param_xml_ini(ini_path, tool): + st.session_state[sk].add((tool, short)) + except RuntimeError: + # No Streamlit session (e.g. plain `python` import) + pass + def create_ini(self, tool: str) -> bool: """ Create an ini file for a TOPP tool if it doesn't exist. @@ -41,11 +108,14 @@ def create_ini(self, tool: str) -> bool: """ ini_path = Path(self.ini_dir, tool + ".ini") if ini_path.exists(): + self._merge_bool_params_from_ini(tool) return True try: subprocess.call([tool, "-write_ini", str(ini_path)]) except FileNotFoundError: return False + if ini_path.exists(): + self._merge_bool_params_from_ini(tool) return ini_path.exists() def save_parameters(self) -> None: diff --git a/tests/test_legal_links.py b/tests/test_legal_links.py new file mode 100644 index 0000000..a38e201 --- /dev/null +++ b/tests/test_legal_links.py @@ -0,0 +1,160 @@ +""" +Tests for get_legal_links() in src/common/common.py. + +get_legal_links() resolves the Impressum / Privacy Policy / Terms of Use URLs +shown in the sidebar footer (on every page) and the privacy-policy link wired +into the GDPR consent banner. It merges the optional "legal_links" object from +settings.json over the built-in official-OpenMS defaults so that: + + * apps built from a settings.json without a "legal_links" key still inherit + working legal links by default, + * a self-hosting fork can override any or all of the three URLs, + * an empty/blank override value never erases a default. + +Streamlit (and the other heavy runtime deps pulled in by common.py) are mocked +before import so the helper can be unit-tested without a running Streamlit app, +mirroring tests/test_parameter_presets.py. +""" +import os +import sys +from unittest.mock import MagicMock + +# Add project root to path for imports +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(PROJECT_ROOT) + + +class FakeSessionState(dict): + """Minimal stand-in for Streamlit's SessionState. + + Supports both attribute access (``state.settings``) and item/membership + access (``"settings" in state``), exactly like the real SessionState that + common.py relies on. + """ + + def __getattr__(self, name): + try: + return self[name] + except KeyError as exc: + raise AttributeError(name) from exc + + def __setattr__(self, name, value): + self[name] = value + + +# Mock streamlit (with a SessionState-like session_state) and the other heavy +# imports pulled in by src/common/common.py, so importing get_legal_links here +# doesn't require a running Streamlit app context. +# +# IMPORTANT: these mocks are installed into sys.modules only for the duration of +# the import below and then restored, so they don't leak into other test modules +# (e.g. the AppTest-based tests that need the real `streamlit` package). This +# mirrors the pattern in tests/test_parameter_presets.py. +mock_streamlit = MagicMock() +mock_streamlit.session_state = FakeSessionState() + +_MOCKED_MODULES = { + "streamlit": mock_streamlit, + "streamlit.components": MagicMock(), + "streamlit.components.v1": MagicMock(), + "streamlit.source_util": MagicMock(), + "pandas": MagicMock(), + "psutil": MagicMock(), + # Local submodules with their own heavy deps (e.g. the captcha image library). + "src.common.captcha_": MagicMock(), + "src.common.admin": MagicMock(), +} +_saved_modules = {name: sys.modules.get(name) for name in _MOCKED_MODULES} +sys.modules.update(_MOCKED_MODULES) + +# Force a FRESH import of src.common.common under the streamlit mock, even if an +# earlier test module (e.g. test_gui.py) already imported the real-streamlit-bound +# version. Save whatever was cached first so we can restore it afterwards. +_saved_common = sys.modules.pop("src.common.common", None) + +from src.common.common import get_legal_links, DEFAULT_LEGAL_LINKS # noqa: E402 + +# Restore the real modules (or remove ones that weren't present) so that other +# test modules get the genuine packages. +for _name, _orig in _saved_modules.items(): + if _orig is None: + sys.modules.pop(_name, None) + else: + sys.modules[_name] = _orig +# Restore the original cached common module (the real-streamlit-bound one, if +# any) so AppTest-based test modules keep getting the genuine package. +# get_legal_links keeps working: it holds a reference to the freshly-imported +# mock-bound module's globals (and the same `mock_streamlit` object the tests +# mutate). +if _saved_common is None: + sys.modules.pop("src.common.common", None) +else: + sys.modules["src.common.common"] = _saved_common + + +def setup_function(_): + """Reset session_state before each test for isolation.""" + mock_streamlit.session_state = FakeSessionState() + + +def test_defaults_point_to_openms(): + """The built-in defaults are the official OpenMS pages.""" + assert DEFAULT_LEGAL_LINKS == { + "impressum": "https://openms.de/impressum", + "privacy": "https://openms.de/privacy", + "terms": "https://openms.de/terms", + } + + +def test_defaults_when_settings_not_loaded(): + """No settings loaded at all -> defaults, no crash.""" + mock_streamlit.session_state = FakeSessionState() + assert get_legal_links() == DEFAULT_LEGAL_LINKS + + +def test_defaults_when_no_legal_links_key(): + """settings present but without 'legal_links' -> all OpenMS defaults.""" + mock_streamlit.session_state = FakeSessionState({"settings": {}}) + assert get_legal_links() == DEFAULT_LEGAL_LINKS + + +def test_overrides_replace_defaults(): + """A fork's custom legal_links replace every default.""" + mock_streamlit.session_state = FakeSessionState( + { + "settings": { + "legal_links": { + "impressum": "https://acme.example/impressum", + "privacy": "https://acme.example/privacy", + "terms": "https://acme.example/terms", + } + } + } + ) + assert get_legal_links() == { + "impressum": "https://acme.example/impressum", + "privacy": "https://acme.example/privacy", + "terms": "https://acme.example/terms", + } + + +def test_partial_override_keeps_other_defaults(): + """Overriding only one link leaves the others at their OpenMS default.""" + mock_streamlit.session_state = FakeSessionState( + {"settings": {"legal_links": {"impressum": "https://acme.example/impressum"}}} + ) + links = get_legal_links() + assert links["impressum"] == "https://acme.example/impressum" + assert links["privacy"] == DEFAULT_LEGAL_LINKS["privacy"] + assert links["terms"] == DEFAULT_LEGAL_LINKS["terms"] + + +def test_empty_or_none_override_falls_back_to_default(): + """A blank/None override must not erase the default for that key.""" + mock_streamlit.session_state = FakeSessionState( + {"settings": {"legal_links": {"privacy": "", "impressum": None}}} + ) + links = get_legal_links() + assert links["privacy"] == DEFAULT_LEGAL_LINKS["privacy"] + assert links["impressum"] == DEFAULT_LEGAL_LINKS["impressum"] + assert links["terms"] == DEFAULT_LEGAL_LINKS["terms"]