Skip to content

Commit fc53dfb

Browse files
committed
Merge branch 'release_25.1' into dev
2 parents db9565a + 29b4d13 commit fc53dfb

File tree

12 files changed

+473
-39
lines changed

12 files changed

+473
-39
lines changed

client/src/components/User/Credentials/ServiceCredentialsGroupsList.vue

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* <ServiceCredentialsGroupsList :service-groups="groups" />
1919
*/
2020
21-
import { faKey, faPencilAlt, faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
21+
import { faExclamationTriangle, faKey, faPencilAlt, faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
2222
import { BModal } from "bootstrap-vue";
2323
import { faCheck } from "font-awesome-6";
2424
import { storeToRefs } from "pinia";
@@ -71,7 +71,7 @@ const props = defineProps<Props>();
7171
7272
const { confirm } = useConfirmDialog();
7373
74-
const { getToolNameById } = useToolStore();
74+
const { getToolForId, getToolNameById } = useToolStore();
7575
7676
const userToolsServiceCredentialsStore = useUserToolsServiceCredentialsStore();
7777
const { userToolsServicesCurrentGroupIds } = storeToRefs(userToolsServiceCredentialsStore);
@@ -95,12 +95,25 @@ const cardTitle = computed(() => (group: ServiceCredentialsGroupDetails) => {
9595
return `${group.serviceDefinition.name} (v${group.serviceDefinition.version}) - ${group.name}`;
9696
});
9797
98+
/**
99+
* Checks if the source tool for a credential group is missing/deleted.
100+
* @param {ServiceCredentialsGroupDetails} group - The credential group to check.
101+
* @returns {boolean} True if the tool is no longer available.
102+
*/
103+
const isToolMissing = computed(() => (group: ServiceCredentialsGroupDetails) => {
104+
return !getToolForId(group.sourceId);
105+
});
106+
98107
/**
99108
* Checks if a credential group is currently in use by any tool.
100109
* @param {ServiceCredentialsGroupDetails} group - The credential group to check.
101110
* @returns {boolean} True if the group is in use.
102111
*/
103112
const isGroupInUse = computed(() => (group: ServiceCredentialsGroupDetails) => {
113+
if (isToolMissing.value(group)) {
114+
return false;
115+
}
116+
104117
const userToolKey = userToolsServiceCredentialsStore.getUserToolKey(group.sourceId, group.sourceVersion);
105118
const userToolService = userToolsServicesCurrentGroupIds.value[userToolKey];
106119
for (const groupId of Object.values(userToolService || {})) {
@@ -111,6 +124,18 @@ const isGroupInUse = computed(() => (group: ServiceCredentialsGroupDetails) => {
111124
return false;
112125
});
113126
127+
/**
128+
* Gets the display name for a tool, with a fallback for missing/deleted tools.
129+
* @param {ServiceCredentialsGroupDetails} group - The credential group.
130+
* @returns {string} The tool name or a fallback indicator.
131+
*/
132+
const getToolDisplayName = computed(() => (group: ServiceCredentialsGroupDetails) => {
133+
if (isToolMissing.value(group)) {
134+
return `${group.sourceId} (deleted)`;
135+
}
136+
return getToolNameById(group.sourceId);
137+
});
138+
114139
/**
115140
* Deletes a credential group after user confirmation.
116141
* @param {ServiceCredentialsGroupDetails} groupToDelete - The group to delete.
@@ -120,7 +145,9 @@ const isGroupInUse = computed(() => (group: ServiceCredentialsGroupDetails) => {
120145
async function deleteGroup(groupToDelete: ServiceCredentialsGroupDetails): Promise<void> {
121146
let message = `Are you sure you want to delete the credentials group "${groupToDelete.name}"?`;
122147
123-
if (isGroupInUse.value(groupToDelete)) {
148+
if (isToolMissing.value(groupToDelete)) {
149+
message = message.concat(` The associated tool is no longer available.`);
150+
} else if (isGroupInUse.value(groupToDelete)) {
124151
message = message.concat(` This group is currently in use by '${getToolNameById(groupToDelete.sourceId)}'.`);
125152
}
126153
@@ -221,23 +248,40 @@ async function onSaveChanges(): Promise<void> {
221248
* @returns {CardBadge[]} Array of badge configurations.
222249
*/
223250
function getBadgesFor(group: ServiceCredentialsGroupDetails): CardBadge[] {
224-
const badges: CardBadge[] = [
225-
{
226-
id: `tool-${group.sourceId}`,
227-
icon: faWrench,
228-
title: "This tool is using this credentials group. Click to view.",
229-
label: getToolNameById(group.sourceId),
230-
to: `/root?tool_id=${group.sourceId}&tool_version=${group.sourceVersion}`,
231-
},
232-
{
251+
const toolMissing = isToolMissing.value(group);
252+
const badges: CardBadge[] = [];
253+
254+
if (toolMissing) {
255+
badges.push({
256+
id: `tool-missing-${group.id}`,
257+
icon: faExclamationTriangle,
258+
title: "The tool associated with these credentials is no longer available. You cannot edit or use this group.",
259+
label: "Tool Unavailable",
260+
variant: "warning",
261+
});
262+
}
263+
264+
badges.push({
265+
id: `tool-${group.sourceId}`,
266+
icon: faWrench,
267+
title: toolMissing
268+
? "This tool is no longer available."
269+
: "This tool is using this credentials group. Click to view.",
270+
label: getToolDisplayName.value(group),
271+
to: toolMissing ? undefined : `/root?tool_id=${group.sourceId}&tool_version=${group.sourceVersion}`,
272+
});
273+
274+
if (!toolMissing) {
275+
badges.push({
233276
id: `in-use-${group.id}`,
234277
icon: faCheck,
235278
title: "This group is currently in use.",
236279
label: "In Use",
237280
variant: "success",
238281
visible: isGroupInUse.value(group),
239-
},
240-
];
282+
});
283+
}
284+
241285
return badges;
242286
}
243287
@@ -247,6 +291,7 @@ function getBadgesFor(group: ServiceCredentialsGroupDetails): CardBadge[] {
247291
* @returns {CardAction[]} Array of action configurations
248292
*/
249293
function getPrimaryActions(group: ServiceCredentialsGroupDetails): CardAction[] {
294+
const toolMissing = isToolMissing.value(group);
250295
const primaryActions: CardAction[] = [
251296
{
252297
id: `delete-${group.id}`,
@@ -259,10 +304,11 @@ function getPrimaryActions(group: ServiceCredentialsGroupDetails): CardAction[]
259304
{
260305
id: `edit-${group.id}`,
261306
label: "Edit",
262-
title: "Edit this group",
307+
title: !toolMissing ? "Cannot edit - tool definition not available" : "Edit this group",
263308
icon: faPencilAlt,
264309
variant: "outline-info",
265310
handler: () => editGroup(group),
311+
disabled: toolMissing,
266312
},
267313
];
268314
return primaryActions;

lib/galaxy/config/sample/tool_conf.xml.sample

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
<tool file="${model_tools_path}/build_list.xml" />
5454
<tool file="${model_tools_path}/build_list_1.2.0.xml" />
5555
<tool file="${model_tools_path}/sample_sheet_to_tabular.xml" />
56+
<tool file="${model_tools_path}/convert_sample_sheet.xml" />
5657
<tool file="${model_tools_path}/extract_dataset.xml" />
5758
<tool file="${model_tools_path}/duplicate_file_to_collection.xml" />
5859
</section>

lib/galaxy/files/sources/_rdm.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@
2323

2424

2525
class RDMFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration):
26-
token: Union[str, TemplateExpansion]
27-
public_name: Union[str, TemplateExpansion]
26+
token: Optional[Union[str, TemplateExpansion]] = None
27+
public_name: Optional[Union[str, TemplateExpansion]] = None
2828

2929

3030
class RDMFileSourceConfiguration(BaseFileSourceConfiguration):
31-
token: str
32-
public_name: str
31+
token: Optional[str] = None
32+
public_name: Optional[str] = None
3333

3434

3535
class ContainerAndFileIdentifier(NamedTuple):
@@ -51,7 +51,7 @@ class RDMRepositoryInteractor:
5151
"""
5252

5353
def __init__(self, repository_url: str, plugin: "RDMFilesSource"):
54-
self._repository_url = repository_url
54+
self._repository_url = self._strip_last_slash(repository_url)
5555
self._plugin = plugin
5656

5757
@property
@@ -138,6 +138,12 @@ def download_file_from_container(
138138
"""
139139
raise NotImplementedError()
140140

141+
def _strip_last_slash(self, url: str) -> str:
142+
"""Utility method to strip the last slash from a URL if present."""
143+
if url.endswith("/"):
144+
return url[:-1]
145+
return url
146+
141147

142148
class RDMFilesSource(BaseFilesSource[RDMFileSourceTemplateConfiguration, RDMFileSourceConfiguration]):
143149
"""Base class for Research Data Management (RDM) file sources.

lib/galaxy/files/sources/dataverse.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from galaxy.exceptions import (
1515
AuthenticationRequired,
16+
MessageException,
1617
ObjectNotFound,
1718
)
1819
from galaxy.files.models import (
@@ -105,6 +106,7 @@ def parse_path(self, source_path: str, container_id_only: bool = False) -> Conta
105106
- doi:10.70122/FK2/AVNCLL (persistent ID)
106107
- doi:10.70122/FK2/DIG2DG/AVNCLL (persistent ID)
107108
- doi:10.70122/FK2/DIG2DG/id:12345 (database ID)
109+
- doi:10.5072/FK2/doi:10.70122/AVNCLL (persistent ID)
108110
- perma:BSC/3ST00L/id:9056 (database ID)
109111
"""
110112
if not source_path.startswith("/"):
@@ -125,20 +127,54 @@ def parse_path(self, source_path: str, container_id_only: bool = False) -> Conta
125127
f"Invalid source path: '{source_path}'. Expected format: '/<dataset_id>/<file_identifier>'."
126128
)
127129

128-
file_id_part = parts[-1]
129-
dataset_id = "/".join(parts[:-1])
130+
dataset_id, file_id_part = self._split_dataset_and_file_pid(parts)
130131

131132
# The file identifier can be either:
132133
# - A persistent ID suffix (e.g., 'AVNCLL' -> full ID is 'doi:10.70122/FK2/DIG2DG/AVNCLL')
133134
# - A database ID with 'id:' prefix (e.g., 'id:12345' -> file_identifier is 'id:12345')
134135
if file_id_part.startswith("id:"):
135136
# Database ID format - keep the 'id:' prefix as the file identifier
136137
file_id = file_id_part
138+
elif re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:.*", file_id_part):
139+
# Full persistent identifier (e.g. doi:, hdl:, ark:, or custom PID providers).
140+
# Files in Dataverse may have their own independent persistent IDs that are
141+
# not hierarchically related to the dataset persistent ID.
142+
file_id = file_id_part
137143
else:
138-
# Persistent ID format - construct full persistent ID
144+
# Dataset-scoped persistent ID suffix - construct full persistent ID
139145
file_id = f"{dataset_id}/{file_id_part}"
140146
return ContainerAndFileIdentifier(container_id=dataset_id, file_identifier=file_id)
141147

148+
@staticmethod
149+
def _split_dataset_and_file_pid(parts: list[str]) -> tuple[str, str]:
150+
"""
151+
Split a Dataverse source path into dataset ID and file identifier parts.
152+
153+
Dataverse file-level persistent IDs may themselves contain slashes and are not
154+
necessarily hierarchically related to the dataset persistent ID. For example:
155+
156+
/doi:10.57745/I8EUTL/doi:10.57745/L7SOAJ
157+
158+
In this case:
159+
dataset_id = doi:10.57745/I8EUTL
160+
file_id = doi:10.57745/L7SOAJ
161+
162+
This helper detects such cases by recognizing URI-scheme prefixes in path segments
163+
and grouping them accordingly.
164+
"""
165+
# Default: last segment is the file identifier
166+
file_id_part = parts[-1]
167+
dataset_id = "/".join(parts[:-1])
168+
169+
# Heuristic: if the penultimate segment starts a URI scheme (e.g. doi:, hdl:, ark:),
170+
# then the file persistent ID spans the last two segments.
171+
pid_scheme_re = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
172+
if len(parts) >= 3 and pid_scheme_re.match(parts[-2]):
173+
file_id_part = f"{parts[-2]}/{parts[-1]}"
174+
dataset_id = "/".join(parts[:-2])
175+
176+
return dataset_id, file_id_part
177+
142178
def get_container_id_from_path(self, source_path: str) -> str:
143179
return self.parse_path(source_path, container_id_only=True).container_id
144180

@@ -336,14 +372,14 @@ def create_draft_file_container(
336372
collection_payload = self._prepare_collection_data(title, public_name, user_email)
337373
collection = self._create_collection(":root", collection_payload, context)
338374
if not collection or "data" not in collection or "alias" not in collection["data"]:
339-
raise Exception("Could not create collection in Dataverse or response has an unexpected format.")
375+
raise MessageException("Could not create collection in Dataverse or response has an unexpected format.")
340376
collection_alias = collection["data"]["alias"]
341377

342378
# Prepare and create the dataset
343379
dataset_payload = self._prepare_dataset_data(title, public_name, user_email)
344380
dataset = self._create_dataset(collection_alias, dataset_payload, context)
345381
if not dataset or "data" not in dataset:
346-
raise Exception("Could not create dataset in Dataverse or response has an unexpected format.")
382+
raise MessageException("Could not create dataset in Dataverse or response has an unexpected format.")
347383

348384
dataset["data"]["name"] = title
349385
return dataset["data"]
@@ -421,14 +457,18 @@ def _download_file(
421457
f"Authentication required to download file from '{download_file_content_url}'. "
422458
f"Please provide a valid API token in your user preferences."
423459
)
424-
# TODO: We can only download files from published datasets for now
425-
if e.code in [403, 404]:
460+
if e.code == 403:
461+
# Permission denied: dataset may be unpublished or user lacks access rights
462+
raise ObjectNotFound(
463+
f"Access forbidden when downloading file from '{download_file_content_url}'. "
464+
f"You may not have permission to access this file, or the dataset is not published."
465+
)
466+
if e.code == 404:
426467
raise ObjectNotFound(
427468
f"File not found at '{download_file_content_url}'. "
428469
f"Please make sure the dataset and file exist and are published."
429470
)
430-
else:
431-
raise
471+
raise
432472

433473
def _get_datasets_from_response(self, response: dict) -> list[RemoteDirectory]:
434474
rval: list[RemoteDirectory] = []
@@ -494,7 +534,7 @@ def _ensure_response_has_expected_status_code(self, response, expected_status_co
494534
error_message = self._get_response_error_message(response)
495535
if response.status_code == 403:
496536
self._raise_auth_required(error_message)
497-
raise Exception(
537+
raise MessageException(
498538
f"Request to {response.url} failed with status code {response.status_code}: {error_message}"
499539
)
500540

lib/galaxy/model/dataset_collections/types/sample_sheet_workbook.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,5 +618,24 @@ def _list_to_sample_sheet_collection_type(input_collection_type: str) -> SampleS
618618
)
619619

620620

621+
def _sample_sheet_to_list_collection_type(input_collection_type: str) -> str:
622+
"""Convert sample_sheet collection types to corresponding list collection types.
623+
624+
Converts sample_sheet types to list types (e.g., sample_sheet:paired -> list:paired).
625+
"""
626+
if input_collection_type == "sample_sheet":
627+
return "list"
628+
elif input_collection_type == "sample_sheet:paired":
629+
return "list:paired"
630+
elif input_collection_type == "sample_sheet:paired_or_unpaired":
631+
return "list:paired_or_unpaired"
632+
elif input_collection_type == "sample_sheet:record":
633+
return "list:record"
634+
else:
635+
raise RequestParameterInvalidException(
636+
f"Invalid collection type for sample sheet conversion: {input_collection_type}"
637+
)
638+
639+
621640
def _prefix_column_to_column_target(column_header: FetchPrefixColumn) -> ColumnTarget:
622641
return target_model_by_type(column_header.type)

0 commit comments

Comments
 (0)