mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge branch 'release_25.1' into dev
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
* <ServiceCredentialsGroupsList :service-groups="groups" />
|
||||
*/
|
||||
|
||||
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<Props>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
* @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;
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<tool file="${model_tools_path}/build_list.xml" />
|
||||
<tool file="${model_tools_path}/build_list_1.2.0.xml" />
|
||||
<tool file="${model_tools_path}/sample_sheet_to_tabular.xml" />
|
||||
<tool file="${model_tools_path}/convert_sample_sheet.xml" />
|
||||
<tool file="${model_tools_path}/extract_dataset.xml" />
|
||||
<tool file="${model_tools_path}/duplicate_file_to_collection.xml" />
|
||||
</section>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: '/<dataset_id>/<file_identifier>'."
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<tool id="__CONVERT_SAMPLE_SHEET__"
|
||||
name="Convert sample sheet"
|
||||
version="1.0.0"
|
||||
tool_type="convert_sample_sheet">
|
||||
<description>to list collection</description>
|
||||
<type class="ConvertSampleSheetTool" module="galaxy.tools" />
|
||||
<action module="galaxy.tools.actions.model_operations"
|
||||
class="ModelOperationToolAction"/>
|
||||
<edam_operations>
|
||||
<edam_operation>operation_2409</edam_operation>
|
||||
</edam_operations>
|
||||
<macros>
|
||||
<import>model_operation_macros.xml</import>
|
||||
</macros>
|
||||
<inputs>
|
||||
<param type="data_collection" collection_type="sample_sheet,sample_sheet:paired,sample_sheet:paired_or_unpaired,sample_sheet" name="input" label="Sample sheet to convert" />
|
||||
</inputs>
|
||||
<outputs>
|
||||
<!-- type_source is a lie, we don't have a way to encode the transform that is actually happening -->
|
||||
<collection name="output" format_source="input" type_source="input" label="${on_string} (converted)" >
|
||||
</collection>
|
||||
</outputs>
|
||||
<help><![CDATA[
|
||||
|
||||
========
|
||||
Synopsis
|
||||
========
|
||||
|
||||
Converts a sample sheet collection back to its corresponding list collection type.
|
||||
|
||||
===========
|
||||
Description
|
||||
===========
|
||||
|
||||
This tool takes a sample sheet collection and produces a regular list collection, removing all sample sheet metadata (column definitions and row values).
|
||||
|
||||
The conversion follows this mapping:
|
||||
|
||||
- ``sample_sheet`` becomes ``list``
|
||||
- ``sample_sheet:paired`` becomes ``list:paired``
|
||||
- ``sample_sheet:paired_or_unpaired`` becomes ``list:paired_or_unpaired``
|
||||
|
||||
Use this tool when you need to pass a sample sheet to a tool that expects a regular list collection, or when you want to discard the sample sheet metadata.
|
||||
|
||||
----
|
||||
|
||||
.. class:: infomark
|
||||
|
||||
@QUOTA_USAGE_NOTE@
|
||||
|
||||
]]></help>
|
||||
</tool>
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -338,6 +338,7 @@
|
||||
<tool file="${model_tools_path}/build_list.xml" />
|
||||
<tool file="${model_tools_path}/build_list_1.2.0.xml" />
|
||||
<tool file="${model_tools_path}/sample_sheet_to_tabular.xml" />
|
||||
<tool file="${model_tools_path}/convert_sample_sheet.xml" />
|
||||
<tool file="${model_tools_path}/extract_dataset.xml" />
|
||||
<tool file="${model_tools_path}/duplicate_file_to_collection.xml" />
|
||||
<tool file="${model_tools_path}/split_paired_and_unpaired.xml" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user