diff --git a/client/src/components/User/Credentials/ServiceCredentialsGroupsList.vue b/client/src/components/User/Credentials/ServiceCredentialsGroupsList.vue
index f1f019c2e5e..d6d127afdd4 100644
--- a/client/src/components/User/Credentials/ServiceCredentialsGroupsList.vue
+++ b/client/src/components/User/Credentials/ServiceCredentialsGroupsList.vue
@@ -18,7 +18,7 @@
*
*/
-import { faKey, faPencilAlt, faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
+import { faExclamationTriangle, faKey, faPencilAlt, faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
import { BModal } from "bootstrap-vue";
import { faCheck } from "font-awesome-6";
import { storeToRefs } from "pinia";
@@ -71,7 +71,7 @@ const props = defineProps();
const { confirm } = useConfirmDialog();
-const { getToolNameById } = useToolStore();
+const { getToolForId, getToolNameById } = useToolStore();
const userToolsServiceCredentialsStore = useUserToolsServiceCredentialsStore();
const { userToolsServicesCurrentGroupIds } = storeToRefs(userToolsServiceCredentialsStore);
@@ -95,12 +95,25 @@ const cardTitle = computed(() => (group: ServiceCredentialsGroupDetails) => {
return `${group.serviceDefinition.name} (v${group.serviceDefinition.version}) - ${group.name}`;
});
+/**
+ * Checks if the source tool for a credential group is missing/deleted.
+ * @param {ServiceCredentialsGroupDetails} group - The credential group to check.
+ * @returns {boolean} True if the tool is no longer available.
+ */
+const isToolMissing = computed(() => (group: ServiceCredentialsGroupDetails) => {
+ return !getToolForId(group.sourceId);
+});
+
/**
* Checks if a credential group is currently in use by any tool.
* @param {ServiceCredentialsGroupDetails} group - The credential group to check.
* @returns {boolean} True if the group is in use.
*/
const isGroupInUse = computed(() => (group: ServiceCredentialsGroupDetails) => {
+ if (isToolMissing.value(group)) {
+ return false;
+ }
+
const userToolKey = userToolsServiceCredentialsStore.getUserToolKey(group.sourceId, group.sourceVersion);
const userToolService = userToolsServicesCurrentGroupIds.value[userToolKey];
for (const groupId of Object.values(userToolService || {})) {
@@ -111,6 +124,18 @@ const isGroupInUse = computed(() => (group: ServiceCredentialsGroupDetails) => {
return false;
});
+/**
+ * Gets the display name for a tool, with a fallback for missing/deleted tools.
+ * @param {ServiceCredentialsGroupDetails} group - The credential group.
+ * @returns {string} The tool name or a fallback indicator.
+ */
+const getToolDisplayName = computed(() => (group: ServiceCredentialsGroupDetails) => {
+ if (isToolMissing.value(group)) {
+ return `${group.sourceId} (deleted)`;
+ }
+ return getToolNameById(group.sourceId);
+});
+
/**
* Deletes a credential group after user confirmation.
* @param {ServiceCredentialsGroupDetails} groupToDelete - The group to delete.
@@ -120,7 +145,9 @@ const isGroupInUse = computed(() => (group: ServiceCredentialsGroupDetails) => {
async function deleteGroup(groupToDelete: ServiceCredentialsGroupDetails): Promise {
let message = `Are you sure you want to delete the credentials group "${groupToDelete.name}"?`;
- if (isGroupInUse.value(groupToDelete)) {
+ if (isToolMissing.value(groupToDelete)) {
+ message = message.concat(` The associated tool is no longer available.`);
+ } else if (isGroupInUse.value(groupToDelete)) {
message = message.concat(` This group is currently in use by '${getToolNameById(groupToDelete.sourceId)}'.`);
}
@@ -221,23 +248,40 @@ async function onSaveChanges(): Promise {
* @returns {CardBadge[]} Array of badge configurations.
*/
function getBadgesFor(group: ServiceCredentialsGroupDetails): CardBadge[] {
- const badges: CardBadge[] = [
- {
- id: `tool-${group.sourceId}`,
- icon: faWrench,
- title: "This tool is using this credentials group. Click to view.",
- label: getToolNameById(group.sourceId),
- to: `/root?tool_id=${group.sourceId}&tool_version=${group.sourceVersion}`,
- },
- {
+ const toolMissing = isToolMissing.value(group);
+ const badges: CardBadge[] = [];
+
+ if (toolMissing) {
+ badges.push({
+ id: `tool-missing-${group.id}`,
+ icon: faExclamationTriangle,
+ title: "The tool associated with these credentials is no longer available. You cannot edit or use this group.",
+ label: "Tool Unavailable",
+ variant: "warning",
+ });
+ }
+
+ badges.push({
+ id: `tool-${group.sourceId}`,
+ icon: faWrench,
+ title: toolMissing
+ ? "This tool is no longer available."
+ : "This tool is using this credentials group. Click to view.",
+ label: getToolDisplayName.value(group),
+ to: toolMissing ? undefined : `/root?tool_id=${group.sourceId}&tool_version=${group.sourceVersion}`,
+ });
+
+ if (!toolMissing) {
+ badges.push({
id: `in-use-${group.id}`,
icon: faCheck,
title: "This group is currently in use.",
label: "In Use",
variant: "success",
visible: isGroupInUse.value(group),
- },
- ];
+ });
+ }
+
return badges;
}
@@ -247,6 +291,7 @@ function getBadgesFor(group: ServiceCredentialsGroupDetails): CardBadge[] {
* @returns {CardAction[]} Array of action configurations
*/
function getPrimaryActions(group: ServiceCredentialsGroupDetails): CardAction[] {
+ const toolMissing = isToolMissing.value(group);
const primaryActions: CardAction[] = [
{
id: `delete-${group.id}`,
@@ -259,10 +304,11 @@ function getPrimaryActions(group: ServiceCredentialsGroupDetails): CardAction[]
{
id: `edit-${group.id}`,
label: "Edit",
- title: "Edit this group",
+ title: !toolMissing ? "Cannot edit - tool definition not available" : "Edit this group",
icon: faPencilAlt,
variant: "outline-info",
handler: () => editGroup(group),
+ disabled: toolMissing,
},
];
return primaryActions;
diff --git a/lib/galaxy/config/sample/tool_conf.xml.sample b/lib/galaxy/config/sample/tool_conf.xml.sample
index 599dba908cc..a494ca91e64 100644
--- a/lib/galaxy/config/sample/tool_conf.xml.sample
+++ b/lib/galaxy/config/sample/tool_conf.xml.sample
@@ -53,6 +53,7 @@
+
diff --git a/lib/galaxy/files/sources/_rdm.py b/lib/galaxy/files/sources/_rdm.py
index 335f7299a15..2a1e3ae3542 100644
--- a/lib/galaxy/files/sources/_rdm.py
+++ b/lib/galaxy/files/sources/_rdm.py
@@ -23,13 +23,13 @@ log = logging.getLogger(__name__)
class RDMFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration):
- token: Union[str, TemplateExpansion]
- public_name: Union[str, TemplateExpansion]
+ token: Optional[Union[str, TemplateExpansion]] = None
+ public_name: Optional[Union[str, TemplateExpansion]] = None
class RDMFileSourceConfiguration(BaseFileSourceConfiguration):
- token: str
- public_name: str
+ token: Optional[str] = None
+ public_name: Optional[str] = None
class ContainerAndFileIdentifier(NamedTuple):
@@ -51,7 +51,7 @@ class RDMRepositoryInteractor:
"""
def __init__(self, repository_url: str, plugin: "RDMFilesSource"):
- self._repository_url = repository_url
+ self._repository_url = self._strip_last_slash(repository_url)
self._plugin = plugin
@property
@@ -138,6 +138,12 @@ class RDMRepositoryInteractor:
"""
raise NotImplementedError()
+ def _strip_last_slash(self, url: str) -> str:
+ """Utility method to strip the last slash from a URL if present."""
+ if url.endswith("/"):
+ return url[:-1]
+ return url
+
class RDMFilesSource(BaseFilesSource[RDMFileSourceTemplateConfiguration, RDMFileSourceConfiguration]):
"""Base class for Research Data Management (RDM) file sources.
diff --git a/lib/galaxy/files/sources/dataverse.py b/lib/galaxy/files/sources/dataverse.py
index 5154f685612..81c1ae710d2 100644
--- a/lib/galaxy/files/sources/dataverse.py
+++ b/lib/galaxy/files/sources/dataverse.py
@@ -13,6 +13,7 @@ from typing_extensions import TypedDict
from galaxy.exceptions import (
AuthenticationRequired,
+ MessageException,
ObjectNotFound,
)
from galaxy.files.models import (
@@ -105,6 +106,7 @@ class DataverseRDMFilesSource(RDMFilesSource):
- doi:10.70122/FK2/AVNCLL (persistent ID)
- doi:10.70122/FK2/DIG2DG/AVNCLL (persistent ID)
- doi:10.70122/FK2/DIG2DG/id:12345 (database ID)
+ - doi:10.5072/FK2/doi:10.70122/AVNCLL (persistent ID)
- perma:BSC/3ST00L/id:9056 (database ID)
"""
if not source_path.startswith("/"):
@@ -125,8 +127,7 @@ class DataverseRDMFilesSource(RDMFilesSource):
f"Invalid source path: '{source_path}'. Expected format: '//'."
)
- file_id_part = parts[-1]
- dataset_id = "/".join(parts[:-1])
+ dataset_id, file_id_part = self._split_dataset_and_file_pid(parts)
# The file identifier can be either:
# - A persistent ID suffix (e.g., 'AVNCLL' -> full ID is 'doi:10.70122/FK2/DIG2DG/AVNCLL')
@@ -134,11 +135,46 @@ class DataverseRDMFilesSource(RDMFilesSource):
if file_id_part.startswith("id:"):
# Database ID format - keep the 'id:' prefix as the file identifier
file_id = file_id_part
+ elif re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:.*", file_id_part):
+ # Full persistent identifier (e.g. doi:, hdl:, ark:, or custom PID providers).
+ # Files in Dataverse may have their own independent persistent IDs that are
+ # not hierarchically related to the dataset persistent ID.
+ file_id = file_id_part
else:
- # Persistent ID format - construct full persistent ID
+ # Dataset-scoped persistent ID suffix - construct full persistent ID
file_id = f"{dataset_id}/{file_id_part}"
return ContainerAndFileIdentifier(container_id=dataset_id, file_identifier=file_id)
+ @staticmethod
+ def _split_dataset_and_file_pid(parts: list[str]) -> tuple[str, str]:
+ """
+ Split a Dataverse source path into dataset ID and file identifier parts.
+
+ Dataverse file-level persistent IDs may themselves contain slashes and are not
+ necessarily hierarchically related to the dataset persistent ID. For example:
+
+ /doi:10.57745/I8EUTL/doi:10.57745/L7SOAJ
+
+ In this case:
+ dataset_id = doi:10.57745/I8EUTL
+ file_id = doi:10.57745/L7SOAJ
+
+ This helper detects such cases by recognizing URI-scheme prefixes in path segments
+ and grouping them accordingly.
+ """
+ # Default: last segment is the file identifier
+ file_id_part = parts[-1]
+ dataset_id = "/".join(parts[:-1])
+
+ # Heuristic: if the penultimate segment starts a URI scheme (e.g. doi:, hdl:, ark:),
+ # then the file persistent ID spans the last two segments.
+ pid_scheme_re = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
+ if len(parts) >= 3 and pid_scheme_re.match(parts[-2]):
+ file_id_part = f"{parts[-2]}/{parts[-1]}"
+ dataset_id = "/".join(parts[:-2])
+
+ return dataset_id, file_id_part
+
def get_container_id_from_path(self, source_path: str) -> str:
return self.parse_path(source_path, container_id_only=True).container_id
@@ -336,14 +372,14 @@ class DataverseRepositoryInteractor(RDMRepositoryInteractor):
collection_payload = self._prepare_collection_data(title, public_name, user_email)
collection = self._create_collection(":root", collection_payload, context)
if not collection or "data" not in collection or "alias" not in collection["data"]:
- raise Exception("Could not create collection in Dataverse or response has an unexpected format.")
+ raise MessageException("Could not create collection in Dataverse or response has an unexpected format.")
collection_alias = collection["data"]["alias"]
# Prepare and create the dataset
dataset_payload = self._prepare_dataset_data(title, public_name, user_email)
dataset = self._create_dataset(collection_alias, dataset_payload, context)
if not dataset or "data" not in dataset:
- raise Exception("Could not create dataset in Dataverse or response has an unexpected format.")
+ raise MessageException("Could not create dataset in Dataverse or response has an unexpected format.")
dataset["data"]["name"] = title
return dataset["data"]
@@ -421,14 +457,18 @@ class DataverseRepositoryInteractor(RDMRepositoryInteractor):
f"Authentication required to download file from '{download_file_content_url}'. "
f"Please provide a valid API token in your user preferences."
)
- # TODO: We can only download files from published datasets for now
- if e.code in [403, 404]:
+ if e.code == 403:
+ # Permission denied: dataset may be unpublished or user lacks access rights
+ raise ObjectNotFound(
+ f"Access forbidden when downloading file from '{download_file_content_url}'. "
+ f"You may not have permission to access this file, or the dataset is not published."
+ )
+ if e.code == 404:
raise ObjectNotFound(
f"File not found at '{download_file_content_url}'. "
f"Please make sure the dataset and file exist and are published."
)
- else:
- raise
+ raise
def _get_datasets_from_response(self, response: dict) -> list[RemoteDirectory]:
rval: list[RemoteDirectory] = []
@@ -494,7 +534,7 @@ class DataverseRepositoryInteractor(RDMRepositoryInteractor):
error_message = self._get_response_error_message(response)
if response.status_code == 403:
self._raise_auth_required(error_message)
- raise Exception(
+ raise MessageException(
f"Request to {response.url} failed with status code {response.status_code}: {error_message}"
)
diff --git a/lib/galaxy/model/dataset_collections/types/sample_sheet_workbook.py b/lib/galaxy/model/dataset_collections/types/sample_sheet_workbook.py
index 3bbb83dc62b..ca17defe683 100644
--- a/lib/galaxy/model/dataset_collections/types/sample_sheet_workbook.py
+++ b/lib/galaxy/model/dataset_collections/types/sample_sheet_workbook.py
@@ -618,5 +618,24 @@ def _list_to_sample_sheet_collection_type(input_collection_type: str) -> SampleS
)
+def _sample_sheet_to_list_collection_type(input_collection_type: str) -> str:
+ """Convert sample_sheet collection types to corresponding list collection types.
+
+ Converts sample_sheet types to list types (e.g., sample_sheet:paired -> list:paired).
+ """
+ if input_collection_type == "sample_sheet":
+ return "list"
+ elif input_collection_type == "sample_sheet:paired":
+ return "list:paired"
+ elif input_collection_type == "sample_sheet:paired_or_unpaired":
+ return "list:paired_or_unpaired"
+ elif input_collection_type == "sample_sheet:record":
+ return "list:record"
+ else:
+ raise RequestParameterInvalidException(
+ f"Invalid collection type for sample sheet conversion: {input_collection_type}"
+ )
+
+
def _prefix_column_to_column_target(column_header: FetchPrefixColumn) -> ColumnTarget:
return target_model_by_type(column_header.type)
diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py
index ba5008f1c94..3062109b5d3 100644
--- a/lib/galaxy/tools/__init__.py
+++ b/lib/galaxy/tools/__init__.py
@@ -62,6 +62,7 @@ from galaxy.model import (
ToolRequest,
)
from galaxy.model.dataset_collections.matching import MatchingCollections
+from galaxy.model.dataset_collections.types.sample_sheet_workbook import _sample_sheet_to_list_collection_type
from galaxy.schema.credentials import CredentialsContext
from galaxy.tool_shed.util.repository_util import get_installed_repository
from galaxy.tool_shed.util.shed_util_common import set_image_paths
@@ -4797,6 +4798,66 @@ class DuplicateFileToCollectionTool(DatabaseOperationTool):
)
+class ConvertSampleSheetTool(DatabaseOperationTool):
+ """Convert a sample sheet collection back to its corresponding non-sample-sheet type.
+
+ This tool strips the sample sheet metadata (column_definitions and row columns)
+ and converts the collection type from sample_sheet variants to list variants.
+ """
+
+ tool_type = "convert_sample_sheet"
+ require_terminal_states = False
+ require_dataset_ok = False
+
+ def produce_outputs(self, trans, out_data, output_collections, incoming, history, **kwds):
+ has_collection = incoming["input"]
+ if hasattr(has_collection, "element_type"):
+ # It is a DCE
+ collection = has_collection.element_object
+ else:
+ # It is an HDCA
+ collection = has_collection.collection
+
+ input_collection_type = collection.collection_type
+ output_collection_type = _sample_sheet_to_list_collection_type(input_collection_type)
+
+ new_elements: dict[str, Any] = {}
+ copied_datasets = []
+
+ def copy_elements(source_collection, target_dict):
+ for dce in source_collection.elements:
+ element_identifier = dce.element_identifier
+ dce_object = dce.element_object
+ if dce.is_collection:
+ # Handle nested collections (e.g., paired within sample_sheet:paired)
+ sub_collection: dict[str, Any] = {}
+ sub_collection["src"] = "new_collection"
+ sub_collection["collection_type"] = dce_object.collection_type
+ sub_elements = {}
+ for sub_dce in dce_object.elements:
+ sub_element_identifier = sub_dce.element_identifier
+ sub_dce_object = sub_dce.element_object
+ copied_dataset = sub_dce_object.copy(copy_tags=sub_dce_object.tags, flush=False)
+ sub_elements[sub_element_identifier] = copied_dataset
+ copied_datasets.append(copied_dataset)
+ sub_collection["elements"] = sub_elements
+ target_dict[element_identifier] = sub_collection
+ else:
+ copied_dataset = dce_object.copy(copy_tags=dce_object.tags, flush=False)
+ target_dict[element_identifier] = copied_dataset
+ copied_datasets.append(copied_dataset)
+
+ copy_elements(collection, new_elements)
+ self._add_datasets_to_history(history, copied_datasets)
+ output_collections.create_collection(
+ next(iter(self.outputs.values())),
+ "output",
+ collection_type=output_collection_type,
+ elements=new_elements,
+ propagate_hda_tags=False,
+ )
+
+
# Populate tool_type to ToolClass mappings
TOOL_CLASSES: list[type[Tool]] = [
Tool,
@@ -4816,6 +4877,7 @@ TOOL_CLASSES: list[type[Tool]] = [
BuildListCollectionTool,
ExtractDatasetCollectionTool,
DataDestinationTool,
+ ConvertSampleSheetTool,
]
tool_types = {tool_class.tool_type: tool_class for tool_class in TOOL_CLASSES}
diff --git a/lib/galaxy/tools/convert_sample_sheet.xml b/lib/galaxy/tools/convert_sample_sheet.xml
new file mode 100644
index 00000000000..0ed0a43a700
--- /dev/null
+++ b/lib/galaxy/tools/convert_sample_sheet.xml
@@ -0,0 +1,52 @@
+
+ to list collection
+
+
+
+ operation_2409
+
+
+ model_operation_macros.xml
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/galaxy/webapps/galaxy/services/credentials.py b/lib/galaxy/webapps/galaxy/services/credentials.py
index 9b7977c75db..e6637930a90 100644
--- a/lib/galaxy/webapps/galaxy/services/credentials.py
+++ b/lib/galaxy/webapps/galaxy/services/credentials.py
@@ -272,14 +272,30 @@ class CredentialsService:
for user_credentials, credentials_group, credential in existing_user_credentials:
cred_id = user_credentials.id
- definition = self._get_credentials_definition(
- user,
- cast(SOURCE_TYPE, user_credentials.source_type),
- user_credentials.source_id,
- user_credentials.source_version,
- user_credentials.name,
- user_credentials.version,
- )
+ definition = None
+ try:
+ definition = self._get_credentials_definition(
+ user,
+ cast(SOURCE_TYPE, user_credentials.source_type),
+ user_credentials.source_id,
+ user_credentials.source_version,
+ user_credentials.name,
+ user_credentials.version,
+ )
+ except ObjectNotFound:
+ # Tool was removed or is no longer available - create a minimal fallback definition
+ # using the stored credential data so the UI can still display the credentials
+ if include_definition:
+ definition = CredentialsRequirement(
+ name=user_credentials.name,
+ version=user_credentials.version,
+ description="",
+ label="",
+ optional=False,
+ variables=[],
+ secrets=[],
+ )
+
user_credentials_dict.setdefault(
cred_id,
{
@@ -295,7 +311,7 @@ class CredentialsService:
},
)
- if include_definition:
+ if include_definition and definition:
user_credentials_dict[cred_id]["definition"] = {
"name": definition.name,
"version": definition.version,
diff --git a/lib/galaxy_test/api/test_tools.py b/lib/galaxy_test/api/test_tools.py
index bd8a2fa8678..c06ff6a127a 100644
--- a/lib/galaxy_test/api/test_tools.py
+++ b/lib/galaxy_test/api/test_tools.py
@@ -778,6 +778,89 @@ class TestToolsApi(ApiTestCase, TestsTools):
assert run_response.status_code == 400
assert run_response.json()["err_msg"] == "Dataset collection has no element_index with key 100."
+ @skip_without_tool("__CONVERT_SAMPLE_SHEET__")
+ def test_convert_sample_sheet_to_list(self):
+ with self.dataset_populator.test_history(require_new=False) as history_id:
+ # Create sample_sheet collection with column_definitions and rows
+ create_response = self.dataset_collection_populator.create_sample_sheet(
+ history_id,
+ contents=[("sample1", "content1"), ("sample2", "content2")],
+ column_definitions=[
+ {"type": "int", "name": "replicate", "optional": False},
+ {"type": "string", "name": "treatment", "optional": False},
+ ],
+ rows={"sample1": [1, "control"], "sample2": [2, "treatment"]},
+ )
+ self._assert_status_code_is(create_response, 200)
+ sample_sheet_hdca = create_response.json()
+ assert sample_sheet_hdca["collection_type"] == "sample_sheet"
+ assert sample_sheet_hdca["column_definitions"] is not None
+
+ # Run convert sample sheet tool
+ inputs = {"input": {"src": "hdca", "id": sample_sheet_hdca["id"]}}
+ self.dataset_populator.wait_for_history(history_id, assert_ok=True)
+ response = self._run("__CONVERT_SAMPLE_SHEET__", history_id, inputs, assert_ok=True)
+
+ # Verify output is a list collection without sample sheet metadata
+ output_collections = response["output_collections"]
+ assert len(output_collections) == 1
+ self.dataset_populator.wait_for_job(response["jobs"][0]["id"], assert_ok=True)
+ converted_hdca = self.dataset_populator.get_history_collection_details(
+ history_id, hid=output_collections[0]["hid"]
+ )
+ assert converted_hdca["collection_type"] == "list"
+ assert converted_hdca.get("column_definitions") is None
+ assert len(converted_hdca["elements"]) == 2
+ element_identifiers = [e["element_identifier"] for e in converted_hdca["elements"]]
+ assert "sample1" in element_identifiers
+ assert "sample2" in element_identifiers
+
+ @skip_without_tool("__CONVERT_SAMPLE_SHEET__")
+ def test_convert_sample_sheet_paired_to_list_paired(self):
+ with self.dataset_populator.test_history(require_new=False) as history_id:
+ # Create sample_sheet:paired collection
+ pair_identifiers = self.dataset_collection_populator.pair_identifiers(history_id, ["forward", "reverse"])
+ element_identifiers = [
+ {
+ "name": "sample1",
+ "collection_type": "paired",
+ "src": "new_collection",
+ "element_identifiers": pair_identifiers,
+ }
+ ]
+ create_response = self.dataset_collection_populator.create_sample_sheet(
+ history_id,
+ contents=element_identifiers,
+ column_definitions=[{"type": "int", "name": "replicate", "default_value": 0, "optional": False}],
+ rows={"sample1": [42]},
+ collection_type="sample_sheet:paired",
+ )
+ self._assert_status_code_is(create_response, 200)
+ sample_sheet_hdca = create_response.json()
+ assert sample_sheet_hdca["collection_type"] == "sample_sheet:paired"
+ assert sample_sheet_hdca["column_definitions"] is not None
+
+ # Run convert sample sheet tool
+ inputs = {"input": {"src": "hdca", "id": sample_sheet_hdca["id"]}}
+ self.dataset_populator.wait_for_history(history_id, assert_ok=True)
+ response = self._run("__CONVERT_SAMPLE_SHEET__", history_id, inputs, assert_ok=True)
+
+ # Verify output is a list:paired collection without sample sheet metadata
+ output_collections = response["output_collections"]
+ assert len(output_collections) == 1
+ self.dataset_populator.wait_for_job(response["jobs"][0]["id"], assert_ok=True)
+ converted_hdca = self.dataset_populator.get_history_collection_details(
+ history_id, hid=output_collections[0]["hid"]
+ )
+ assert converted_hdca["collection_type"] == "list:paired"
+ assert converted_hdca.get("column_definitions") is None
+ assert len(converted_hdca["elements"]) == 1
+ # Verify nested paired structure is preserved
+ element = converted_hdca["elements"][0]
+ assert element["element_type"] == "dataset_collection"
+ assert element["object"]["collection_type"] == "paired"
+ assert len(element["object"]["elements"]) == 2
+
@skip_without_tool("__FILTER_FAILED_DATASETS__")
def test_filter_failed_list(self):
with self.dataset_populator.test_history(require_new=False) as history_id:
diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py
index 392bc1f59bc..19b5d9718ba 100644
--- a/lib/galaxy_test/base/populators.py
+++ b/lib/galaxy_test/base/populators.py
@@ -3607,6 +3607,47 @@ class BaseDatasetCollectionPopulator:
element_identifiers = [hda_to_identifier(i, hda) for (i, hda) in enumerate(hdas)]
return element_identifiers
+ def create_sample_sheet(
+ self,
+ history_id: str,
+ contents: list,
+ column_definitions: list,
+ rows: dict,
+ name: str = "test sample sheet",
+ collection_type: str = "sample_sheet",
+ ):
+ """Create a sample_sheet collection with metadata.
+
+ Args:
+ history_id: The history ID to create the collection in.
+ contents: A list of 2-tuples of form (name, dataset_content) for flat sample sheets,
+ or a list of element identifiers dicts for nested collections.
+ column_definitions: List of column definition dicts.
+ rows: Dict mapping element identifiers to row values.
+ name: Name for the collection.
+ collection_type: The collection type (sample_sheet, sample_sheet:paired, etc).
+
+ Returns:
+ Response from creating the collection.
+ """
+ # For flat sample sheets, create element identifiers from contents
+ if contents and isinstance(contents[0], tuple):
+ element_identifiers = self.list_identifiers(history_id, contents)
+ else:
+ # Assume contents is already element_identifiers for nested collections
+ element_identifiers = contents
+
+ payload = dict(
+ name=name,
+ instance_type="history",
+ history_id=history_id,
+ element_identifiers=element_identifiers,
+ collection_type=collection_type,
+ column_definitions=column_definitions,
+ rows=rows,
+ )
+ return self._create_collection(payload)
+
def __create(self, payload, wait=False):
# Create a collection - either from existing datasets using collection creation API
# or from direct uploads with the fetch API. Dispatch on "targets" keyword in payload
diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml
index e1d5e36e5bc..d164f0d4c5f 100644
--- a/test/functional/tools/sample_tool_conf.xml
+++ b/test/functional/tools/sample_tool_conf.xml
@@ -338,6 +338,7 @@
+
diff --git a/test/integration/test_credentials.py b/test/integration/test_credentials.py
index 2b68630d832..d3aa13485cc 100644
--- a/test/integration/test_credentials.py
+++ b/test/integration/test_credentials.py
@@ -458,6 +458,73 @@ class TestCredentialsApi(integration_util.IntegrationTestCase, integration_util.
vault_ref = self._get_vault_ref(payload, group["id"], secret["name"])
self._check_vault_entry_exists(test_user_email, vault_ref, should_exist=False)
+ @skip_without_tool(CREDENTIALS_TEST_TOOL)
+ def test_list_credentials_with_missing_tool(self):
+ # Create credentials for the test tool
+ payload = self._build_credentials_payload()
+ self._provide_user_credentials(payload)
+
+ # Verify credentials exist normally
+ credentials_list = self._check_credentials_exist()
+ assert len(credentials_list) == 1
+ user_credentials_id = credentials_list[0]["id"]
+
+ # Save the tool reference before removing it
+ tool = self._app.toolbox.get_tool(CREDENTIALS_TEST_TOOL)
+ assert tool is not None, f"Tool {CREDENTIALS_TEST_TOOL} should be available before removal"
+
+ try:
+ # Remove the tool to simulate it being unavailable
+ # Use remove_from_panel=False to keep restoration simple
+ self._app.toolbox.remove_tool_by_id(CREDENTIALS_TEST_TOOL, remove_from_panel=False)
+
+ # Verify tool is actually removed
+ assert self._app.toolbox.get_tool(CREDENTIALS_TEST_TOOL) is None
+
+ # Test 1: List credentials with include_definition=True
+ response = self._get("/api/users/current/credentials?include_definition=true")
+ self._assert_status_code_is(response, 200)
+ credentials_with_definition = response.json()
+
+ assert len(credentials_with_definition) == 1
+ credential = credentials_with_definition[0]
+
+ # Check that the credential still has basic information
+ assert credential["id"] == user_credentials_id
+ assert credential["source_id"] == CREDENTIALS_TEST_TOOL
+ assert credential["source_type"] == "tool"
+
+ # Check that the fallback definition was provided
+ assert "definition" in credential
+ definition = credential["definition"]
+ assert definition["name"] == payload["service_credential"]["name"]
+ assert definition["version"] == payload["service_credential"]["version"]
+ assert definition["description"] == ""
+ assert definition["label"] == ""
+ assert definition["optional"] is False
+ assert definition["variables"] == []
+ assert definition["secrets"] == []
+
+ # Verify that groups are still present and accessible
+ assert len(credential["groups"]) > 0
+
+ # Test 2: List credentials without include_definition
+ response = self._get("/api/users/current/credentials")
+ self._assert_status_code_is(response, 200)
+ credentials_without_definition = response.json()
+
+ assert len(credentials_without_definition) == 1
+ credential_no_def = credentials_without_definition[0]
+
+ # Should not have definition field when not requested
+ assert "definition" not in credential_no_def
+ assert credential_no_def["id"] == user_credentials_id
+ assert len(credential_no_def["groups"]) > 0
+ finally:
+ # Restore the tool to avoid affecting other tests
+ if tool is not None:
+ self._app.toolbox.register_tool(tool)
+
def _provide_user_credentials(self, payload=None, status_code=200):
payload = payload or self._build_credentials_payload()
response = self._post("/api/users/current/credentials", data=payload, json=True)