From bb9844d5671d5313e2bcc40baa9cf905551624fe Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" <34970661+fonsecareyna82@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:52:32 +0200 Subject: [PATCH 1/8] Add workflow clipboard API endpoints --- .../api/routers/workflow_clipboard_router.py | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 app/backend/api/routers/workflow_clipboard_router.py diff --git a/app/backend/api/routers/workflow_clipboard_router.py b/app/backend/api/routers/workflow_clipboard_router.py new file mode 100644 index 0000000..21ed2c1 --- /dev/null +++ b/app/backend/api/routers/workflow_clipboard_router.py @@ -0,0 +1,294 @@ +import json +import logging +import os +from typing import Any, Dict, List, Optional, Set, Union + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from app.backend.api.dependencies import getCurrentUser +from app.backend.api.services.project_service import ProjectService +from app.backend.database import getMapper +from app.backend.mapper.postgresql import PostgresqlFlatMapper + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +class WorkflowExportRequest(BaseModel): + protocolIds: List[Union[int, str]] = Field(default_factory=list) + includeUpstream: bool = False + + +class WorkflowImportRequest(BaseModel): + workflow: Any + mode: str = "append" + sourceProjectId: Optional[Union[int, str]] = None + sourceProjectName: Optional[str] = None + + +def getProjectService() -> ProjectService: + return ProjectService() + + +def _sortProtocolIds(protocolIds: Set[str]) -> List[str]: + def sortKey(value: str): + try: + return 0, int(value) + except Exception: + return 1, str(value) + + return sorted(protocolIds, key=sortKey) + + +def _getCurrentWorkflowProtocolIds(service: ProjectService) -> Set[str]: + try: + runs = service.currentProject.getRunsGraph(refresh=True, checkPids=False) + nodesDict = getattr(runs, "_nodesDict", {}) or {} + except Exception: + return set() + + return { + str(nodeId) + for nodeId in nodesDict.keys() + if str(nodeId) != "PROJECT" + } + + +def _normalizeWorkflowImportErrors(result: Any) -> List[str]: + if result is None: + return [] + + if isinstance(result, dict): + rawErrors = result.get("errors") or result.get("error") or result.get("detail") + if rawErrors is None: + return [] + if isinstance(rawErrors, list): + return [str(item) for item in rawErrors if str(item).strip()] + text = str(rawErrors).strip() + return [text] if text else [] + + if isinstance(result, (list, tuple, set)): + return [str(item) for item in result if str(item).strip()] + + text = str(result).strip() + return [text] if text else [] + + +def _unwrapWorkflowImportPayload(service: ProjectService, workflowPayload: Any) -> Any: + if workflowPayload is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Missing workflow", + ) + + if isinstance(workflowPayload, dict): + metadata = workflowPayload.get("scipionWeb") + if isinstance(metadata, dict): + requiredPluginNames = [ + str(name).strip() + for name in metadata.get("requiredPluginNames", []) or [] + if str(name).strip() + ] + service._validateWorkflowRequiredPlugins(requiredPluginNames) + + if "workflow" in workflowPayload: + return workflowPayload.get("workflow") + + if "content" in workflowPayload: + return workflowPayload.get("content") + + return workflowPayload + + +def _getProjectDisplayName(service: ProjectService) -> str: + try: + projectPath = service.currentProject.getPath() + if projectPath: + return os.path.basename(str(projectPath)) or str(projectPath) + except Exception: + pass + + return "" + + +@router.post( + "/{projectId}/protocols/export-workflow", + response_model=Any, + status_code=status.HTTP_200_OK, +) +def exportWorkflowProtocols( + projectId: int, + payload: WorkflowExportRequest, + currentUser=Depends(getCurrentUser), + mapper: PostgresqlFlatMapper = Depends(getMapper), + service: ProjectService = Depends(getProjectService), +): + project = service.getProjectById( + mapper, + projectId, + currentUser, + refresh=False, + checkPid=False, + ) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found", + ) + + if bool(payload.includeUpstream): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="includeUpstream is not supported yet", + ) + + protocolIds = service._normalizeProtocolIdsForExport(payload.protocolIds) + if not protocolIds: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Missing protocolIds", + ) + + try: + protocolIdInts = [int(protocolId) for protocolId in protocolIds] + except Exception: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="protocolIds must be numeric", + ) + + protocolList = [] + missing: List[str] = [] + + for protocolId in protocolIdInts: + protocol = service.currentProject.getProtocol(protocolId) + if protocol is None: + missing.append(str(protocolId)) + continue + protocolList.append(protocol) + + if missing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Protocol(s) not found: {', '.join(missing)}", + ) + + try: + rawExport = service.currentProject.getProtocolsJson(protocolList) + workflow = service._decodeExportJsonPayload(rawExport) + metadata = service._buildWorkflowPluginMetadata(protocolList) + except HTTPException: + raise + except Exception as e: + logger.exception("Failed to export workflow protocols. projectId=%s", projectId) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to export workflow protocols: {e}", + ) + + return { + "sourceProjectId": projectId, + "sourceProjectName": _getProjectDisplayName(service), + "protocolIds": protocolIds, + "workflow": workflow, + "scipionWeb": metadata, + "summary": { + "protocolCount": len(protocolList), + "requiredPluginNames": metadata.get("requiredPluginNames", []), + }, + } + + +@router.post( + "/{projectId}/protocols/import-workflow", + response_model=Any, + status_code=status.HTTP_200_OK, +) +def importWorkflowProtocols( + projectId: int, + payload: WorkflowImportRequest, + currentUser=Depends(getCurrentUser), + mapper: PostgresqlFlatMapper = Depends(getMapper), + service: ProjectService = Depends(getProjectService), +): + project = service.getProjectById( + mapper, + projectId, + currentUser, + refresh=True, + checkPid=False, + ) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found", + ) + + mode = str(payload.mode or "append").strip().lower() + if mode != "append": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported import mode: {mode}", + ) + + workflowContent = _unwrapWorkflowImportPayload(service, payload.workflow) + + if isinstance(workflowContent, str): + workflowText = service._extractWorkflowJsonText(workflowContent) + try: + workflowContent = json.loads(workflowText) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid workflow JSON: {e}", + ) + + if not isinstance(workflowContent, (list, dict)): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Workflow must be a JSON list or object", + ) + + beforeIds = _getCurrentWorkflowProtocolIds(service) + workflowJson = json.dumps(workflowContent, ensure_ascii=False) + + try: + loadResult = service.currentProject.loadProtocols(jsonStr=workflowJson) + except Exception as e: + logger.exception("Failed to import workflow protocols. projectId=%s", projectId) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to import workflow protocols: {e}", + ) + + errors = _normalizeWorkflowImportErrors(loadResult) + + try: + syncInfo = service.syncProjectProtocolsAndDependencies( + mapper, + projectId, + refresh=True, + checkPid=True, + ) + except HTTPException: + raise + except Exception as e: + logger.exception("Failed to sync workflow after import. projectId=%s", projectId) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Workflow was imported but graph sync failed: {e}", + ) + + afterIds = _getCurrentWorkflowProtocolIds(service) + createdIds = _sortProtocolIds(afterIds - beforeIds) + + return { + "status": 1 if errors else 0, + "errors": errors, + "workflow": [], + "created": [{"newId": protocolId} for protocolId in createdIds], + "protocolsCount": int(syncInfo.get("protocols", 0)), + "dependenciesCount": int(syncInfo.get("dependencies", 0)), + } From dd4176caa4b24ef0cc948f1b73dde08fd7a892d9 Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" Date: Thu, 11 Jun 2026 14:06:58 +0200 Subject: [PATCH 2/8] Handle export-import workflows --- app/backend/api/routers/project_router.py | 90 +++++- .../api/routers/workflow_clipboard_router.py | 294 ------------------ app/backend/api/schemas/protocols_schema.py | 12 + app/backend/api/services/project_service.py | 194 ++++++++++++ 4 files changed, 295 insertions(+), 295 deletions(-) delete mode 100644 app/backend/api/routers/workflow_clipboard_router.py diff --git a/app/backend/api/routers/project_router.py b/app/backend/api/routers/project_router.py index eefb342..ceb20da 100644 --- a/app/backend/api/routers/project_router.py +++ b/app/backend/api/routers/project_router.py @@ -19,7 +19,12 @@ from pydantic import BaseModel, Field from app.backend.api.dependencies import getCurrentUser -from app.backend.api.schemas.protocols_schema import ExportProtocolsRequest, RemoteFileWriteRequest +from app.backend.api.schemas.protocols_schema import ( + ExportProtocolsRequest, + RemoteFileWriteRequest, + WorkflowExportRequest, + WorkflowImportRequest, +) from app.backend.api.schemas.tags_schema import ProtocolTagCreateIn, ProtocolTagUpdateIn, ProtocolTagsSetIn from app.backend.database import getMapper from app.backend.api.schemas.project_schema import (ProjectCreate, ProjectOut, ProjectUpdate, ProjectShareCreate, @@ -1249,6 +1254,89 @@ def exportProtocols( ) +@router.post( + "/{projectId}/protocols/export-workflow", + response_model=Any, + status_code=status.HTTP_200_OK, +) +def exportWorkflowProtocols( + projectId: int, + payload: WorkflowExportRequest, + currentUser=Depends(getCurrentUser), + mapper: PostgresqlFlatMapper = Depends(getMapper), + service: ProjectService = Depends(getProjectService), +): + project = service.getProjectById( + mapper, + projectId, + currentUser, + refresh=False, + checkPid=False, + ) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found", + ) + + try: + return service.exportWorkflowProtocolsService( + mapper=mapper, + projectId=projectId, + currentUser=currentUser, + payload=payload, + ) + except HTTPException: + raise + except Exception as e: + logger.exception("Error in exportWorkflowProtocols: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to export workflow protocols: {e}", + ) + + +@router.post( + "/{projectId}/protocols/import-workflow", + response_model=Any, + status_code=status.HTTP_200_OK, +) +def importWorkflowProtocols( + projectId: int, + payload: WorkflowImportRequest, + currentUser=Depends(getCurrentUser), + mapper: PostgresqlFlatMapper = Depends(getMapper), + service: ProjectService = Depends(getProjectService), +): + project = service.getProjectById( + mapper, + projectId, + currentUser, + refresh=True, + checkPid=False, + ) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found", + ) + + try: + return service.importWorkflowProtocolsService( + mapper=mapper, + projectId=projectId, + currentUser=currentUser, + payload=payload, + ) + except HTTPException: + raise + except Exception as e: + logger.exception("Error in importWorkflowProtocols: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to import workflow protocols: {e}", + ) + @router.post( "/{projectId}/protocols/{protocolId}/fs/write", response_model=Any, diff --git a/app/backend/api/routers/workflow_clipboard_router.py b/app/backend/api/routers/workflow_clipboard_router.py deleted file mode 100644 index 21ed2c1..0000000 --- a/app/backend/api/routers/workflow_clipboard_router.py +++ /dev/null @@ -1,294 +0,0 @@ -import json -import logging -import os -from typing import Any, Dict, List, Optional, Set, Union - -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field - -from app.backend.api.dependencies import getCurrentUser -from app.backend.api.services.project_service import ProjectService -from app.backend.database import getMapper -from app.backend.mapper.postgresql import PostgresqlFlatMapper - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/projects", tags=["projects"]) - - -class WorkflowExportRequest(BaseModel): - protocolIds: List[Union[int, str]] = Field(default_factory=list) - includeUpstream: bool = False - - -class WorkflowImportRequest(BaseModel): - workflow: Any - mode: str = "append" - sourceProjectId: Optional[Union[int, str]] = None - sourceProjectName: Optional[str] = None - - -def getProjectService() -> ProjectService: - return ProjectService() - - -def _sortProtocolIds(protocolIds: Set[str]) -> List[str]: - def sortKey(value: str): - try: - return 0, int(value) - except Exception: - return 1, str(value) - - return sorted(protocolIds, key=sortKey) - - -def _getCurrentWorkflowProtocolIds(service: ProjectService) -> Set[str]: - try: - runs = service.currentProject.getRunsGraph(refresh=True, checkPids=False) - nodesDict = getattr(runs, "_nodesDict", {}) or {} - except Exception: - return set() - - return { - str(nodeId) - for nodeId in nodesDict.keys() - if str(nodeId) != "PROJECT" - } - - -def _normalizeWorkflowImportErrors(result: Any) -> List[str]: - if result is None: - return [] - - if isinstance(result, dict): - rawErrors = result.get("errors") or result.get("error") or result.get("detail") - if rawErrors is None: - return [] - if isinstance(rawErrors, list): - return [str(item) for item in rawErrors if str(item).strip()] - text = str(rawErrors).strip() - return [text] if text else [] - - if isinstance(result, (list, tuple, set)): - return [str(item) for item in result if str(item).strip()] - - text = str(result).strip() - return [text] if text else [] - - -def _unwrapWorkflowImportPayload(service: ProjectService, workflowPayload: Any) -> Any: - if workflowPayload is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Missing workflow", - ) - - if isinstance(workflowPayload, dict): - metadata = workflowPayload.get("scipionWeb") - if isinstance(metadata, dict): - requiredPluginNames = [ - str(name).strip() - for name in metadata.get("requiredPluginNames", []) or [] - if str(name).strip() - ] - service._validateWorkflowRequiredPlugins(requiredPluginNames) - - if "workflow" in workflowPayload: - return workflowPayload.get("workflow") - - if "content" in workflowPayload: - return workflowPayload.get("content") - - return workflowPayload - - -def _getProjectDisplayName(service: ProjectService) -> str: - try: - projectPath = service.currentProject.getPath() - if projectPath: - return os.path.basename(str(projectPath)) or str(projectPath) - except Exception: - pass - - return "" - - -@router.post( - "/{projectId}/protocols/export-workflow", - response_model=Any, - status_code=status.HTTP_200_OK, -) -def exportWorkflowProtocols( - projectId: int, - payload: WorkflowExportRequest, - currentUser=Depends(getCurrentUser), - mapper: PostgresqlFlatMapper = Depends(getMapper), - service: ProjectService = Depends(getProjectService), -): - project = service.getProjectById( - mapper, - projectId, - currentUser, - refresh=False, - checkPid=False, - ) - if not project: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Project not found", - ) - - if bool(payload.includeUpstream): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="includeUpstream is not supported yet", - ) - - protocolIds = service._normalizeProtocolIdsForExport(payload.protocolIds) - if not protocolIds: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Missing protocolIds", - ) - - try: - protocolIdInts = [int(protocolId) for protocolId in protocolIds] - except Exception: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="protocolIds must be numeric", - ) - - protocolList = [] - missing: List[str] = [] - - for protocolId in protocolIdInts: - protocol = service.currentProject.getProtocol(protocolId) - if protocol is None: - missing.append(str(protocolId)) - continue - protocolList.append(protocol) - - if missing: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Protocol(s) not found: {', '.join(missing)}", - ) - - try: - rawExport = service.currentProject.getProtocolsJson(protocolList) - workflow = service._decodeExportJsonPayload(rawExport) - metadata = service._buildWorkflowPluginMetadata(protocolList) - except HTTPException: - raise - except Exception as e: - logger.exception("Failed to export workflow protocols. projectId=%s", projectId) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to export workflow protocols: {e}", - ) - - return { - "sourceProjectId": projectId, - "sourceProjectName": _getProjectDisplayName(service), - "protocolIds": protocolIds, - "workflow": workflow, - "scipionWeb": metadata, - "summary": { - "protocolCount": len(protocolList), - "requiredPluginNames": metadata.get("requiredPluginNames", []), - }, - } - - -@router.post( - "/{projectId}/protocols/import-workflow", - response_model=Any, - status_code=status.HTTP_200_OK, -) -def importWorkflowProtocols( - projectId: int, - payload: WorkflowImportRequest, - currentUser=Depends(getCurrentUser), - mapper: PostgresqlFlatMapper = Depends(getMapper), - service: ProjectService = Depends(getProjectService), -): - project = service.getProjectById( - mapper, - projectId, - currentUser, - refresh=True, - checkPid=False, - ) - if not project: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Project not found", - ) - - mode = str(payload.mode or "append").strip().lower() - if mode != "append": - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Unsupported import mode: {mode}", - ) - - workflowContent = _unwrapWorkflowImportPayload(service, payload.workflow) - - if isinstance(workflowContent, str): - workflowText = service._extractWorkflowJsonText(workflowContent) - try: - workflowContent = json.loads(workflowText) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Invalid workflow JSON: {e}", - ) - - if not isinstance(workflowContent, (list, dict)): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Workflow must be a JSON list or object", - ) - - beforeIds = _getCurrentWorkflowProtocolIds(service) - workflowJson = json.dumps(workflowContent, ensure_ascii=False) - - try: - loadResult = service.currentProject.loadProtocols(jsonStr=workflowJson) - except Exception as e: - logger.exception("Failed to import workflow protocols. projectId=%s", projectId) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to import workflow protocols: {e}", - ) - - errors = _normalizeWorkflowImportErrors(loadResult) - - try: - syncInfo = service.syncProjectProtocolsAndDependencies( - mapper, - projectId, - refresh=True, - checkPid=True, - ) - except HTTPException: - raise - except Exception as e: - logger.exception("Failed to sync workflow after import. projectId=%s", projectId) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Workflow was imported but graph sync failed: {e}", - ) - - afterIds = _getCurrentWorkflowProtocolIds(service) - createdIds = _sortProtocolIds(afterIds - beforeIds) - - return { - "status": 1 if errors else 0, - "errors": errors, - "workflow": [], - "created": [{"newId": protocolId} for protocolId in createdIds], - "protocolsCount": int(syncInfo.get("protocols", 0)), - "dependenciesCount": int(syncInfo.get("dependencies", 0)), - } diff --git a/app/backend/api/schemas/protocols_schema.py b/app/backend/api/schemas/protocols_schema.py index 1e05249..fe1c5b6 100644 --- a/app/backend/api/schemas/protocols_schema.py +++ b/app/backend/api/schemas/protocols_schema.py @@ -87,3 +87,15 @@ class RemoteFileWriteRequest(BaseModel): content: str = "" mimeType: Optional[str] = "application/json" + +class WorkflowExportRequest(BaseModel): + protocolIds: List[Union[int, str]] = Field(default_factory=list) + includeUpstream: bool = False + + +class WorkflowImportRequest(BaseModel): + workflow: Any + mode: str = "append" + sourceProjectId: Optional[Union[int, str]] = None + sourceProjectName: Optional[str] = None + diff --git a/app/backend/api/services/project_service.py b/app/backend/api/services/project_service.py index 5bf224c..2f2741f 100644 --- a/app/backend/api/services/project_service.py +++ b/app/backend/api/services/project_service.py @@ -4707,6 +4707,200 @@ def exportProtocolsService( detail=f"Scipion export failed: {e}", ) + def _getCurrentWorkflowProtocolIds(self) -> Set[str]: + try: + runs = self.currentProject.getRunsGraph(refresh=True, checkPids=False) + nodesDict = getattr(runs, "_nodesDict", {}) or {} + except Exception: + return set() + + return { + str(nodeId) + for nodeId in nodesDict.keys() + if str(nodeId) != "PROJECT" + } + + @staticmethod + def _sortProtocolIds(protocolIds: Set[str]) -> List[str]: + def sortKey(value: str): + try: + return (0, int(value)) + except Exception: + return (1, str(value)) + + return sorted(protocolIds, key=sortKey) + + def _normalizeWorkflowImportErrors(self, result: Any) -> List[str]: + if result is None: + return [] + + if isinstance(result, dict): + rawErrors = result.get("errors") or result.get("error") or result.get("detail") + if rawErrors is None: + return [] + if isinstance(rawErrors, list): + return [str(item) for item in rawErrors if str(item).strip()] + return [str(rawErrors)] if str(rawErrors).strip() else [] + + if isinstance(result, (list, tuple, set)): + return [str(item) for item in result if str(item).strip()] + + text = str(result).strip() + return [text] if text else [] + + def _unwrapWorkflowImportPayload(self, workflowPayload: Any) -> Any: + if workflowPayload is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Missing workflow", + ) + + if isinstance(workflowPayload, dict): + metadata = workflowPayload.get("scipionWeb") + if isinstance(metadata, dict): + requiredPluginNames = [ + str(name).strip() + for name in metadata.get("requiredPluginNames", []) or [] + if str(name).strip() + ] + self._validateWorkflowRequiredPlugins(requiredPluginNames) + + if "workflow" in workflowPayload: + return workflowPayload.get("workflow") + + if "content" in workflowPayload: + return workflowPayload.get("content") + + return workflowPayload + + def exportWorkflowProtocolsService( + self, + mapper: PostgresqlFlatMapper, + projectId: int, + currentUser: dict, + payload: Any, + ) -> Dict[str, Any]: + if bool(getattr(payload, "includeUpstream", False)): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="includeUpstream is not supported yet", + ) + + protocolIds = self._normalizeProtocolIdsForExport( + getattr(payload, "protocolIds", None), + ) + if not protocolIds: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Missing protocolIds", + ) + + try: + protocolIdInts = [int(pid) for pid in protocolIds] + except Exception: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="protocolIds must be numeric", + ) + + protocolList = [] + missing: List[str] = [] + + for protocolId in protocolIdInts: + protocol = self.currentProject.getProtocol(protocolId) + if protocol is None: + missing.append(str(protocolId)) + continue + protocolList.append(protocol) + + if missing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Protocol(s) not found: {', '.join(missing)}", + ) + + rawExport = self.currentProject.getProtocolsJson(protocolList) + workflow = self._decodeExportJsonPayload(rawExport) + metadata = self._buildWorkflowPluginMetadata(protocolList) + + return { + "sourceProjectId": projectId, + "protocolIds": protocolIds, + "workflow": workflow, + "scipionWeb": metadata, + "summary": { + "protocolCount": len(protocolList), + "requiredPluginNames": metadata.get("requiredPluginNames", []), + }, + } + + def importWorkflowProtocolsService( + self, + mapper: PostgresqlFlatMapper, + projectId: int, + currentUser: dict, + payload: Any, + ) -> Dict[str, Any]: + mode = str(getattr(payload, "mode", "append") or "append").strip().lower() + if mode != "append": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported import mode: {mode}", + ) + + workflowContent = self._unwrapWorkflowImportPayload( + getattr(payload, "workflow", None), + ) + + if isinstance(workflowContent, str): + workflowText = self._extractWorkflowJsonText(workflowContent) + try: + workflowContent = json.loads(workflowText) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid workflow JSON: {e}", + ) + + if not isinstance(workflowContent, (list, dict)): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Workflow must be a JSON list or object", + ) + + beforeIds = self._getCurrentWorkflowProtocolIds() + workflowJson = json.dumps(workflowContent, ensure_ascii=False) + + try: + loadResult = self.currentProject.loadProtocols(jsonStr=workflowJson) + except Exception as e: + logger.exception("Failed to import workflow protocols. projectId=%s", projectId) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to import workflow protocols: {e}", + ) + + errors = self._normalizeWorkflowImportErrors(loadResult) + + syncInfo = self.syncProjectProtocolsAndDependencies( + mapper, + projectId, + refresh=True, + checkPid=True, + ) + + afterIds = self._getCurrentWorkflowProtocolIds() + createdIds = self._sortProtocolIds(afterIds - beforeIds) + + return { + "status": 1 if errors else 0, + "errors": errors, + "workflow": [], + "created": [{"newId": protocolId} for protocolId in createdIds], + "protocolsCount": int(syncInfo.get("protocols", 0)), + "dependenciesCount": int(syncInfo.get("dependencies", 0)), + } + def writeRemoteFileService( self, protocolId: Union[int, str], From 837a8a913c1721ac3027d8538539d27f827f75df Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" Date: Thu, 11 Jun 2026 16:46:56 +0200 Subject: [PATCH 3/8] =?UTF-8?q?Adding=20new=20context=20men=C3=BA=20option?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/api/services/project_service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/backend/api/services/project_service.py b/app/backend/api/services/project_service.py index 2f2741f..0431945 100644 --- a/app/backend/api/services/project_service.py +++ b/app/backend/api/services/project_service.py @@ -9072,6 +9072,8 @@ def getContextMenuVisibilityPolicy(self) -> dict: "browse": True, "rename": True, "duplicate": True, + "copyWorkflow": True, + "pasteWorkflow": True, "delete": True, "restart": True, "continue": True, From eb4e5c116f8e4b009a09276abd9da6754c60c106 Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" Date: Thu, 11 Jun 2026 18:17:06 +0200 Subject: [PATCH 4/8] Updating context menu visibility test --- tests/unit/backend/api/services/test_project_service_core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/backend/api/services/test_project_service_core.py b/tests/unit/backend/api/services/test_project_service_core.py index bb2981e..ca63bd6 100644 --- a/tests/unit/backend/api/services/test_project_service_core.py +++ b/tests/unit/backend/api/services/test_project_service_core.py @@ -246,6 +246,8 @@ def test_GetContextMenuVisibilityPolicyReturnsAllExpectedFlags(projectService): "browse", "rename", "duplicate", + "copyWorkflow", + "pasteWorkflow", "delete", "restart", "continue", From 013ba9dd3b42248c800609a97f6ed195571443f1 Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" Date: Fri, 12 Jun 2026 10:13:30 +0200 Subject: [PATCH 5/8] Preserve workflow links only for same-project paste --- app/backend/api/services/project_service.py | 104 ++++++++++++++++++-- 1 file changed, 97 insertions(+), 7 deletions(-) diff --git a/app/backend/api/services/project_service.py b/app/backend/api/services/project_service.py index 0431945..e12d73e 100644 --- a/app/backend/api/services/project_service.py +++ b/app/backend/api/services/project_service.py @@ -4748,6 +4748,89 @@ def _normalizeWorkflowImportErrors(self, result: Any) -> List[str]: text = str(result).strip() return [text] if text else [] + def _getWorkflowImportSourceProjectId(self, payload: Any, workflowPayload: Any) -> Optional[str]: + sourceProjectId = getattr(payload, "sourceProjectId", None) + + if sourceProjectId is None and isinstance(workflowPayload, dict): + sourceProjectId = workflowPayload.get("sourceProjectId") + + metadata = workflowPayload.get("scipionWeb") + if sourceProjectId is None and isinstance(metadata, dict): + sourceProjectId = metadata.get("sourceProjectId") + + if sourceProjectId is None: + return None + + sourceProjectIdText = str(sourceProjectId).strip() + return sourceProjectIdText or None + + def _getWorkflowProtocolItems(self, workflowContent: Any) -> List[Dict[str, Any]]: + if isinstance(workflowContent, list): + return [item for item in workflowContent if isinstance(item, dict)] + + if isinstance(workflowContent, dict): + for key in ("workflow", "content", "protocols"): + value = workflowContent.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + + return [] + + def _getWorkflowProtocolId(self, protocolItem: Dict[str, Any], fallbackIndex: int) -> str: + protocolId = ( + protocolItem.get("object.id") + or protocolItem.get("id") + or protocolItem.get("_objId") + or fallbackIndex + ) + + return str(protocolId).strip() + + def _collectWorkflowProtocolIds(self, workflowContent: Any) -> Set[str]: + protocolIds: Set[str] = set() + + for index, protocolItem in enumerate(self._getWorkflowProtocolItems(workflowContent)): + protocolId = self._getWorkflowProtocolId(protocolItem, index) + if protocolId: + protocolIds.add(protocolId) + + return protocolIds + + def _sanitizeWorkflowExternalReferences(self, workflowContent: Any) -> Any: + copiedProtocolIds = self._collectWorkflowProtocolIds(workflowContent) + if not copiedProtocolIds: + return workflowContent + + dropValue = object() + pointerPattern = re.compile(r"^\s*(\d+)\.([A-Za-z_][A-Za-z0-9_\.]*)\s*$") + + def sanitizeValue(value: Any) -> Any: + if isinstance(value, str): + match = pointerPattern.match(value) + if match and match.group(1) not in copiedProtocolIds: + return dropValue + return value + + if isinstance(value, list): + nextList = [] + for item in value: + nextItem = sanitizeValue(item) + if nextItem is not dropValue: + nextList.append(nextItem) + return nextList + + if isinstance(value, dict): + nextDict = {} + for key, item in value.items(): + nextItem = sanitizeValue(item) + if nextItem is not dropValue: + nextDict[key] = nextItem + return nextDict + + return value + + return sanitizeValue(workflowContent) + def _unwrapWorkflowImportPayload(self, workflowPayload: Any) -> Any: if workflowPayload is None: raise HTTPException( @@ -4822,16 +4905,14 @@ def exportWorkflowProtocolsService( rawExport = self.currentProject.getProtocolsJson(protocolList) workflow = self._decodeExportJsonPayload(rawExport) metadata = self._buildWorkflowPluginMetadata(protocolList) + metadata["sourceProjectId"] = projectId + metadata["sourceProtocolIds"] = protocolIds return { "sourceProjectId": projectId, "protocolIds": protocolIds, "workflow": workflow, "scipionWeb": metadata, - "summary": { - "protocolCount": len(protocolList), - "requiredPluginNames": metadata.get("requiredPluginNames", []), - }, } def importWorkflowProtocolsService( @@ -4848,9 +4929,10 @@ def importWorkflowProtocolsService( detail=f"Unsupported import mode: {mode}", ) - workflowContent = self._unwrapWorkflowImportPayload( - getattr(payload, "workflow", None), - ) + rawWorkflowPayload = getattr(payload, "workflow", None) + sourceProjectId = self._getWorkflowImportSourceProjectId(payload, rawWorkflowPayload) + + workflowContent = self._unwrapWorkflowImportPayload(rawWorkflowPayload) if isinstance(workflowContent, str): workflowText = self._extractWorkflowJsonText(workflowContent) @@ -4868,6 +4950,14 @@ def importWorkflowProtocolsService( detail="Workflow must be a JSON list or object", ) + isSameProjectImport = ( + sourceProjectId is not None + and str(sourceProjectId).strip() == str(projectId).strip() + ) + + if not isSameProjectImport: + workflowContent = self._sanitizeWorkflowExternalReferences(workflowContent) + beforeIds = self._getCurrentWorkflowProtocolIds() workflowJson = json.dumps(workflowContent, ensure_ascii=False) From 054c3f0f219ed40f3d8b553c473ba35d4088a1d5 Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" <34970661+fonsecareyna82@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:59:59 +0200 Subject: [PATCH 6/8] Use memory-mapped volume reads for slice previews --- app/backend/utils/volume_utils.py | 94 ++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/app/backend/utils/volume_utils.py b/app/backend/utils/volume_utils.py index fd142af..5878d98 100644 --- a/app/backend/utils/volume_utils.py +++ b/app/backend/utils/volume_utils.py @@ -27,11 +27,14 @@ from pathlib import Path from dataclasses import dataclass from functools import lru_cache -from typing import Tuple, Dict, Any +from typing import Tuple, Dict, Any, Optional import numpy as np from pwem.emlib.image.image_readers import ImageReadersRegistry +MRC_LIKE_EXTENSIONS = {".mrc", ".map", ".mrcs", ".rec", ".ali"} + + @dataclass(frozen=True) class VolumeSignature: path: str @@ -44,29 +47,94 @@ def buildVolumeSignature(p: Path) -> VolumeSignature: return VolumeSignature(str(p), st.st_mtime_ns, st.st_size) -@lru_cache(maxsize=8) +def _normalizeVolumeArray(data: Any) -> np.ndarray: + arr = np.asarray(data) + if arr.ndim not in (2, 3): + arr = np.squeeze(arr) + if arr.ndim not in (2, 3): + raise ValueError(f"Unsupported dimensionality: {arr.shape}") + + if arr.ndim == 2: + arr = arr[None, ...] + elif arr.ndim != 3: + raise ValueError(f"Unsupported volume shape {arr.shape}") + + return arr.astype(np.float32, copy=False) + + +def _extractMrcVoxelSize(mrc: Any) -> Dict[str, Any]: + voxelSize = getattr(mrc, "voxel_size", None) + if voxelSize is None: + return {} + + try: + values = (float(voxelSize.x), float(voxelSize.y), float(voxelSize.z)) + except Exception: + try: + rawValues = list(voxelSize) + if len(rawValues) < 3: + return {} + values = (float(rawValues[0]), float(rawValues[1]), float(rawValues[2])) + except Exception: + return {} + + if not all(np.isfinite(v) and v > 0 for v in values): + return {} + + return {"voxelSize": values, "samplingRate": values} + + +def _openMrcMemmap(path: str) -> Optional[Tuple[np.ndarray, Dict[str, Any], Any]]: + try: + import mrcfile + except Exception: + return None + + try: + mrc = mrcfile.mmap(path, mode="r", permissive=True) + arr = _normalizeVolumeArray(mrc.data) + props = _extractMrcVoxelSize(mrc) + return arr, props, mrc + except Exception: + try: + mrc.close() + except Exception: + pass + return None + + +@lru_cache(maxsize=4) +def _readMrcVolumeMapped(sig: VolumeSignature) -> Tuple[np.ndarray, Dict[str, Any], Any]: + mapped = _openMrcMemmap(sig.path) + if mapped is None: + raise RuntimeError("Could not open volume as an MRC memory map") + return mapped + + +@lru_cache(maxsize=2) def _readVolumeCached(sig: VolumeSignature) -> Tuple[np.ndarray, Dict[str, Any]]: imgStk = ImageReadersRegistry.open(sig.path) - data = np.asarray(imgStk.getImages()) - if data.ndim not in (2, 3): - data = np.squeeze(data) - if data.ndim not in (2, 3): - raise ValueError(f"Unsupported dimensionality: {data.shape}") + data = _normalizeVolumeArray(imgStk.getImages()) try: props = imgStk.getProperties() or {} except Exception: props = {} - return np.asarray(data, dtype=np.float32), props + return data, props def readVolumeArray3d(volumePath: str) -> Tuple[np.ndarray, Dict[str, Any]]: p = Path(volumePath) if not p.exists(): raise FileNotFoundError(volumePath) + sig = buildVolumeSignature(p) + + if p.suffix.lower() in MRC_LIKE_EXTENSIONS: + try: + arr, props, _mrcHandle = _readMrcVolumeMapped(sig) + return arr, props + except Exception: + pass + arr, props = _readVolumeCached(sig) - if arr.ndim == 2: - arr = arr[None, ...] # (1, Y, X) - elif arr.ndim != 3: - raise ValueError(f"Unsupported volume shape {arr.shape}") - return arr.astype(np.float32, copy=False), props + return arr, props From 830843d582320c3b9f5165e281dd361991a98fe5 Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" Date: Fri, 12 Jun 2026 18:02:27 +0200 Subject: [PATCH 7/8] fix(coords3d): stabilize tomogram slice rendering --- app/backend/api/services/project_service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/backend/api/services/project_service.py b/app/backend/api/services/project_service.py index e12d73e..00f5837 100644 --- a/app/backend/api/services/project_service.py +++ b/app/backend/api/services/project_service.py @@ -7054,7 +7054,10 @@ def renderCoords3dTomogramSliceService( if thumb is not None and thumb > 0: pilTmp = PILImage.fromarray(gray.astype(np.uint8), mode="L") pilTmp.thumbnail((thumb, thumb)) - gray = np.asarray(pilTmp, copy=False) + gray = np.asarray(pilTmp) + + if gray.dtype != np.uint8: + gray = gray.astype(np.uint8, copy=False) imgArray = gray.astype(np.uint8, copy=False) pilMode = "L" From d33ae6612ace4c02d64f08f3888af940a6bc7a76 Mon Sep 17 00:00:00 2001 From: "Yunior C. Fonseca Reyna" Date: Sat, 13 Jun 2026 14:36:14 +0200 Subject: [PATCH 8/8] Improving user settings --- app/backend/api/schemas/settings_schema.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/backend/api/schemas/settings_schema.py b/app/backend/api/schemas/settings_schema.py index 2e62b53..5ac89c1 100644 --- a/app/backend/api/schemas/settings_schema.py +++ b/app/backend/api/schemas/settings_schema.py @@ -36,6 +36,7 @@ class UserSettingsOut(BaseModel): theme: Literal["system", "light", "dark"] = "system" uiDensity: Literal["comfortable", "compact"] = "comfortable" fontScale: float = Field(default=1.0, ge=0.85, le=1.25) + workflowViewMode: Optional[Literal["treeTb", "treeLr", "grid", "table"]] = "treeTb" language: Literal["en", "es"] = "en" timeZone: str = "Europe/Madrid" @@ -56,6 +57,7 @@ class UserSettingsPatch(BaseModel): theme: Optional[Literal["system", "light", "dark"]] = None uiDensity: Optional[Literal["comfortable", "compact"]] = None fontScale: Optional[float] = Field(default=None, ge=0.85, le=1.25) + workflowViewMode: Optional[Literal["treeTb", "treeLr", "grid", "table"]] = None language: Optional[Literal["en", "es"]] = None timeZone: Optional[str] = None