From 20face60a1b8788e73b6df79a231596ac227b2fd Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 7 Jul 2023 11:47:42 +0200 Subject: [PATCH 01/41] Add basic Invenio file source plugin Currently only basic support for listing records and downloading files in public records. --- lib/galaxy/files/sources/invenio.py | 143 ++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 lib/galaxy/files/sources/invenio.py diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py new file mode 100644 index 000000000000..64e4ca597756 --- /dev/null +++ b/lib/galaxy/files/sources/invenio.py @@ -0,0 +1,143 @@ +import ssl +import urllib.request +from typing import ( + cast, + Optional, +) +from urllib.parse import urljoin + +import requests +from typing_extensions import Unpack + +from galaxy.files.sources import ( + BaseFilesSource, + FilesSourceOptions, + FilesSourceProperties, +) +from galaxy.util import ( + DEFAULT_SOCKET_TIMEOUT, + get_charset_from_http_headers, + stream_to_open_named_file, +) + +# TODO: Remove this block. Ignoring SSL errors for testing purposes. +VERIFY = False +SSL_CONTEXT = ssl.create_default_context() +SSL_CONTEXT.check_hostname = False +SSL_CONTEXT.verify_mode = ssl.CERT_NONE + + +class InvenioFilesSourceProperties(FilesSourceProperties): + url: str + + +class InvenioFilesSource(BaseFilesSource): + """A files source for Invenio turn-key research data management repository.""" + + plugin_type = "inveniordm" + + def __init__(self, **kwd: Unpack[InvenioFilesSourceProperties]): + props = self._parse_common_config_opts(kwd) + base_url = props.get("url", None) + if not base_url: + raise Exception("InvenioFilesSource requires a url") + self._invenio_url = base_url + self._props = props + + def _list(self, path="/", recursive=True, user_context=None, opts: Optional[FilesSourceOptions] = None): + is_listing_records = path == "/" + if is_listing_records: + # TODO: This is limited to 25 records by default. We should add pagination support. + request_url = urljoin(self._invenio_url, "api/records") + else: + # listing a record's files + request_url = urljoin(self._invenio_url, f"{path}/files") + + rval = [] + headers = self._get_request_headers(user_context) + response = requests.get(request_url, headers=headers, verify=VERIFY) + if response.status_code == 200: + response_json = response.json() + if is_listing_records: + rval = self._get_records_from_response(path, response_json) + else: + rval = self._get_record_files_from_response(path, response_json) + else: + raise Exception(f"Request to {request_url} failed with status code {response.status_code}") + return rval + + def _get_request_headers(self, user_context): + preferences = user_context.preferences if user_context else None + token = preferences.get(f"{self.id}|token", None) if preferences else None + headers = {"Authorization": f"Bearer {token}"} if token else {} + return headers + + def _get_records_from_response(self, path: str, response: dict): + records = response["hits"]["hits"] + rval = [] + for record in records: + uri = self._to_plugin_uri(record["links"]["self"]) + rval.append( + { + "class": "Directory", + "name": record["metadata"]["title"], + "ctime": record["created"], + "uri": uri, + "path": path, + } + ) + + return rval + + def _get_record_files_from_response(self, path: str, response: dict): + files_enabled = response.get("enabled", False) + if not files_enabled: + return [] + entries = response["entries"] + rval = [] + for entry in entries: + if entry.get("status") == "completed": + uri = self._to_plugin_uri(entry["links"]["content"]) + rval.append( + { + "class": "File", + "name": entry["key"], + "size": entry["size"], + "ctime": entry["created"], + "uri": uri, + "path": path, + } + ) + return rval + + def _to_plugin_uri(self, uri: str) -> str: + return uri.replace(self._invenio_url, self.get_uri_root()) + + def _realize_to( + self, source_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None + ): + remote_path = urljoin(self._invenio_url, source_path) + # TODO: user_context is always None here when called from a data fetch. + # This prevents downloading files that require authentication even if the user provided a token. + headers = self._get_request_headers(user_context) + req = urllib.request.Request(remote_path, headers=headers) + with urllib.request.urlopen(req, timeout=DEFAULT_SOCKET_TIMEOUT, context=SSL_CONTEXT) as page: + f = open(native_path, "wb") + return stream_to_open_named_file( + page, f.fileno(), native_path, source_encoding=get_charset_from_http_headers(page.headers) + ) + + def _write_from( + self, target_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None + ): + raise NotImplementedError() + + def _serialization_props(self, user_context=None) -> InvenioFilesSourceProperties: + effective_props = {} + for key, val in self._props.items(): + effective_props[key] = self._evaluate_prop(val, user_context=user_context) + effective_props["url"] = self._invenio_url + return cast(InvenioFilesSourceProperties, effective_props) + + +__all__ = ("InvenioFilesSource",) From e06e295f56ecf0b1c9e23e391c3d3e11b523601b Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 7 Jul 2023 13:31:14 +0200 Subject: [PATCH 02/41] Add Invenio RDM plugin configuration sample --- lib/galaxy/config/sample/file_sources_conf.yml.sample | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/galaxy/config/sample/file_sources_conf.yml.sample b/lib/galaxy/config/sample/file_sources_conf.yml.sample index f7ab5e7b40e5..e8fdf524b5d9 100644 --- a/lib/galaxy/config/sample/file_sources_conf.yml.sample +++ b/lib/galaxy/config/sample/file_sources_conf.yml.sample @@ -191,3 +191,9 @@ label: Stock DRS filesource id: drsstock doc: Make sure to define this generic drs file source if you have defined any other drs file sources, or stock drs download capability will be disabled. + +- type: inveniordm + id: invenio + doc: Invenio RDM turn-key research data management repository + label: Invenio RDM Demo Repository + url: https://inveniordm.web.cern.ch/ From 6b4d9e783433ab127a7b237968dcf3d32af4360e Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 10 Jul 2023 16:05:12 +0200 Subject: [PATCH 03/41] Explore record publishing This has a severe limitation. We can export a record with files (without much metadata) but we cannot reference it back directly form an export URI. The reason is how we construct the URI for regular file sources, which obviously are just file paths, while with DOI repositories, we need to differentiate between records and files only. --- lib/galaxy/files/sources/invenio.py | 268 ++++++++++++++++++++++++---- 1 file changed, 229 insertions(+), 39 deletions(-) diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index 64e4ca597756..42cd737a0c08 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -1,13 +1,21 @@ +import datetime +import json +import os import ssl import urllib.request from typing import ( cast, + List, Optional, ) from urllib.parse import urljoin import requests -from typing_extensions import Unpack +from typing_extensions import ( + Literal, + TypedDict, + Unpack, +) from galaxy.files.sources import ( BaseFilesSource, @@ -27,10 +35,82 @@ SSL_CONTEXT.verify_mode = ssl.CERT_NONE +AccessStatus = Literal["public", "restricted"] + + class InvenioFilesSourceProperties(FilesSourceProperties): url: str +class ResourceType(TypedDict): + id: str + + +class RecordAccess(TypedDict): + record: AccessStatus + files: AccessStatus + + +class RecordFiles(TypedDict): + enabled: bool + + +class IdentifierEntry(TypedDict): + scheme: str + identifier: str + + +class AffiliationEntry(TypedDict): + id: str + name: str + + +class RecordPersonOrOrg(TypedDict): + family_name: str + given_name: str + type: Literal["personal", "organizational"] + name: str + identifiers: List[IdentifierEntry] + + +class Creator(TypedDict): + person_or_org: RecordPersonOrOrg + affiliations: Optional[List[AffiliationEntry]] + + +class RecordMetadata(TypedDict): + title: str + resource_type: ResourceType + publication_date: str + creators: List[Creator] + + +class RecordLinks(TypedDict): + self: str + self_html: str + self_iiif_manifest: str + self_iiif_sequence: str + files: str + record: str + record_html: str + publish: str + review: str + versions: str + access_links: str + reserve_doi: str + + +class InvenioRecord(TypedDict): + id: str + title: str + resource_type: ResourceType + publication_date: str + access: RecordAccess + files: RecordFiles + metadata: RecordMetadata + links: RecordLinks + + class InvenioFilesSource(BaseFilesSource): """A files source for Invenio turn-key research data management repository.""" @@ -45,26 +125,56 @@ def __init__(self, **kwd: Unpack[InvenioFilesSourceProperties]): self._props = props def _list(self, path="/", recursive=True, user_context=None, opts: Optional[FilesSourceOptions] = None): - is_listing_records = path == "/" - if is_listing_records: - # TODO: This is limited to 25 records by default. We should add pagination support. - request_url = urljoin(self._invenio_url, "api/records") - else: - # listing a record's files - request_url = urljoin(self._invenio_url, f"{path}/files") + is_root_path = path == "/" + if is_root_path: + return self._list_records(user_context) + return self._list_record_files(path, user_context) - rval = [] + def _realize_to( + self, source_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None + ): + # TODO: source_path will be wrong when constructed from the UI as it assumes the target_uri is `get_root_uri() + filename` + + remote_path = urljoin(self._invenio_url, source_path) + # TODO: user_context is always None here when called from a data fetch. + # This prevents downloading files that require authentication even if the user provided a token. + headers = self._get_request_headers(user_context) + req = urllib.request.Request(remote_path, headers=headers) + with urllib.request.urlopen(req, timeout=DEFAULT_SOCKET_TIMEOUT, context=SSL_CONTEXT) as page: + f = open(native_path, "wb") + return stream_to_open_named_file( + page, f.fileno(), native_path, source_encoding=get_charset_from_http_headers(page.headers) + ) + + def _write_from( + self, target_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None + ): + filename = os.path.basename(target_path) + record_title = f"{filename} (exported by Galaxy)" + draft_record = self._create_draft_record(title=record_title, user_context=user_context) + try: + self._upload_file_to_draft_record(draft_record, filename, native_path, user_context=user_context) + self._publish_draft_record(draft_record, user_context=user_context) + except Exception: + self._delete_draft_record(draft_record, user_context) + raise + + def _list_records(self, user_context=None): + # TODO: This is limited to 25 records by default. Add pagination support? + request_url = urljoin(self._invenio_url, "api/records") + response_data = self._get_response(user_context, request_url) + return self._get_records_from_response(response_data) + + def _list_record_files(self, path, user_context=None): + request_url = urljoin(self._invenio_url, f"{path}/files") + response_data = self._get_response(user_context, request_url) + return self._get_record_files_from_response(path, response_data) + + def _get_response(self, user_context, request_url: str) -> dict: headers = self._get_request_headers(user_context) response = requests.get(request_url, headers=headers, verify=VERIFY) - if response.status_code == 200: - response_json = response.json() - if is_listing_records: - rval = self._get_records_from_response(path, response_json) - else: - rval = self._get_record_files_from_response(path, response_json) - else: - raise Exception(f"Request to {request_url} failed with status code {response.status_code}") - return rval + self._ensure_response_has_expected_status_code(response, 200) + return response.json() def _get_request_headers(self, user_context): preferences = user_context.preferences if user_context else None @@ -72,7 +182,7 @@ def _get_request_headers(self, user_context): headers = {"Authorization": f"Bearer {token}"} if token else {} return headers - def _get_records_from_response(self, path: str, response: dict): + def _get_records_from_response(self, response: dict): records = response["hits"]["hits"] rval = [] for record in records: @@ -83,7 +193,7 @@ def _get_records_from_response(self, path: str, response: dict): "name": record["metadata"]["title"], "ctime": record["created"], "uri": uri, - "path": path, + "path": f"/{record['id']}", } ) @@ -98,6 +208,7 @@ def _get_record_files_from_response(self, path: str, response: dict): for entry in entries: if entry.get("status") == "completed": uri = self._to_plugin_uri(entry["links"]["content"]) + path = self._to_plugin_uri(entry["links"]["self"]) rval.append( { "class": "File", @@ -113,25 +224,6 @@ def _get_record_files_from_response(self, path: str, response: dict): def _to_plugin_uri(self, uri: str) -> str: return uri.replace(self._invenio_url, self.get_uri_root()) - def _realize_to( - self, source_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None - ): - remote_path = urljoin(self._invenio_url, source_path) - # TODO: user_context is always None here when called from a data fetch. - # This prevents downloading files that require authentication even if the user provided a token. - headers = self._get_request_headers(user_context) - req = urllib.request.Request(remote_path, headers=headers) - with urllib.request.urlopen(req, timeout=DEFAULT_SOCKET_TIMEOUT, context=SSL_CONTEXT) as page: - f = open(native_path, "wb") - return stream_to_open_named_file( - page, f.fileno(), native_path, source_encoding=get_charset_from_http_headers(page.headers) - ) - - def _write_from( - self, target_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None - ): - raise NotImplementedError() - def _serialization_props(self, user_context=None) -> InvenioFilesSourceProperties: effective_props = {} for key, val in self._props.items(): @@ -139,5 +231,103 @@ def _serialization_props(self, user_context=None) -> InvenioFilesSourcePropertie effective_props["url"] = self._invenio_url return cast(InvenioFilesSourceProperties, effective_props) + def _create_draft_record(self, title: str, user_context=None) -> InvenioRecord: + today = datetime.date.today().isoformat() + creator = self._get_creator_from_user_context(user_context) + should_publish = self._get_public_records_user_setting_enabled_status(user_context) + access = "public" if should_publish else "restricted" + create_record_request = { + "access": {"record": access, "files": access}, + "files": {"enabled": True}, + "metadata": { + "title": title, + "publication_date": today, + "resource_type": {"id": "dataset"}, + "creators": [ + creator, + ], + }, + } + + headers = self._get_request_headers(user_context) + if "Authorization" not in headers: + raise Exception( + "Cannot create record without authentication token. Please set your personal access token in your Galaxy preferences." + ) + + create_record_url = urljoin(self._invenio_url, "api/records") + response = requests.post(create_record_url, json=create_record_request, headers=headers, verify=VERIFY) + self._ensure_response_has_expected_status_code(response, 201) + record = response.json() + return record + + def _delete_draft_record(self, record: InvenioRecord, user_context=None): + delete_record_url = record["links"]["self"] + headers = self._get_request_headers(user_context) + response = requests.delete(delete_record_url, headers=headers, verify=VERIFY) + self._ensure_response_has_expected_status_code(response, 204) + + def _upload_file_to_draft_record(self, record: InvenioRecord, filename: str, native_path: str, user_context=None): + upload_file_url = urljoin(self._invenio_url, f"api/records/{record['id']}/draft/files") + headers = self._get_request_headers(user_context) + + # Add file metadata + response = requests.post(upload_file_url, json=[{"key": filename}], headers=headers, verify=VERIFY) + self._ensure_response_has_expected_status_code(response, 201) + + # Upload file content + file_entry = response.json()["entries"][0] + upload_file_content_url = file_entry["links"]["content"] + commit_file_upload_url = file_entry["links"]["commit"] + with open(native_path, "rb") as file: + response = requests.put(upload_file_content_url, data=file, headers=headers, verify=VERIFY) + self._ensure_response_has_expected_status_code(response, 200) + + # Commit file upload + response = requests.post(commit_file_upload_url, headers=headers, verify=VERIFY) + self._ensure_response_has_expected_status_code(response, 200) + + def _publish_draft_record(self, record: InvenioRecord, user_context=None): + publish_record_url = urljoin(self._invenio_url, f"api/records/{record['id']}/draft/actions/publish") + headers = self._get_request_headers(user_context) + response = requests.post(publish_record_url, headers=headers, verify=VERIFY) + self._ensure_response_has_expected_status_code(response, 202) + + def _get_creator_from_user_context(self, user_context): + preferences = user_context.preferences if user_context else None + public_name = preferences.get(f"{self.id}|public_name", None) if preferences else None + family_name = "Galaxy User" + given_name = "Anonymous" + if public_name: + tokens = public_name.split(", ") + if len(tokens) == 2: + family_name = tokens[0] + given_name = tokens[1] + else: + given_name = public_name + return {"person_or_org": {"family_name": family_name, "given_name": given_name, "type": "personal"}} + + def _get_public_records_user_setting_enabled_status(self, user_context) -> bool: + preferences = user_context.preferences if user_context else None + public_records = preferences.get(f"{self.id}|public_records", None) if preferences else None + if public_records: + return True + return False + + def _ensure_response_has_expected_status_code(self, response, expected_status_code: int): + if response.status_code != expected_status_code: + error_message = self._get_response_error_message(response) + raise Exception( + f"Request to {response.url} failed with status code {response.status_code}: {error_message}" + ) + + def _get_response_error_message(self, response): + response_json = response.json() + error_message = response_json.get("message") if response.status_code == 400 else response.text + errors = response_json.get("errors", []) + for error in errors: + error_message += f"\n{json.dumps(error)}" + return error_message + __all__ = ("InvenioFilesSource",) From 333b251efe93f1a2353a873a4bd2459ec45d4d62 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 11 Jul 2023 14:26:04 +0200 Subject: [PATCH 04/41] Use vault secret to store personal token Also add example config to user_preferences_extra_conf.yml.sample --- .../user_preferences_extra_conf.yml.sample | 17 +++++ lib/galaxy/files/sources/invenio.py | 63 ++++++++++++++----- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/lib/galaxy/config/sample/user_preferences_extra_conf.yml.sample b/lib/galaxy/config/sample/user_preferences_extra_conf.yml.sample index d0280966e40e..17c7fbf41572 100644 --- a/lib/galaxy/config/sample/user_preferences_extra_conf.yml.sample +++ b/lib/galaxy/config/sample/user_preferences_extra_conf.yml.sample @@ -94,3 +94,20 @@ preferences: label: Password type: password required: False + + invenio: + description: Your Invenio RDM Account + inputs: + - name: token + label: Personal Token to publish records to Invenio RDM + type: secret + store: vault # Requires setting up vault_config_file in your galaxy.yml + required: False + - name: public_name + label: Public name to publish records (formatted as "Lastname, Firstname") + type: text + required: False + - name: public_records + label: Whether to publish records (file exports) or make them restricted. Only public records can be imported back. + type: boolean + required: False diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index 42cd737a0c08..1aa70ed0c6af 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -17,6 +17,7 @@ Unpack, ) +from galaxy.files import ProvidesUserFileSourcesUserContext from galaxy.files.sources import ( BaseFilesSource, FilesSourceOptions, @@ -124,14 +125,24 @@ def __init__(self, **kwd: Unpack[InvenioFilesSourceProperties]): self._invenio_url = base_url self._props = props - def _list(self, path="/", recursive=True, user_context=None, opts: Optional[FilesSourceOptions] = None): + def _list( + self, + path="/", + recursive=True, + user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + opts: Optional[FilesSourceOptions] = None, + ): is_root_path = path == "/" if is_root_path: return self._list_records(user_context) return self._list_record_files(path, user_context) def _realize_to( - self, source_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None + self, + source_path: str, + native_path: str, + user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + opts: Optional[FilesSourceOptions] = None, ): # TODO: source_path will be wrong when constructed from the UI as it assumes the target_uri is `get_root_uri() + filename` @@ -147,7 +158,11 @@ def _realize_to( ) def _write_from( - self, target_path: str, native_path: str, user_context=None, opts: Optional[FilesSourceOptions] = None + self, + target_path: str, + native_path: str, + user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + opts: Optional[FilesSourceOptions] = None, ): filename = os.path.basename(target_path) record_title = f"{filename} (exported by Galaxy)" @@ -159,26 +174,26 @@ def _write_from( self._delete_draft_record(draft_record, user_context) raise - def _list_records(self, user_context=None): + def _list_records(self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): # TODO: This is limited to 25 records by default. Add pagination support? request_url = urljoin(self._invenio_url, "api/records") response_data = self._get_response(user_context, request_url) return self._get_records_from_response(response_data) - def _list_record_files(self, path, user_context=None): + def _list_record_files(self, path, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): request_url = urljoin(self._invenio_url, f"{path}/files") response_data = self._get_response(user_context, request_url) return self._get_record_files_from_response(path, response_data) - def _get_response(self, user_context, request_url: str) -> dict: + def _get_response(self, user_context: Optional[ProvidesUserFileSourcesUserContext], request_url: str) -> dict: headers = self._get_request_headers(user_context) response = requests.get(request_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 200) return response.json() - def _get_request_headers(self, user_context): - preferences = user_context.preferences if user_context else None - token = preferences.get(f"{self.id}|token", None) if preferences else None + def _get_request_headers(self, user_context: Optional[ProvidesUserFileSourcesUserContext]): + vault = user_context.user_vault if user_context else None + token = vault.read_secret(f"preferences/{self.id}/token") if vault else None headers = {"Authorization": f"Bearer {token}"} if token else {} return headers @@ -224,14 +239,18 @@ def _get_record_files_from_response(self, path: str, response: dict): def _to_plugin_uri(self, uri: str) -> str: return uri.replace(self._invenio_url, self.get_uri_root()) - def _serialization_props(self, user_context=None) -> InvenioFilesSourceProperties: + def _serialization_props( + self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None + ) -> InvenioFilesSourceProperties: effective_props = {} for key, val in self._props.items(): effective_props[key] = self._evaluate_prop(val, user_context=user_context) effective_props["url"] = self._invenio_url return cast(InvenioFilesSourceProperties, effective_props) - def _create_draft_record(self, title: str, user_context=None) -> InvenioRecord: + def _create_draft_record( + self, title: str, user_context: Optional[ProvidesUserFileSourcesUserContext] = None + ) -> InvenioRecord: today = datetime.date.today().isoformat() creator = self._get_creator_from_user_context(user_context) should_publish = self._get_public_records_user_setting_enabled_status(user_context) @@ -261,13 +280,21 @@ def _create_draft_record(self, title: str, user_context=None) -> InvenioRecord: record = response.json() return record - def _delete_draft_record(self, record: InvenioRecord, user_context=None): + def _delete_draft_record( + self, record: InvenioRecord, user_context: Optional[ProvidesUserFileSourcesUserContext] = None + ): delete_record_url = record["links"]["self"] headers = self._get_request_headers(user_context) response = requests.delete(delete_record_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 204) - def _upload_file_to_draft_record(self, record: InvenioRecord, filename: str, native_path: str, user_context=None): + def _upload_file_to_draft_record( + self, + record: InvenioRecord, + filename: str, + native_path: str, + user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + ): upload_file_url = urljoin(self._invenio_url, f"api/records/{record['id']}/draft/files") headers = self._get_request_headers(user_context) @@ -287,13 +314,15 @@ def _upload_file_to_draft_record(self, record: InvenioRecord, filename: str, nat response = requests.post(commit_file_upload_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 200) - def _publish_draft_record(self, record: InvenioRecord, user_context=None): + def _publish_draft_record( + self, record: InvenioRecord, user_context: Optional[ProvidesUserFileSourcesUserContext] = None + ): publish_record_url = urljoin(self._invenio_url, f"api/records/{record['id']}/draft/actions/publish") headers = self._get_request_headers(user_context) response = requests.post(publish_record_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 202) - def _get_creator_from_user_context(self, user_context): + def _get_creator_from_user_context(self, user_context: Optional[ProvidesUserFileSourcesUserContext]): preferences = user_context.preferences if user_context else None public_name = preferences.get(f"{self.id}|public_name", None) if preferences else None family_name = "Galaxy User" @@ -307,7 +336,9 @@ def _get_creator_from_user_context(self, user_context): given_name = public_name return {"person_or_org": {"family_name": family_name, "given_name": given_name, "type": "personal"}} - def _get_public_records_user_setting_enabled_status(self, user_context) -> bool: + def _get_public_records_user_setting_enabled_status( + self, user_context: Optional[ProvidesUserFileSourcesUserContext] + ) -> bool: preferences = user_context.preferences if user_context else None public_records = preferences.get(f"{self.id}|public_records", None) if preferences else None if public_records: From 6fb9349541c36263883114ec2701e3bcbafa7c23 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 16:28:37 +0200 Subject: [PATCH 05/41] Add write intent option to File Sources This options helps to identify when we are browsing a file source with the "intent to write" to it. This is helpful to avoid listing those elements (directories/records) that might not be writable (protected, read-only, etc.) even if the file source itself is writable. The plugin implementation should handle this option accordingly if necessary. --- lib/galaxy/files/sources/__init__.py | 7 ++++++- lib/galaxy/managers/remote_files.py | 12 +++++++++++- lib/galaxy/webapps/galaxy/api/remote_files.py | 12 +++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index d369fcfe4293..272547bc26f8 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -53,7 +53,12 @@ class FilesSourceProperties(TypedDict): class FilesSourceOptions: - """Options to control behaviour of filesource operations, such as realize_to and write_from""" + """Options to control behavior of file source operations, such as realize_to, write_from and list.""" + + # Indicates access to the FS operation with intent to write. + # A file source can be "writeable" but, for example, some directories (or elements) may be restricted or read-only + # so those should be skipped while browsing with write_intent=True. + write_intent: Optional[bool] # Property overrides for values initially configured through the constructor. For example # the HTTPFilesSource passes in additional http_headers through these properties, which diff --git a/lib/galaxy/managers/remote_files.py b/lib/galaxy/managers/remote_files.py index fd577e546b69..fb589880133f 100644 --- a/lib/galaxy/managers/remote_files.py +++ b/lib/galaxy/managers/remote_files.py @@ -8,6 +8,7 @@ ConfiguredFileSources, ProvidesUserFileSourcesUserContext, ) +from galaxy.files.sources import FilesSourceOptions from galaxy.managers.context import ProvidesUserContext from galaxy.schema.remote_files import ( AnyRemoteFilesListResponse, @@ -41,6 +42,7 @@ def index( format: Optional[RemoteFilesFormat], recursive: Optional[bool], disable: Optional[RemoteFilesDisableMode], + write_intent: Optional[bool] = False, ) -> AnyRemoteFilesListResponse: """Returns a list of remote files available to the user.""" @@ -75,8 +77,16 @@ def index( file_source_path = self._file_sources.get_file_source_path(uri) file_source = file_source_path.file_source + + opts = FilesSourceOptions() + opts.write_intent = write_intent or False try: - index = file_source.list(file_source_path.path, recursive=recursive, user_context=user_file_source_context) + index = file_source.list( + file_source_path.path, + recursive=recursive, + user_context=user_file_source_context, + opts=opts, + ) except exceptions.MessageException: log.warning(f"Problem listing file source path {file_source_path}", exc_info=True) raise diff --git a/lib/galaxy/webapps/galaxy/api/remote_files.py b/lib/galaxy/webapps/galaxy/api/remote_files.py index 39f21c872768..84957a11018e 100644 --- a/lib/galaxy/webapps/galaxy/api/remote_files.py +++ b/lib/galaxy/webapps/galaxy/api/remote_files.py @@ -59,6 +59,15 @@ ), ) +WriteIntentQueryParam: Optional[bool] = Query( + default=None, + title="Write Intent", + description=( + "Whether the query is made with the intention of writing to the source." + " If set to True, only entries that can be written to will be accessible." + ), +) + BrowsableQueryParam: Optional[bool] = Query( default=True, title="Browsable filesources only", @@ -90,9 +99,10 @@ async def index( format: Optional[RemoteFilesFormat] = FormatQueryParam, recursive: Optional[bool] = RecursiveQueryParam, disable: Optional[RemoteFilesDisableMode] = DisableModeQueryParam, + write_intent: Optional[bool] = WriteIntentQueryParam, ) -> AnyRemoteFilesListResponse: """Lists all remote files available to the user from different sources.""" - return self.manager.index(user_ctx, target, format, recursive, disable) + return self.manager.index(user_ctx, target, format, recursive, disable, write_intent=write_intent) @router.get( "/api/remote_files/plugins", From bf15a1df556c22dd5afd21172a0f89d19f1b318e Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 16:38:44 +0200 Subject: [PATCH 06/41] Add create_entry endpoint for File sources This allows to create a new entry (directory/record) in those remote file sources that supports it. By default, the endpoint will raise NotImplementedError unless the plugin implements the _create_entry method. --- lib/galaxy/files/sources/__init__.py | 31 +++++++++++++++-- lib/galaxy/managers/remote_files.py | 21 ++++++++++++ lib/galaxy/schema/remote_files.py | 34 +++++++++++++++++++ lib/galaxy/webapps/galaxy/api/remote_files.py | 20 ++++++++++- 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index 272547bc26f8..863dfb03e3d5 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -67,6 +67,18 @@ class FilesSourceOptions: extra_props: Optional[FilesSourceProperties] +class EntryData(TypedDict): + name: str + # May contain additional properties depending on the file source + + +class Entry(TypedDict): + name: str + uri: str + # May contain additional properties depending on the file source + external_link: NotRequired[str] + + class SingleFileSource(metaclass=abc.ABCMeta): """ Represents a protocol handler for a single remote file that can be read by or written to by Galaxy. @@ -298,9 +310,20 @@ def list(self, path="/", recursive=False, user_context=None, opts: Optional[File def _list(self, path="/", recursive=False, user_context=None, opts: Optional[FilesSourceOptions] = None): pass + def create_entry( + self, entry_data: EntryData, user_context=None, opts: Optional[FilesSourceOptions] = None + ) -> Entry: + self._ensure_writeable() + self._check_user_access(user_context) + return self._create_entry(entry_data, user_context, opts) + + def _create_entry( + self, entry_data: EntryData, user_context=None, opts: Optional[FilesSourceOptions] = None + ) -> Entry: + raise NotImplementedError() + def write_from(self, target_path, native_path, user_context=None, opts: Optional[FilesSourceOptions] = None): - if not self.get_writable(): - raise Exception("Cannot write to a non-writable file source.") + self._ensure_writeable() self._check_user_access(user_context) self._write_from(target_path, native_path, user_context=user_context, opts=opts) @@ -316,6 +339,10 @@ def realize_to(self, source_path, native_path, user_context=None, opts: Optional def _realize_to(self, source_path, native_path, user_context=None, opts: Optional[FilesSourceOptions] = None): pass + def _ensure_writeable(self): + if not self.get_writable(): + raise Exception("Cannot write to a non-writable file source.") + def _check_user_access(self, user_context): """Raises an exception if the given user doesn't have the rights to access this file source. diff --git a/lib/galaxy/managers/remote_files.py b/lib/galaxy/managers/remote_files.py index fb589880133f..470ae5cb3307 100644 --- a/lib/galaxy/managers/remote_files.py +++ b/lib/galaxy/managers/remote_files.py @@ -12,6 +12,8 @@ from galaxy.managers.context import ProvidesUserContext from galaxy.schema.remote_files import ( AnyRemoteFilesListResponse, + CreatedEntryResponse, + CreateEntryPayload, FilesSourcePlugin, FilesSourcePluginList, RemoteFilesDisableMode, @@ -139,3 +141,22 @@ def get_files_source_plugins( @property def _file_sources(self) -> ConfiguredFileSources: return self._app.file_sources + + def create_entry(self, user_ctx: ProvidesUserContext, entry_data: CreateEntryPayload) -> CreatedEntryResponse: + """Create an entry (directory or record) in a remote files location.""" + target = entry_data.target + user_file_source_context = ProvidesUserFileSourcesUserContext(user_ctx) + self._file_sources.validate_uri_root(target, user_context=user_file_source_context) + file_source_path = self._file_sources.get_file_source_path(target) + file_source = file_source_path.file_source + try: + result = file_source.create_entry(entry_data.dict(), user_context=user_file_source_context) + except Exception: + message = f"Problem creating entry {entry_data.name} in file source {entry_data.target}" + log.warning(message, exc_info=True) + raise exceptions.InternalServerError(message) + return CreatedEntryResponse( + name=result["name"], + uri=result["uri"], + external_link=result.get("external_link", None), + ) diff --git a/lib/galaxy/schema/remote_files.py b/lib/galaxy/schema/remote_files.py index 66302390ea92..a408f076a9c8 100644 --- a/lib/galaxy/schema/remote_files.py +++ b/lib/galaxy/schema/remote_files.py @@ -149,3 +149,37 @@ class ListUriResponse(Model): AnyRemoteFilesListResponse = Union[ListUriResponse, ListJstreeResponse] + + +class CreateEntryPayload(Model): + target: str = Field( + Required, + title="Target", + description="The target file source to create the entry in.", + ) + name: str = Field( + Required, + title="Name", + description="The name of the entry to create.", + example="my_new_entry", + ) + + +class CreatedEntryResponse(Model): + name: str = Field( + Required, + title="Name", + description="The name of the created entry.", + example="my_new_entry", + ) + uri: str = Field( + Required, + title="URI", + description="The URI of the created entry.", + example="gxfiles://my_new_entry", + ) + external_link: Optional[str] = Field( + default=None, + title="External link", + description="An optional external link to the created entry if available.", + ) diff --git a/lib/galaxy/webapps/galaxy/api/remote_files.py b/lib/galaxy/webapps/galaxy/api/remote_files.py index 84957a11018e..16284e265e3c 100644 --- a/lib/galaxy/webapps/galaxy/api/remote_files.py +++ b/lib/galaxy/webapps/galaxy/api/remote_files.py @@ -4,12 +4,15 @@ import logging from typing import Optional +from fastapi import Body from fastapi.param_functions import Query from galaxy.managers.context import ProvidesUserContext from galaxy.managers.remote_files import RemoteFilesManager from galaxy.schema.remote_files import ( AnyRemoteFilesListResponse, + CreatedEntryResponse, + CreateEntryPayload, FilesSourcePluginList, RemoteFilesDisableMode, RemoteFilesFormat, @@ -115,4 +118,19 @@ async def plugins( browsable_only: Optional[bool] = BrowsableQueryParam, ) -> FilesSourcePluginList: """Display plugin information for each of the gxfiles:// URI targets available.""" - return self.manager.get_files_source_plugins(user_ctx, browsable_only) + + @router.post( + "/api/remote_files", + summary="Creates a new entry (directory/record) on the remote files source.", + ) + async def create_entry( + self, + user_ctx: ProvidesUserContext = DependsOnTrans, + payload: CreateEntryPayload = Body( + ..., + title="Entry Data", + description="Information about the entry to create. Depends on the target file source.", + ), + ) -> CreatedEntryResponse: + """Creates a new entry on the remote files source.""" + return self.manager.create_entry(user_ctx, payload) From 35dfa9c2aced221e185179bd4e11617ccdf7aed4 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:23:53 +0200 Subject: [PATCH 07/41] Add RDMFilesSource subclass Implement the _create_entry method for InvenioRDMFilesSource and some additional refactors. --- lib/galaxy/files/sources/_rdm.py | 61 +++++++++++++++ lib/galaxy/files/sources/invenio.py | 116 +++++++++++++++++----------- 2 files changed, 133 insertions(+), 44 deletions(-) create mode 100644 lib/galaxy/files/sources/_rdm.py diff --git a/lib/galaxy/files/sources/_rdm.py b/lib/galaxy/files/sources/_rdm.py new file mode 100644 index 000000000000..9479982be50a --- /dev/null +++ b/lib/galaxy/files/sources/_rdm.py @@ -0,0 +1,61 @@ +import logging +from typing import ( + cast, + Optional, +) + +from typing_extensions import Unpack + +from galaxy.files import ProvidesUserFileSourcesUserContext +from galaxy.files.sources import ( + BaseFilesSource, + FilesSourceProperties, +) + +log = logging.getLogger(__name__) + + +class RDMFilesSourceProperties(FilesSourceProperties): + url: str + + +class RDMFilesSource(BaseFilesSource): + """Base class for Research Data Management (RDM) file sources. + + This class is not intended to be used directly, but rather to be subclassed + by file sources that interact with RDM repositories. + + A RDM file source is similar to a regular file source, but instead of tree of + files and directories, it provides a (one level) list of records (representing directories) + that can contain only files (no subdirectories). + + In addition, RDM file sources might need to create a new record (directory) in advance in the + repository, and then upload a file to it. This is done by calling the `create_entry` + method. + + """ + + # This allows to filter out the RDM file sources from the list of available + # file sources. + supports_rdm = True + + def __init__(self, **kwd: Unpack[FilesSourceProperties]): + props = self._parse_common_config_opts(kwd) + base_url = props.get("url", None) + if not base_url: + raise Exception("URL for RDM repository must be provided in configuration") + self._repository_url = base_url + self._props = props + + @property + def repository_url(self) -> str: + return self._repository_url + + def _serialization_props( + self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None + ) -> RDMFilesSourceProperties: + effective_props = {} + for key, val in self._props.items(): + effective_props[key] = self._evaluate_prop(val, user_context=user_context) + effective_props["url"] = self.repository_url + return cast(RDMFilesSourceProperties, effective_props) diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index 1aa70ed0c6af..d1765f0eafc7 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -4,7 +4,6 @@ import ssl import urllib.request from typing import ( - cast, List, Optional, ) @@ -14,15 +13,15 @@ from typing_extensions import ( Literal, TypedDict, - Unpack, ) from galaxy.files import ProvidesUserFileSourcesUserContext from galaxy.files.sources import ( - BaseFilesSource, + Entry, + EntryData, FilesSourceOptions, - FilesSourceProperties, ) +from galaxy.files.sources._rdm import RDMFilesSource from galaxy.util import ( DEFAULT_SOCKET_TIMEOUT, get_charset_from_http_headers, @@ -39,10 +38,6 @@ AccessStatus = Literal["public", "restricted"] -class InvenioFilesSourceProperties(FilesSourceProperties): - url: str - - class ResourceType(TypedDict): id: str @@ -104,6 +99,8 @@ class RecordLinks(TypedDict): class InvenioRecord(TypedDict): id: str title: str + created: str + updated: str resource_type: ResourceType publication_date: str access: RecordAccess @@ -112,19 +109,11 @@ class InvenioRecord(TypedDict): links: RecordLinks -class InvenioFilesSource(BaseFilesSource): +class InvenioRDMFilesSource(RDMFilesSource): """A files source for Invenio turn-key research data management repository.""" plugin_type = "inveniordm" - def __init__(self, **kwd: Unpack[InvenioFilesSourceProperties]): - props = self._parse_common_config_opts(kwd) - base_url = props.get("url", None) - if not base_url: - raise Exception("InvenioFilesSource requires a url") - self._invenio_url = base_url - self._props = props - def _list( self, path="/", @@ -132,10 +121,24 @@ def _list( user_context: Optional[ProvidesUserFileSourcesUserContext] = None, opts: Optional[FilesSourceOptions] = None, ): + write_intent = opts and opts.write_intent or False is_root_path = path == "/" if is_root_path: - return self._list_records(user_context) - return self._list_record_files(path, user_context) + return self._list_records(write_intent, user_context) + return self._list_record_files(path, write_intent, user_context) + + def _create_entry( + self, + entry_data: EntryData, + user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + opts: Optional[FilesSourceOptions] = None, + ) -> Entry: + record = self._create_draft_record(entry_data["name"], user_context=user_context) + return { + "uri": self._to_plugin_uri(record["links"]["record"]), + "name": record["metadata"]["title"], + "external_link": record["links"]["self_html"], + } def _realize_to( self, @@ -144,9 +147,8 @@ def _realize_to( user_context: Optional[ProvidesUserFileSourcesUserContext] = None, opts: Optional[FilesSourceOptions] = None, ): - # TODO: source_path will be wrong when constructed from the UI as it assumes the target_uri is `get_root_uri() + filename` - - remote_path = urljoin(self._invenio_url, source_path) + # source_path = '/api/records/pxpnk-7c133/Tester.rocrate.zip' + remote_path = urljoin(self.repository_url, source_path) # TODO: user_context is always None here when called from a data fetch. # This prevents downloading files that require authentication even if the user provided a token. headers = self._get_request_headers(user_context) @@ -165,23 +167,48 @@ def _write_from( opts: Optional[FilesSourceOptions] = None, ): filename = os.path.basename(target_path) - record_title = f"{filename} (exported by Galaxy)" - draft_record = self._create_draft_record(title=record_title, user_context=user_context) + dirname = os.path.dirname(target_path) + record_id = dirname.replace("/api/records/", "") + use_existing_record = len(record_id) > 5 + + # TODO: if we create the record here, then the target_path of the export will not have the record id and it will not be possible to import it back. + # We need to create the record before the export and then use the record id in the target_path. + + if use_existing_record: + draft_record = self._get_draft_record(record_id, user_context=user_context) + else: + record_title = f"{filename} (exported by Galaxy)" + draft_record = self._create_draft_record(title=record_title, user_context=user_context) try: self._upload_file_to_draft_record(draft_record, filename, native_path, user_context=user_context) self._publish_draft_record(draft_record, user_context=user_context) except Exception: - self._delete_draft_record(draft_record, user_context) + if not use_existing_record: + self._delete_draft_record(draft_record, user_context) raise - def _list_records(self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): + def _list_records(self, write_intent: bool, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): + if write_intent: + return self._list_writeable_records(user_context) + return self._list_all_records(user_context) + + def _list_all_records(self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): + # TODO: This is limited to 25 records by default. Add pagination support? + request_url = urljoin(self.repository_url, "api/records") + response_data = self._get_response(user_context, request_url) + return self._get_records_from_response(response_data) + + def _list_writeable_records(self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): # TODO: This is limited to 25 records by default. Add pagination support? - request_url = urljoin(self._invenio_url, "api/records") + # Only draft records can be written to. + request_url = urljoin(self.repository_url, "api/user/records?is_published=false") response_data = self._get_response(user_context, request_url) return self._get_records_from_response(response_data) - def _list_record_files(self, path, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): - request_url = urljoin(self._invenio_url, f"{path}/files") + def _list_record_files( + self, path: str, write_intent: bool, user_context: Optional[ProvidesUserFileSourcesUserContext] = None + ): + request_url = urljoin(self.repository_url, f"{path}{'/draft' if write_intent else '' }/files") response_data = self._get_response(user_context, request_url) return self._get_record_files_from_response(path, response_data) @@ -199,9 +226,13 @@ def _get_request_headers(self, user_context: Optional[ProvidesUserFileSourcesUse def _get_records_from_response(self, response: dict): records = response["hits"]["hits"] + return self._get_records_as_directories(records) + + def _get_records_as_directories(self, records): rval = [] for record in records: uri = self._to_plugin_uri(record["links"]["self"]) + # TODO: define model for Directory and File rval.append( { "class": "Directory", @@ -237,16 +268,12 @@ def _get_record_files_from_response(self, path: str, response: dict): return rval def _to_plugin_uri(self, uri: str) -> str: - return uri.replace(self._invenio_url, self.get_uri_root()) + return uri.replace(self.repository_url, self.get_uri_root()) - def _serialization_props( - self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None - ) -> InvenioFilesSourceProperties: - effective_props = {} - for key, val in self._props.items(): - effective_props[key] = self._evaluate_prop(val, user_context=user_context) - effective_props["url"] = self._invenio_url - return cast(InvenioFilesSourceProperties, effective_props) + def _get_draft_record(self, record_id: str, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): + request_url = urljoin(self.repository_url, f"api/records/{record_id}/draft") + draft_record = self._get_response(user_context, request_url) + return draft_record def _create_draft_record( self, title: str, user_context: Optional[ProvidesUserFileSourcesUserContext] = None @@ -274,7 +301,7 @@ def _create_draft_record( "Cannot create record without authentication token. Please set your personal access token in your Galaxy preferences." ) - create_record_url = urljoin(self._invenio_url, "api/records") + create_record_url = urljoin(self.repository_url, "api/records") response = requests.post(create_record_url, json=create_record_request, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 201) record = response.json() @@ -295,15 +322,16 @@ def _upload_file_to_draft_record( native_path: str, user_context: Optional[ProvidesUserFileSourcesUserContext] = None, ): - upload_file_url = urljoin(self._invenio_url, f"api/records/{record['id']}/draft/files") + upload_file_url = record["links"]["files"] headers = self._get_request_headers(user_context) - # Add file metadata + # Add file metadata entry response = requests.post(upload_file_url, json=[{"key": filename}], headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 201) # Upload file content - file_entry = response.json()["entries"][0] + entries = response.json()["entries"] + file_entry = next(entry for entry in entries if entry["key"] == filename) upload_file_content_url = file_entry["links"]["content"] commit_file_upload_url = file_entry["links"]["commit"] with open(native_path, "rb") as file: @@ -317,7 +345,7 @@ def _upload_file_to_draft_record( def _publish_draft_record( self, record: InvenioRecord, user_context: Optional[ProvidesUserFileSourcesUserContext] = None ): - publish_record_url = urljoin(self._invenio_url, f"api/records/{record['id']}/draft/actions/publish") + publish_record_url = urljoin(self.repository_url, f"api/records/{record['id']}/draft/actions/publish") headers = self._get_request_headers(user_context) response = requests.post(publish_record_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 202) @@ -361,4 +389,4 @@ def _get_response_error_message(self, response): return error_message -__all__ = ("InvenioFilesSource",) +__all__ = ("InvenioRDMFilesSource",) From 772292bac2308faa66d8b21248be81e81a9ef2b0 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:26:32 +0200 Subject: [PATCH 08/41] Allow filtering only RDM plugins in the API --- lib/galaxy/files/__init__.py | 3 +++ lib/galaxy/managers/remote_files.py | 8 ++++++-- lib/galaxy/webapps/galaxy/api/remote_files.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py index 88184c7862bc..97f8c4765054 100644 --- a/lib/galaxy/files/__init__.py +++ b/lib/galaxy/files/__init__.py @@ -165,11 +165,14 @@ def plugins_to_dict( for_serialization: bool = False, user_context: Optional["FileSourceDictifiable"] = None, browsable_only: Optional[bool] = False, + rdm_only: Optional[bool] = False, ) -> List[Dict[str, Any]]: rval = [] for file_source in self._file_sources: if not file_source.user_has_access(user_context): continue + if rdm_only and not getattr(file_source, "supports_rdm", False): + continue if browsable_only and not file_source.get_browsable(): continue el = file_source.to_dict(for_serialization=for_serialization, user_context=user_context) diff --git a/lib/galaxy/managers/remote_files.py b/lib/galaxy/managers/remote_files.py index 470ae5cb3307..863c0494436c 100644 --- a/lib/galaxy/managers/remote_files.py +++ b/lib/galaxy/managers/remote_files.py @@ -128,12 +128,16 @@ def index( return index def get_files_source_plugins( - self, user_context: ProvidesUserContext, browsable_only: Optional[bool] = True + self, user_context: ProvidesUserContext, browsable_only: Optional[bool] = True, rdm_only: Optional[bool] = False ) -> FilesSourcePluginList: """Display plugin information for each of the gxfiles:// URI targets available.""" user_file_source_context = ProvidesUserFileSourcesUserContext(user_context) + browsable_only = True if browsable_only is None else browsable_only + rdm_only = rdm_only or False plugins_dict = self._file_sources.plugins_to_dict( - user_context=user_file_source_context, browsable_only=True if browsable_only is None else browsable_only + user_context=user_file_source_context, + browsable_only=browsable_only, + rdm_only=rdm_only, ) plugins = [FilesSourcePlugin(**plugin_dict) for plugin_dict in plugins_dict] return FilesSourcePluginList.construct(__root__=plugins) diff --git a/lib/galaxy/webapps/galaxy/api/remote_files.py b/lib/galaxy/webapps/galaxy/api/remote_files.py index 16284e265e3c..ce930ebd0e11 100644 --- a/lib/galaxy/webapps/galaxy/api/remote_files.py +++ b/lib/galaxy/webapps/galaxy/api/remote_files.py @@ -80,6 +80,14 @@ ), ) +RDMOnlyQueryParam: Optional[bool] = Query( + default=False, + title="RDM only", + description=( + "Whether to return only RDM compatible plugins. The default is `False`, which will return all plugins." + ), +) + @router.cbv class FastAPIRemoteFiles: @@ -116,8 +124,10 @@ async def plugins( self, user_ctx: ProvidesUserContext = DependsOnTrans, browsable_only: Optional[bool] = BrowsableQueryParam, + rdm_only: Optional[bool] = RDMOnlyQueryParam, ) -> FilesSourcePluginList: """Display plugin information for each of the gxfiles:// URI targets available.""" + return self.manager.get_files_source_plugins(user_ctx, browsable_only, rdm_only) @router.post( "/api/remote_files", From 31000c101f551f56b9626a115c9d2f7e0df16e2a Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:27:59 +0200 Subject: [PATCH 09/41] Update client API schema --- client/src/schema/schema.ts | 82 +++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index 5961148ca6a9..6668bfebbff0 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -1232,6 +1232,11 @@ export interface paths { * @description Lists all remote files available to the user from different sources. */ get: operations["index_api_remote_files_get"]; + /** + * Creates a new entry (directory/record) on the remote files source. + * @description Creates a new entry on the remote files source. + */ + post: operations["create_entry_api_remote_files_post"]; }; "/api/remote_files/plugins": { /** @@ -2597,6 +2602,23 @@ export interface components { ConvertedDatasetsMap: { [key: string]: string | undefined; }; + /** + * CreateEntryPayload + * @description Base model definition with common configuration used by all derived models. + */ + CreateEntryPayload: { + /** + * Name + * @description The name of the entry to create. + * @example my_new_entry + */ + name: string; + /** + * Target + * @description The target file source to create the entry in. + */ + target: string; + }; /** * CreateHistoryContentFromStore * @description Base model definition with common configuration used by all derived models. @@ -2968,6 +2990,29 @@ export interface components { */ url: string; }; + /** + * CreatedEntryResponse + * @description Base model definition with common configuration used by all derived models. + */ + CreatedEntryResponse: { + /** + * External link + * @description An optional external link to the created entry if available. + */ + external_link?: string; + /** + * Name + * @description The name of the created entry. + * @example my_new_entry + */ + name: string; + /** + * URI + * @description The URI of the created entry. + * @example gxfiles://my_new_entry + */ + uri: string; + }; /** * CreatedUserModel * @description User in a transaction context. @@ -11122,11 +11167,13 @@ export interface operations { /** @description The requested format of returned data. Either `flat` to simply list all the files, `jstree` to get a tree representation of the files, or the default `uri` to list files and directories by their URI. */ /** @description Wether to recursively lists all sub-directories. This will be `True` by default depending on the `target`. */ /** @description (This only applies when `format` is `jstree`) The value can be either `folders` or `files` and it will disable the corresponding nodes of the tree. */ + /** @description Whether the query is made with the intention of writing to the source. If set to True, only entries that can be written to will be accessible. */ query?: { target?: string; format?: components["schemas"]["RemoteFilesFormat"]; recursive?: boolean; disable?: components["schemas"]["RemoteFilesDisableMode"]; + write_intent?: boolean; }; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ header?: { @@ -16081,11 +16128,13 @@ export interface operations { /** @description The requested format of returned data. Either `flat` to simply list all the files, `jstree` to get a tree representation of the files, or the default `uri` to list files and directories by their URI. */ /** @description Wether to recursively lists all sub-directories. This will be `True` by default depending on the `target`. */ /** @description (This only applies when `format` is `jstree`) The value can be either `folders` or `files` and it will disable the corresponding nodes of the tree. */ + /** @description Whether the query is made with the intention of writing to the source. If set to True, only entries that can be written to will be accessible. */ query?: { target?: string; format?: components["schemas"]["RemoteFilesFormat"]; recursive?: boolean; disable?: components["schemas"]["RemoteFilesDisableMode"]; + write_intent?: boolean; }; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ header?: { @@ -16109,6 +16158,37 @@ export interface operations { }; }; }; + create_entry_api_remote_files_post: { + /** + * Creates a new entry (directory/record) on the remote files source. + * @description Creates a new entry on the remote files source. + */ + parameters?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateEntryPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["CreatedEntryResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; plugins_api_remote_files_plugins_get: { /** * Display plugin information for each of the gxfiles:// URI targets available. @@ -16116,8 +16196,10 @@ export interface operations { */ parameters?: { /** @description Whether to return browsable filesources only. The default is `True`, which will omit filesourceslike `http` and `base64` that do not implement a list method. */ + /** @description Whether to return only RDM compatible plugins. The default is `False`, which will return all plugins. */ query?: { browsable_only?: boolean; + rdm_only?: boolean; }; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ header?: { From 6db572393250a2c60d035d2cf5f2e744e58b7223 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:33:42 +0200 Subject: [PATCH 10/41] Update FilesDialog services - Add new API parameters - Add new POST endpoint for creating entries - Add some code docs --- client/src/components/FilesDialog/services.ts | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/client/src/components/FilesDialog/services.ts b/client/src/components/FilesDialog/services.ts index f022c7c4a0d4..12ad338dc08d 100644 --- a/client/src/components/FilesDialog/services.ts +++ b/client/src/components/FilesDialog/services.ts @@ -5,17 +5,43 @@ export type FilesSourcePlugin = components["schemas"]["FilesSourcePlugin"]; export type RemoteFile = components["schemas"]["RemoteFile"]; export type RemoteDirectory = components["schemas"]["RemoteDirectory"]; export type RemoteEntry = RemoteFile | RemoteDirectory; +export type CreatedEntry = components["schemas"]["CreatedEntryResponse"]; const getRemoteFilesPlugins = fetcher.path("/api/remote_files/plugins").method("get").create(); -export async function getFileSources(): Promise { - const { data } = await getRemoteFilesPlugins({ browsable_only: true }); +/** + * Get the list of available file sources from the server that can be browsed. + * @param rdm_only Whether to only include Research Data Management (RDM) specific file sources. + * @returns The list of available file sources from the server. + */ +export async function getFileSources(rdm_only = false): Promise { + const { data } = await getRemoteFilesPlugins({ browsable_only: true, rdm_only: rdm_only }); return data; } const getRemoteFiles = fetcher.path("/api/remote_files").method("get").create(); -export async function browseRemoteFiles(uri: string, isRecursive = false): Promise { - const { data } = await getRemoteFiles({ target: uri, recursive: isRecursive }); +/** + * Get the list of files and directories from the server for the given file source URI. + * @param uri The file source URI to browse. + * @param isRecursive Whether to recursively retrieve all files inside subdirectories. + * @param writeIntent Whether to include only entries that can be written to. + * @returns The list of files and directories from the server for the given URI. + */ +export async function browseRemoteFiles(uri: string, isRecursive = false, writeIntent = false): Promise { + const { data } = await getRemoteFiles({ target: uri, recursive: isRecursive, write_intent: writeIntent }); return data as RemoteEntry[]; } + +const createEntry = fetcher.path("/api/remote_files").method("post").create(); + +/** + * Create a new entry (directory/record) on the given file source URI. + * @param uri The file source URI to create the entry in. + * @param name The name of the entry to create. + * @returns The created entry details. + */ +export async function createRemoteEntry(uri: string, name: string): Promise { + const { data } = await createEntry({ target: uri, name: name }); + return data; +} From 6af8aad7b502c1b5b2e93dbd12b29d23cf83ed18 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:34:37 +0200 Subject: [PATCH 11/41] Allow to filter RDM FS in composable --- client/src/composables/fileSources.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/composables/fileSources.ts b/client/src/composables/fileSources.ts index 67eb3d635676..d2a378b19350 100644 --- a/client/src/composables/fileSources.ts +++ b/client/src/composables/fileSources.ts @@ -4,14 +4,16 @@ import { FilesSourcePlugin, getFileSources } from "@/components/FilesDialog/serv /** * Composable for accessing and working with file sources. + * + * @param rdmOnly Whether to only include Research Data Management (RDM) specific file sources. */ -export function useFileSources() { +export function useFileSources(rdmOnly = false) { const isLoading = ref(true); const hasWritable = ref(false); const fileSources = ref([]); onMounted(async () => { - fileSources.value = await getFileSources(); + fileSources.value = await getFileSources(rdmOnly); hasWritable.value = fileSources.value.some((fs) => fs.writable); isLoading.value = false; }); From 314ca8b3a280d72c155befcf96d4ce50956032b6 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:37:06 +0200 Subject: [PATCH 12/41] Allow to filter RDM FS in FilesInput --- client/src/components/FilesDialog/FilesInput.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/components/FilesDialog/FilesInput.vue b/client/src/components/FilesDialog/FilesInput.vue index 11a1abcd5d8d..c7a0c88f680a 100644 --- a/client/src/components/FilesDialog/FilesInput.vue +++ b/client/src/components/FilesDialog/FilesInput.vue @@ -8,6 +8,7 @@ interface Props { value: string; mode?: "file" | "directory"; requireWritable?: boolean; + rdmOnly?: boolean; } interface SelectableFile { @@ -36,6 +37,7 @@ const selectFile = () => { const dialogProps = { mode: props.mode, requireWritable: props.requireWritable, + rdmOnly: props.rdmOnly, }; filesDialog((selected: SelectableFile) => { currentValue.value = selected?.url; From f1aaaf98ee6ddaffa62522a56c5fe23e42b021d2 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 20 Jul 2023 17:39:12 +0200 Subject: [PATCH 13/41] Update FilesDialog - Add new mode "source" to select only the root level of a file source. - Allow filtering FS by RDM support - Document props --- .../components/FilesDialog/FilesDialog.vue | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/client/src/components/FilesDialog/FilesDialog.vue b/client/src/components/FilesDialog/FilesDialog.vue index f4ff8a8e57ba..2a6e7a570881 100644 --- a/client/src/components/FilesDialog/FilesDialog.vue +++ b/client/src/components/FilesDialog/FilesDialog.vue @@ -21,9 +21,19 @@ import SelectionDialog from "@/components/SelectionDialog/SelectionDialog.vue"; library.add(faCaretLeft); interface FilesDialogProps { + /** Whether to allow multiple selections */ multiple?: boolean; - mode?: "file" | "directory"; + /** The browsing mode: + * - `file` - allows to select files or directories contained in a source (default) + * - `directory` - allows to select directories (paths) only + * - `source` - allows to select a source plugin root and doesn't list its contents + */ + mode?: "file" | "directory" | "source"; + /** Whether to show only writable sources */ requireWritable?: boolean; + /** Whether to show only RDM sources */ + rdmOnly?: boolean; + /** Callback function to be called passing the results when selection is complete */ callback?: (files: any) => void; } @@ -31,6 +41,7 @@ const props = withDefaults(defineProps(), { multiple: false, mode: "file", requireWritable: false, + rdmOnly: false, callback: () => {}, }); @@ -204,7 +215,7 @@ function load(record?: DirectoryRecord) { undoShow.value = !urlTracker.value.atRoot(); if (urlTracker.value.atRoot() || errorMessage.value) { errorMessage.value = undefined; - getFileSources() + getFileSources(props.rdmOnly) .then((results) => { const convertedItems = results .filter((item) => !props.requireWritable || item.writable) @@ -222,7 +233,15 @@ function load(record?: DirectoryRecord) { if (!currentDirectory.value) { return; } - browseRemoteFiles(currentDirectory.value?.url) + if (props.mode === "source") { + // In source mode, only show sources, not contents + items.value = []; + optionsShow.value = true; + showTime.value = false; + showDetails.value = false; + return; + } + browseRemoteFiles(currentDirectory.value?.url, false, props.requireWritable) .then((results) => { items.value = filterByMode(results).map(entryToRecord); formatRows(); From 556160ddaab9c447e9eb347fdb23984b62c603a0 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 21 Jul 2023 14:15:08 +0200 Subject: [PATCH 14/41] Add ExportDOIForm component This component allows to select or create new records in a DOI repository (RDM file source) for uploading exported artifacts (histories, datasets, etc.) to them. --- .../src/components/Common/ExportDOIForm.vue | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 client/src/components/Common/ExportDOIForm.vue diff --git a/client/src/components/Common/ExportDOIForm.vue b/client/src/components/Common/ExportDOIForm.vue new file mode 100644 index 000000000000..d16b05a6b6db --- /dev/null +++ b/client/src/components/Common/ExportDOIForm.vue @@ -0,0 +1,162 @@ + + + From 5cbc993c97699e8f79b011dd9847e754112fa95f Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 21 Jul 2023 14:16:06 +0200 Subject: [PATCH 15/41] Add option to export histories to DOI repository --- .../History/Export/HistoryExport.vue | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/client/src/components/History/Export/HistoryExport.vue b/client/src/components/History/Export/HistoryExport.vue index 4ff671873a99..1262b53c32ec 100644 --- a/client/src/components/History/Export/HistoryExport.vue +++ b/client/src/components/History/Export/HistoryExport.vue @@ -10,6 +10,7 @@ import { DEFAULT_EXPORT_PARAMS, useShortTermStorage } from "composables/shortTer import { useTaskMonitor } from "composables/taskMonitor"; import { copy as sendToClipboard } from "utils/clipboard"; import { computed, onMounted, reactive, ref, watch } from "vue"; +import { RouterLink } from "vue-router"; import { useHistoryStore } from "@/stores/historyStore"; import { absPath } from "@/utils/redirect"; @@ -17,6 +18,7 @@ import { absPath } from "@/utils/redirect"; import { exportToFileSource, getExportRecords, reimportHistoryFromRecord } from "./services"; import ExportOptions from "./ExportOptions.vue"; +import ExportDOIForm from "components/Common/ExportDOIForm.vue"; import ExportForm from "components/Common/ExportForm.vue"; import ExportRecordDetails from "components/Common/ExportRecordDetails.vue"; import ExportRecordTable from "components/Common/ExportRecordTable.vue"; @@ -29,6 +31,7 @@ const { } = useTaskMonitor(); const { hasWritable: hasWritableFileSources } = useFileSources(); +const { hasWritable: hasWritableRDMFileSources } = useFileSources(true); const { isPreparing: isPreparingDownload, @@ -228,6 +231,31 @@ function updateExportParams(newParams) {

+ +

You can publish your history to one of the available DOI repositories here.

+

+ Your history export archive needs to be uploaded to an existing record. You will need to create + a new record on the repository or select an existing draft record and then export + your history to it. +

+ + You may need to setup your credentials for the selected repository in your + settings page to be able to + export. You can also define some default options for the export in those settings, like the + public name you want to associate with your records or whether you want to publish them + immediately or keep them as drafts after export. + + +
From 6b54f38a6f581a605dc9ba41e10e3600f4a3d283 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 24 Jul 2023 18:37:30 +0200 Subject: [PATCH 16/41] Fix URL conversion between Invenio API / FS plugin API --- lib/galaxy/files/sources/invenio.py | 65 ++++++++++++++++------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index d1765f0eafc7..69d0cc7fc9d0 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -7,7 +7,10 @@ List, Optional, ) -from urllib.parse import urljoin +from urllib.parse import ( + quote, + urljoin, +) import requests from typing_extensions import ( @@ -112,8 +115,14 @@ class InvenioRecord(TypedDict): class InvenioRDMFilesSource(RDMFilesSource): """A files source for Invenio turn-key research data management repository.""" + # TODO: refactor the whole thing + plugin_type = "inveniordm" + @property + def records_api_url(self) -> str: + return f"{self._repository_url}/api/records" + def _list( self, path="/", @@ -147,18 +156,30 @@ def _realize_to( user_context: Optional[ProvidesUserFileSourcesUserContext] = None, opts: Optional[FilesSourceOptions] = None, ): - # source_path = '/api/records/pxpnk-7c133/Tester.rocrate.zip' - remote_path = urljoin(self.repository_url, source_path) + download_file_content_url = self._to_download_file_content_url(source_path) + # TODO: user_context is always None here when called from a data fetch. # This prevents downloading files that require authentication even if the user provided a token. headers = self._get_request_headers(user_context) - req = urllib.request.Request(remote_path, headers=headers) + req = urllib.request.Request(download_file_content_url, headers=headers) with urllib.request.urlopen(req, timeout=DEFAULT_SOCKET_TIMEOUT, context=SSL_CONTEXT) as page: f = open(native_path, "wb") return stream_to_open_named_file( page, f.fileno(), native_path, source_encoding=get_charset_from_http_headers(page.headers) ) + def _to_download_file_content_url(self, source_path: str) -> str: + # source_path can be: + # - '/{record_id}/{file_name}' when reimporting + # - '/{record_id}/files/{file_name}/content' when downloading a existing file + # We need to return a fully qualified url like + # 'https:///api/records/{record_id}/files/{file_name}/content' in both cases. + if source_path.endswith("/content"): + return f"{self.records_api_url}{source_path}" + record_id = source_path.split("/")[1] + file_name = source_path.split("/")[-1] + return urljoin(self.repository_url, f"/api/records/{record_id}/files/{quote(file_name)}/content") + def _write_from( self, target_path: str, @@ -167,25 +188,11 @@ def _write_from( opts: Optional[FilesSourceOptions] = None, ): filename = os.path.basename(target_path) - dirname = os.path.dirname(target_path) - record_id = dirname.replace("/api/records/", "") - use_existing_record = len(record_id) > 5 - - # TODO: if we create the record here, then the target_path of the export will not have the record id and it will not be possible to import it back. - # We need to create the record before the export and then use the record id in the target_path. - - if use_existing_record: - draft_record = self._get_draft_record(record_id, user_context=user_context) - else: - record_title = f"{filename} (exported by Galaxy)" - draft_record = self._create_draft_record(title=record_title, user_context=user_context) - try: - self._upload_file_to_draft_record(draft_record, filename, native_path, user_context=user_context) - self._publish_draft_record(draft_record, user_context=user_context) - except Exception: - if not use_existing_record: - self._delete_draft_record(draft_record, user_context) - raise + record_id = os.path.dirname(target_path) + + draft_record = self._get_draft_record(record_id, user_context=user_context) + self._upload_file_to_draft_record(draft_record, filename, native_path, user_context=user_context) + self._publish_draft_record(draft_record, user_context=user_context) def _list_records(self, write_intent: bool, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): if write_intent: @@ -194,7 +201,7 @@ def _list_records(self, write_intent: bool, user_context: Optional[ProvidesUserF def _list_all_records(self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): # TODO: This is limited to 25 records by default. Add pagination support? - request_url = urljoin(self.repository_url, "api/records") + request_url = self.records_api_url response_data = self._get_response(user_context, request_url) return self._get_records_from_response(response_data) @@ -208,7 +215,7 @@ def _list_writeable_records(self, user_context: Optional[ProvidesUserFileSources def _list_record_files( self, path: str, write_intent: bool, user_context: Optional[ProvidesUserFileSourcesUserContext] = None ): - request_url = urljoin(self.repository_url, f"{path}{'/draft' if write_intent else '' }/files") + request_url = f"{self.records_api_url}{path}{'/draft' if write_intent else '' }/files" response_data = self._get_response(user_context, request_url) return self._get_record_files_from_response(path, response_data) @@ -268,10 +275,10 @@ def _get_record_files_from_response(self, path: str, response: dict): return rval def _to_plugin_uri(self, uri: str) -> str: - return uri.replace(self.repository_url, self.get_uri_root()) + return uri.replace(self.records_api_url, self.get_uri_root()) def _get_draft_record(self, record_id: str, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): - request_url = urljoin(self.repository_url, f"api/records/{record_id}/draft") + request_url = f"{self.records_api_url}{record_id}/draft" draft_record = self._get_response(user_context, request_url) return draft_record @@ -301,7 +308,7 @@ def _create_draft_record( "Cannot create record without authentication token. Please set your personal access token in your Galaxy preferences." ) - create_record_url = urljoin(self.repository_url, "api/records") + create_record_url = self.records_api_url response = requests.post(create_record_url, json=create_record_request, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 201) record = response.json() @@ -345,7 +352,7 @@ def _upload_file_to_draft_record( def _publish_draft_record( self, record: InvenioRecord, user_context: Optional[ProvidesUserFileSourcesUserContext] = None ): - publish_record_url = urljoin(self.repository_url, f"api/records/{record['id']}/draft/actions/publish") + publish_record_url = f"{self.records_api_url}/{record['id']}/draft/actions/publish" headers = self._get_request_headers(user_context) response = requests.post(publish_record_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 202) From cfa908ca7afe1916f2f8a841df9e562bf14f31f4 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 25 Jul 2023 16:34:30 +0200 Subject: [PATCH 17/41] Fix title display of record v-localize will remove spaces between tags for some reason. --- client/src/components/Common/ExportDOIForm.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/components/Common/ExportDOIForm.vue b/client/src/components/Common/ExportDOIForm.vue index d16b05a6b6db..3b72eedb652f 100644 --- a/client/src/components/Common/ExportDOIForm.vue +++ b/client/src/components/Common/ExportDOIForm.vue @@ -83,8 +83,9 @@ function clearInputs() {
-

- A new draft record with name {{ newEntry.name }} has been created in the repository. +

+ {{ newEntry.name }} + draft record has been created in the repository.

You can preview the record in the repository and further edit its metadata at From f5c03952fbe0e459cc4cbaa0ea04e2d14df2804d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B3pez?= <46503462+davelopez@users.noreply.github.com> Date: Wed, 26 Jul 2023 16:54:58 +0200 Subject: [PATCH 18/41] Fix wording in API doc Co-authored-by: Marius van den Beek --- lib/galaxy/webapps/galaxy/api/remote_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/api/remote_files.py b/lib/galaxy/webapps/galaxy/api/remote_files.py index ce930ebd0e11..856d70018d46 100644 --- a/lib/galaxy/webapps/galaxy/api/remote_files.py +++ b/lib/galaxy/webapps/galaxy/api/remote_files.py @@ -67,7 +67,7 @@ title="Write Intent", description=( "Whether the query is made with the intention of writing to the source." - " If set to True, only entries that can be written to will be accessible." + " If set to True, only entries that can be written to will be returned." ), ) From 87909d629bb2420389e8e33efa2771002b2d0ece Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Jul 2023 13:53:03 +0200 Subject: [PATCH 19/41] Add TypedDicts to represent Directories and Files + Docstring --- lib/galaxy/files/sources/__init__.py | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index 863dfb03e3d5..8dcdef0fa547 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -10,6 +10,7 @@ ) from typing_extensions import ( + Literal, NotRequired, TypedDict, ) @@ -68,17 +69,40 @@ class FilesSourceOptions: class EntryData(TypedDict): + """Provides data to create a new entry in a file source.""" + name: str # May contain additional properties depending on the file source class Entry(TypedDict): + """Represents the result of creating a new entry in a file source.""" + name: str uri: str # May contain additional properties depending on the file source external_link: NotRequired[str] +class RemoteEntry(TypedDict): + name: str + uri: str + path: str + + +TDirectoryClass = TypedDict("TDirectoryClass", {"class": Literal["Directory"]}) +TFileClass = TypedDict("TFileClass", {"class": Literal["File"]}) + + +class RemoteDirectory(RemoteEntry, TDirectoryClass): + pass + + +class RemoteFile(RemoteEntry, TFileClass): + size: int + ctime: str + + class SingleFileSource(metaclass=abc.ABCMeta): """ Represents a protocol handler for a single remote file that can be read by or written to by Galaxy. @@ -320,6 +344,11 @@ def create_entry( def _create_entry( self, entry_data: EntryData, user_context=None, opts: Optional[FilesSourceOptions] = None ) -> Entry: + """Create a new entry (directory) in the file source. + + The file source must be writeable. + This function can be overridden by subclasses to provide a way of creating entries in the file source. + """ raise NotImplementedError() def write_from(self, target_path, native_path, user_context=None, opts: Optional[FilesSourceOptions] = None): From 55c05c6954b776ead3e65024affd3ca42c589a8e Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Jul 2023 14:22:00 +0200 Subject: [PATCH 20/41] Add RDMRepositoryInteractor interface + refactor --- lib/galaxy/files/sources/_rdm.py | 120 +++++++++++- lib/galaxy/files/sources/invenio.py | 286 +++++++++++++--------------- 2 files changed, 243 insertions(+), 163 deletions(-) diff --git a/lib/galaxy/files/sources/_rdm.py b/lib/galaxy/files/sources/_rdm.py index 9479982be50a..9e00950255ab 100644 --- a/lib/galaxy/files/sources/_rdm.py +++ b/lib/galaxy/files/sources/_rdm.py @@ -1,6 +1,8 @@ import logging from typing import ( cast, + List, + NamedTuple, Optional, ) @@ -10,15 +12,110 @@ from galaxy.files.sources import ( BaseFilesSource, FilesSourceProperties, + RemoteDirectory, + RemoteFile, ) log = logging.getLogger(__name__) +OptionalUserContext = Optional[ProvidesUserFileSourcesUserContext] + class RDMFilesSourceProperties(FilesSourceProperties): url: str +class RecordFilename(NamedTuple): + record_id: str + filename: str + + +class RDMRepositoryInteractor: + """Base class for interacting with an external RDM repository. + + This class is not intended to be used directly, but rather to be subclassed + by file sources that interact with RDM repositories. + """ + + def __init__(self, repository_url: str, plugin: BaseFilesSource): + self._repository_url = repository_url + self._plugin = plugin + + @property + def plugin(self) -> BaseFilesSource: + """Returns the plugin associated with this repository interactor.""" + return self._plugin + + @property + def repository_url(self) -> str: + """Returns the base URL of the repository. + + Example: https://zenodo.org + """ + return self._repository_url + + def to_plugin_uri(self, record_id: str, filename: Optional[str] = None) -> str: + """Creates a valid plugin URI to reference the given record_id. + + If a filename is provided, the URI will reference the specific file in the record.""" + raise NotImplementedError() + + def get_records(self, write_intent: bool, user_context: OptionalUserContext = None) -> List[RemoteDirectory]: + """Returns the list of records in the repository.""" + raise NotImplementedError() + + def get_files_in_record( + self, record_id: str, write_intent: bool, user_context: OptionalUserContext = None + ) -> List[RemoteFile]: + """Returns the list of files contained in the given record.""" + raise NotImplementedError() + + def create_draft_record(self, title: str, user_context: OptionalUserContext = None): + """Creates a draft record (directory) in the repository with basic metadata. + + The metadata is usually just the title of the record and the user that created it. + Some plugins might also provide additional metadata defaults in the user settings.""" + raise NotImplementedError() + + def upload_file_to_draft_record( + self, + record_id: str, + filename: str, + file_path: str, + user_context: OptionalUserContext = None, + ) -> None: + """Uploads a file with the provided filename (from file_path) to a draft record with the given record_id. + + The draft record must have been created in advance with the `create_draft_record` method. + The file must exist in the file system at the given file_path. + The user_context might be required to authenticate the user in the repository. + """ + raise NotImplementedError() + + def download_file_from_record( + self, + record_id: str, + filename: str, + file_path: str, + user_context: OptionalUserContext = None, + ) -> None: + """Downloads a file with the provided filename from the record with the given record_id. + + The file will be downloaded to the file system at the given file_path. + The user_context might be required to authenticate the user in the repository if the + file is not publicly available. + """ + raise NotImplementedError() + + def publish_draft_record(self, record_id: str, user_context: OptionalUserContext = None) -> None: + """Publishes the draft record with the given record_id. + + The draft record must have been created in advance with the `create_draft_record` method. + The user_context might be required to authenticate the user in the repository. + """ + raise NotImplementedError() + + class RDMFilesSource(BaseFilesSource): """Base class for Research Data Management (RDM) file sources. @@ -46,16 +143,27 @@ def __init__(self, **kwd: Unpack[FilesSourceProperties]): raise Exception("URL for RDM repository must be provided in configuration") self._repository_url = base_url self._props = props + self._repository_interactor = self.get_repository_interactor(base_url) @property - def repository_url(self) -> str: - return self._repository_url + def repository(self) -> RDMRepositoryInteractor: + return self._repository_interactor + + def get_repository_interactor(self, repository_url: str) -> RDMRepositoryInteractor: + """Returns an interactor compatible with the given repository URL. + + This must be implemented by subclasses.""" + raise NotImplementedError() + + def parse_path(self, source_path: str) -> RecordFilename: + # source_path has always the format: '/{record_id}/{file_name}' + record_id = source_path.split("/")[1] + filename = source_path.split("/")[-1] + return RecordFilename(record_id=record_id, filename=filename) - def _serialization_props( - self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None - ) -> RDMFilesSourceProperties: + def _serialization_props(self, user_context: OptionalUserContext = None) -> RDMFilesSourceProperties: effective_props = {} for key, val in self._props.items(): effective_props[key] = self._evaluate_prop(val, user_context=user_context) - effective_props["url"] = self.repository_url + effective_props["url"] = self._repository_url return cast(RDMFilesSourceProperties, effective_props) diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index 69d0cc7fc9d0..28be0d97da40 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -1,16 +1,12 @@ import datetime import json -import os import ssl import urllib.request from typing import ( List, Optional, ) -from urllib.parse import ( - quote, - urljoin, -) +from urllib.parse import quote import requests from typing_extensions import ( @@ -18,13 +14,18 @@ TypedDict, ) -from galaxy.files import ProvidesUserFileSourcesUserContext from galaxy.files.sources import ( Entry, EntryData, FilesSourceOptions, + RemoteDirectory, + RemoteFile, +) +from galaxy.files.sources._rdm import ( + OptionalUserContext, + RDMFilesSource, + RDMRepositoryInteractor, ) -from galaxy.files.sources._rdm import RDMFilesSource from galaxy.util import ( DEFAULT_SOCKET_TIMEOUT, get_charset_from_http_headers, @@ -115,36 +116,34 @@ class InvenioRecord(TypedDict): class InvenioRDMFilesSource(RDMFilesSource): """A files source for Invenio turn-key research data management repository.""" - # TODO: refactor the whole thing - plugin_type = "inveniordm" - @property - def records_api_url(self) -> str: - return f"{self._repository_url}/api/records" + def get_repository_interactor(self, repository_url: str) -> RDMRepositoryInteractor: + return InvenioRepositoryInteractor(repository_url, self) def _list( self, path="/", recursive=True, - user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + user_context: OptionalUserContext = None, opts: Optional[FilesSourceOptions] = None, ): write_intent = opts and opts.write_intent or False is_root_path = path == "/" if is_root_path: - return self._list_records(write_intent, user_context) - return self._list_record_files(path, write_intent, user_context) + return self.repository.get_records(write_intent, user_context) + record_id, _ = self.parse_path(path) + return self.repository.get_files_in_record(record_id, write_intent, user_context) def _create_entry( self, entry_data: EntryData, - user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + user_context: OptionalUserContext = None, opts: Optional[FilesSourceOptions] = None, ) -> Entry: - record = self._create_draft_record(entry_data["name"], user_context=user_context) + record = self.repository.create_draft_record(entry_data["name"], user_context=user_context) return { - "uri": self._to_plugin_uri(record["links"]["record"]), + "uri": self.repository.to_plugin_uri(record["id"]), "name": record["metadata"]["title"], "external_link": record["links"]["self_html"], } @@ -153,141 +152,53 @@ def _realize_to( self, source_path: str, native_path: str, - user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + user_context: OptionalUserContext = None, opts: Optional[FilesSourceOptions] = None, ): - download_file_content_url = self._to_download_file_content_url(source_path) - # TODO: user_context is always None here when called from a data fetch. # This prevents downloading files that require authentication even if the user provided a token. - headers = self._get_request_headers(user_context) - req = urllib.request.Request(download_file_content_url, headers=headers) - with urllib.request.urlopen(req, timeout=DEFAULT_SOCKET_TIMEOUT, context=SSL_CONTEXT) as page: - f = open(native_path, "wb") - return stream_to_open_named_file( - page, f.fileno(), native_path, source_encoding=get_charset_from_http_headers(page.headers) - ) - def _to_download_file_content_url(self, source_path: str) -> str: - # source_path can be: - # - '/{record_id}/{file_name}' when reimporting - # - '/{record_id}/files/{file_name}/content' when downloading a existing file - # We need to return a fully qualified url like - # 'https:///api/records/{record_id}/files/{file_name}/content' in both cases. - if source_path.endswith("/content"): - return f"{self.records_api_url}{source_path}" - record_id = source_path.split("/")[1] - file_name = source_path.split("/")[-1] - return urljoin(self.repository_url, f"/api/records/{record_id}/files/{quote(file_name)}/content") + record_id, filename = self.parse_path(source_path) + self.repository.download_file_from_record(record_id, filename, native_path, user_context=user_context) def _write_from( self, target_path: str, native_path: str, - user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + user_context: OptionalUserContext = None, opts: Optional[FilesSourceOptions] = None, ): - filename = os.path.basename(target_path) - record_id = os.path.dirname(target_path) + record_id, filename = self.parse_path(target_path) + self.repository.upload_file_to_draft_record(record_id, filename, native_path, user_context=user_context) + self.repository.publish_draft_record(record_id, user_context=user_context) - draft_record = self._get_draft_record(record_id, user_context=user_context) - self._upload_file_to_draft_record(draft_record, filename, native_path, user_context=user_context) - self._publish_draft_record(draft_record, user_context=user_context) - def _list_records(self, write_intent: bool, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): - if write_intent: - return self._list_writeable_records(user_context) - return self._list_all_records(user_context) +class InvenioRepositoryInteractor(RDMRepositoryInteractor): + @property + def records_url(self) -> str: + return f"{self.repository_url}/api/records" - def _list_all_records(self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): - # TODO: This is limited to 25 records by default. Add pagination support? - request_url = self.records_api_url - response_data = self._get_response(user_context, request_url) - return self._get_records_from_response(response_data) + def to_plugin_uri(self, record_id: str, filename: Optional[str] = None) -> str: + return f"{self.plugin.get_uri_root()}/{record_id}{f'/{filename}' if filename else ''}" - def _list_writeable_records(self, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): + def get_records(self, write_intent: bool, user_context: OptionalUserContext = None) -> List[RemoteDirectory]: + # Only draft records owned by the user can be written to. + request_url = f"{self.repository_url}/api/user/records?is_published=false" if write_intent else self.records_url # TODO: This is limited to 25 records by default. Add pagination support? - # Only draft records can be written to. - request_url = urljoin(self.repository_url, "api/user/records?is_published=false") response_data = self._get_response(user_context, request_url) return self._get_records_from_response(response_data) - def _list_record_files( - self, path: str, write_intent: bool, user_context: Optional[ProvidesUserFileSourcesUserContext] = None - ): - request_url = f"{self.records_api_url}{path}{'/draft' if write_intent else '' }/files" + def get_files_in_record( + self, record_id: str, write_intent: bool, user_context: OptionalUserContext = None + ) -> List[RemoteFile]: + request_url = f"{self.records_url}/{record_id}{'/draft' if write_intent else ''}/files" response_data = self._get_response(user_context, request_url) - return self._get_record_files_from_response(path, response_data) - - def _get_response(self, user_context: Optional[ProvidesUserFileSourcesUserContext], request_url: str) -> dict: - headers = self._get_request_headers(user_context) - response = requests.get(request_url, headers=headers, verify=VERIFY) - self._ensure_response_has_expected_status_code(response, 200) - return response.json() - - def _get_request_headers(self, user_context: Optional[ProvidesUserFileSourcesUserContext]): - vault = user_context.user_vault if user_context else None - token = vault.read_secret(f"preferences/{self.id}/token") if vault else None - headers = {"Authorization": f"Bearer {token}"} if token else {} - return headers - - def _get_records_from_response(self, response: dict): - records = response["hits"]["hits"] - return self._get_records_as_directories(records) - - def _get_records_as_directories(self, records): - rval = [] - for record in records: - uri = self._to_plugin_uri(record["links"]["self"]) - # TODO: define model for Directory and File - rval.append( - { - "class": "Directory", - "name": record["metadata"]["title"], - "ctime": record["created"], - "uri": uri, - "path": f"/{record['id']}", - } - ) - - return rval + return self._get_record_files_from_response(record_id, response_data) - def _get_record_files_from_response(self, path: str, response: dict): - files_enabled = response.get("enabled", False) - if not files_enabled: - return [] - entries = response["entries"] - rval = [] - for entry in entries: - if entry.get("status") == "completed": - uri = self._to_plugin_uri(entry["links"]["content"]) - path = self._to_plugin_uri(entry["links"]["self"]) - rval.append( - { - "class": "File", - "name": entry["key"], - "size": entry["size"], - "ctime": entry["created"], - "uri": uri, - "path": path, - } - ) - return rval - - def _to_plugin_uri(self, uri: str) -> str: - return uri.replace(self.records_api_url, self.get_uri_root()) - - def _get_draft_record(self, record_id: str, user_context: Optional[ProvidesUserFileSourcesUserContext] = None): - request_url = f"{self.records_api_url}{record_id}/draft" - draft_record = self._get_response(user_context, request_url) - return draft_record - - def _create_draft_record( - self, title: str, user_context: Optional[ProvidesUserFileSourcesUserContext] = None - ) -> InvenioRecord: + def create_draft_record(self, title: str, user_context: OptionalUserContext = None) -> RemoteDirectory: today = datetime.date.today().isoformat() creator = self._get_creator_from_user_context(user_context) - should_publish = self._get_public_records_user_setting_enabled_status(user_context) + should_publish = bool(self.get_user_preference_by_key("public_records", user_context)) access = "public" if should_publish else "restricted" create_record_request = { "access": {"record": access, "files": access}, @@ -308,27 +219,19 @@ def _create_draft_record( "Cannot create record without authentication token. Please set your personal access token in your Galaxy preferences." ) - create_record_url = self.records_api_url - response = requests.post(create_record_url, json=create_record_request, headers=headers, verify=VERIFY) + response = requests.post(self.records_url, json=create_record_request, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 201) record = response.json() return record - def _delete_draft_record( - self, record: InvenioRecord, user_context: Optional[ProvidesUserFileSourcesUserContext] = None - ): - delete_record_url = record["links"]["self"] - headers = self._get_request_headers(user_context) - response = requests.delete(delete_record_url, headers=headers, verify=VERIFY) - self._ensure_response_has_expected_status_code(response, 204) - - def _upload_file_to_draft_record( + def upload_file_to_draft_record( self, - record: InvenioRecord, + record_id: str, filename: str, - native_path: str, - user_context: Optional[ProvidesUserFileSourcesUserContext] = None, + file_path: str, + user_context: OptionalUserContext = None, ): + record = self._get_draft_record(record_id, user_context=user_context) upload_file_url = record["links"]["files"] headers = self._get_request_headers(user_context) @@ -341,7 +244,7 @@ def _upload_file_to_draft_record( file_entry = next(entry for entry in entries if entry["key"] == filename) upload_file_content_url = file_entry["links"]["content"] commit_file_upload_url = file_entry["links"]["commit"] - with open(native_path, "rb") as file: + with open(file_path, "rb") as file: response = requests.put(upload_file_content_url, data=file, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 200) @@ -349,17 +252,74 @@ def _upload_file_to_draft_record( response = requests.post(commit_file_upload_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 200) - def _publish_draft_record( - self, record: InvenioRecord, user_context: Optional[ProvidesUserFileSourcesUserContext] = None + def download_file_from_record( + self, + record_id: str, + filename: str, + file_path: str, + user_context: OptionalUserContext = None, ): - publish_record_url = f"{self.records_api_url}/{record['id']}/draft/actions/publish" + download_file_content_url = f"{self.records_url}/{record_id}/files/{quote(filename)}/content" + + headers = self._get_request_headers(user_context) + req = urllib.request.Request(download_file_content_url, headers=headers) + with urllib.request.urlopen(req, timeout=DEFAULT_SOCKET_TIMEOUT, context=SSL_CONTEXT) as page: + f = open(file_path, "wb") + return stream_to_open_named_file( + page, f.fileno(), file_path, source_encoding=get_charset_from_http_headers(page.headers) + ) + + def publish_draft_record(self, record_id: str, user_context: OptionalUserContext = None): + publish_record_url = f"{self.records_url}/{record_id}/draft/actions/publish" headers = self._get_request_headers(user_context) response = requests.post(publish_record_url, headers=headers, verify=VERIFY) self._ensure_response_has_expected_status_code(response, 202) - def _get_creator_from_user_context(self, user_context: Optional[ProvidesUserFileSourcesUserContext]): - preferences = user_context.preferences if user_context else None - public_name = preferences.get(f"{self.id}|public_name", None) if preferences else None + def _get_draft_record(self, record_id: str, user_context: OptionalUserContext = None): + request_url = f"{self.records_url}/{record_id}/draft" + draft_record = self._get_response(user_context, request_url) + return draft_record + + def _get_records_from_response(self, response: dict) -> List[RemoteDirectory]: + records = response["hits"]["hits"] + rval: List[RemoteDirectory] = [] + for record in records: + uri = self.to_plugin_uri(record_id=record["id"]) + path = self.plugin.to_relative_path(uri) + rval.append( + { + "class": "Directory", + "name": record["metadata"]["title"], + "uri": uri, + "path": path, + } + ) + return rval + + def _get_record_files_from_response(self, record_id: str, response: dict) -> List[RemoteFile]: + files_enabled = response.get("enabled", False) + if not files_enabled: + return [] + entries = response["entries"] + rval: List[RemoteFile] = [] + for entry in entries: + if entry.get("status") == "completed": + uri = self.to_plugin_uri(record_id=record_id, filename=entry["key"]) + path = self.plugin.to_relative_path(uri) + rval.append( + { + "class": "File", + "name": entry["key"], + "size": entry["size"], + "ctime": entry["created"], + "uri": uri, + "path": path, + } + ) + return rval + + def _get_creator_from_user_context(self, user_context: OptionalUserContext): + public_name = self.get_user_preference_by_key("public_name", user_context) family_name = "Galaxy User" given_name = "Anonymous" if public_name: @@ -371,14 +331,26 @@ def _get_creator_from_user_context(self, user_context: Optional[ProvidesUserFile given_name = public_name return {"person_or_org": {"family_name": family_name, "given_name": given_name, "type": "personal"}} - def _get_public_records_user_setting_enabled_status( - self, user_context: Optional[ProvidesUserFileSourcesUserContext] - ) -> bool: + def get_user_preference_by_key(self, key: str, user_context: OptionalUserContext): preferences = user_context.preferences if user_context else None - public_records = preferences.get(f"{self.id}|public_records", None) if preferences else None - if public_records: - return True - return False + value = preferences.get(f"{self.plugin.id}|{key}", None) if preferences else None + return value + + def _get_response(self, user_context: OptionalUserContext, request_url: str) -> dict: + headers = self._get_request_headers(user_context) + response = requests.get(request_url, headers=headers, verify=VERIFY) + self._ensure_response_has_expected_status_code(response, 200) + return response.json() + + def _get_request_headers(self, user_context: OptionalUserContext): + token = self.get_authorization_token(user_context) + headers = {"Authorization": f"Bearer {token}"} if token else {} + return headers + + def get_authorization_token(self, user_context) -> str: + vault = user_context.user_vault if user_context else None + token = vault.read_secret(f"preferences/{self.plugin.id}/token") if vault else None + return token def _ensure_response_has_expected_status_code(self, response, expected_status_code: int): if response.status_code != expected_status_code: From 9e645aff7b318b7deed8d915db213908bc283f09 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Jul 2023 14:44:43 +0200 Subject: [PATCH 21/41] Rename write_intent to writeable --- client/src/components/FilesDialog/services.ts | 6 +++--- client/src/schema/schema.ts | 8 ++++---- lib/galaxy/files/sources/__init__.py | 6 +++--- lib/galaxy/files/sources/_rdm.py | 15 +++++++++++---- lib/galaxy/files/sources/invenio.py | 15 ++++++++------- lib/galaxy/managers/remote_files.py | 4 ++-- lib/galaxy/webapps/galaxy/api/remote_files.py | 8 ++++---- 7 files changed, 35 insertions(+), 27 deletions(-) diff --git a/client/src/components/FilesDialog/services.ts b/client/src/components/FilesDialog/services.ts index 12ad338dc08d..7566689cc81e 100644 --- a/client/src/components/FilesDialog/services.ts +++ b/client/src/components/FilesDialog/services.ts @@ -25,11 +25,11 @@ const getRemoteFiles = fetcher.path("/api/remote_files").method("get").create(); * Get the list of files and directories from the server for the given file source URI. * @param uri The file source URI to browse. * @param isRecursive Whether to recursively retrieve all files inside subdirectories. - * @param writeIntent Whether to include only entries that can be written to. + * @param writeable Whether to return only entries that can be written to. * @returns The list of files and directories from the server for the given URI. */ -export async function browseRemoteFiles(uri: string, isRecursive = false, writeIntent = false): Promise { - const { data } = await getRemoteFiles({ target: uri, recursive: isRecursive, write_intent: writeIntent }); +export async function browseRemoteFiles(uri: string, isRecursive = false, writeable = false): Promise { + const { data } = await getRemoteFiles({ target: uri, recursive: isRecursive, writeable }); return data as RemoteEntry[]; } diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index 6668bfebbff0..0466dd8d2565 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -11167,13 +11167,13 @@ export interface operations { /** @description The requested format of returned data. Either `flat` to simply list all the files, `jstree` to get a tree representation of the files, or the default `uri` to list files and directories by their URI. */ /** @description Wether to recursively lists all sub-directories. This will be `True` by default depending on the `target`. */ /** @description (This only applies when `format` is `jstree`) The value can be either `folders` or `files` and it will disable the corresponding nodes of the tree. */ - /** @description Whether the query is made with the intention of writing to the source. If set to True, only entries that can be written to will be accessible. */ + /** @description Whether the query is made with the intention of writing to the source. If set to True, only entries that can be written to will be returned. */ query?: { target?: string; format?: components["schemas"]["RemoteFilesFormat"]; recursive?: boolean; disable?: components["schemas"]["RemoteFilesDisableMode"]; - write_intent?: boolean; + writeable?: boolean; }; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ header?: { @@ -16128,13 +16128,13 @@ export interface operations { /** @description The requested format of returned data. Either `flat` to simply list all the files, `jstree` to get a tree representation of the files, or the default `uri` to list files and directories by their URI. */ /** @description Wether to recursively lists all sub-directories. This will be `True` by default depending on the `target`. */ /** @description (This only applies when `format` is `jstree`) The value can be either `folders` or `files` and it will disable the corresponding nodes of the tree. */ - /** @description Whether the query is made with the intention of writing to the source. If set to True, only entries that can be written to will be accessible. */ + /** @description Whether the query is made with the intention of writing to the source. If set to True, only entries that can be written to will be returned. */ query?: { target?: string; format?: components["schemas"]["RemoteFilesFormat"]; recursive?: boolean; disable?: components["schemas"]["RemoteFilesDisableMode"]; - write_intent?: boolean; + writeable?: boolean; }; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ header?: { diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index 8dcdef0fa547..769907eef0b2 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -57,9 +57,9 @@ class FilesSourceOptions: """Options to control behavior of file source operations, such as realize_to, write_from and list.""" # Indicates access to the FS operation with intent to write. - # A file source can be "writeable" but, for example, some directories (or elements) may be restricted or read-only - # so those should be skipped while browsing with write_intent=True. - write_intent: Optional[bool] + # Even if a file source is "writeable" some directories (or elements) may be restricted or read-only + # so those should be skipped while browsing with writeable=True. + writeable: Optional[bool] # Property overrides for values initially configured through the constructor. For example # the HTTPFilesSource passes in additional http_headers through these properties, which diff --git a/lib/galaxy/files/sources/_rdm.py b/lib/galaxy/files/sources/_rdm.py index 9e00950255ab..ee2a0c7adb78 100644 --- a/lib/galaxy/files/sources/_rdm.py +++ b/lib/galaxy/files/sources/_rdm.py @@ -60,14 +60,21 @@ def to_plugin_uri(self, record_id: str, filename: Optional[str] = None) -> str: If a filename is provided, the URI will reference the specific file in the record.""" raise NotImplementedError() - def get_records(self, write_intent: bool, user_context: OptionalUserContext = None) -> List[RemoteDirectory]: - """Returns the list of records in the repository.""" + def get_records(self, writeable: bool, user_context: OptionalUserContext = None) -> List[RemoteDirectory]: + """Returns the list of records in the repository. + + If writeable is True, only records that the user can write to will be returned. + The user_context might be required to authenticate the user in the repository. + """ raise NotImplementedError() def get_files_in_record( - self, record_id: str, write_intent: bool, user_context: OptionalUserContext = None + self, record_id: str, writeable: bool, user_context: OptionalUserContext = None ) -> List[RemoteFile]: - """Returns the list of files contained in the given record.""" + """Returns the list of files contained in the given record. + + If writeable is True, we are signaling that the user intends to write to the record. + """ raise NotImplementedError() def create_draft_record(self, title: str, user_context: OptionalUserContext = None): diff --git a/lib/galaxy/files/sources/invenio.py b/lib/galaxy/files/sources/invenio.py index 28be0d97da40..d7debf620660 100644 --- a/lib/galaxy/files/sources/invenio.py +++ b/lib/galaxy/files/sources/invenio.py @@ -128,12 +128,12 @@ def _list( user_context: OptionalUserContext = None, opts: Optional[FilesSourceOptions] = None, ): - write_intent = opts and opts.write_intent or False + writeable = opts and opts.writeable or False is_root_path = path == "/" if is_root_path: - return self.repository.get_records(write_intent, user_context) + return self.repository.get_records(writeable, user_context) record_id, _ = self.parse_path(path) - return self.repository.get_files_in_record(record_id, write_intent, user_context) + return self.repository.get_files_in_record(record_id, writeable, user_context) def _create_entry( self, @@ -181,17 +181,18 @@ def records_url(self) -> str: def to_plugin_uri(self, record_id: str, filename: Optional[str] = None) -> str: return f"{self.plugin.get_uri_root()}/{record_id}{f'/{filename}' if filename else ''}" - def get_records(self, write_intent: bool, user_context: OptionalUserContext = None) -> List[RemoteDirectory]: + def get_records(self, writeable: bool, user_context: OptionalUserContext = None) -> List[RemoteDirectory]: # Only draft records owned by the user can be written to. - request_url = f"{self.repository_url}/api/user/records?is_published=false" if write_intent else self.records_url + request_url = f"{self.repository_url}/api/user/records?is_published=false" if writeable else self.records_url # TODO: This is limited to 25 records by default. Add pagination support? response_data = self._get_response(user_context, request_url) return self._get_records_from_response(response_data) def get_files_in_record( - self, record_id: str, write_intent: bool, user_context: OptionalUserContext = None + self, record_id: str, writeable: bool, user_context: OptionalUserContext = None ) -> List[RemoteFile]: - request_url = f"{self.records_url}/{record_id}{'/draft' if write_intent else ''}/files" + conditionally_draft = "/draft" if writeable else "" + request_url = f"{self.records_url}/{record_id}{conditionally_draft}/files" response_data = self._get_response(user_context, request_url) return self._get_record_files_from_response(record_id, response_data) diff --git a/lib/galaxy/managers/remote_files.py b/lib/galaxy/managers/remote_files.py index 863c0494436c..8964661b9f13 100644 --- a/lib/galaxy/managers/remote_files.py +++ b/lib/galaxy/managers/remote_files.py @@ -44,7 +44,7 @@ def index( format: Optional[RemoteFilesFormat], recursive: Optional[bool], disable: Optional[RemoteFilesDisableMode], - write_intent: Optional[bool] = False, + writeable: Optional[bool] = False, ) -> AnyRemoteFilesListResponse: """Returns a list of remote files available to the user.""" @@ -81,7 +81,7 @@ def index( file_source = file_source_path.file_source opts = FilesSourceOptions() - opts.write_intent = write_intent or False + opts.writeable = writeable or False try: index = file_source.list( file_source_path.path, diff --git a/lib/galaxy/webapps/galaxy/api/remote_files.py b/lib/galaxy/webapps/galaxy/api/remote_files.py index 856d70018d46..6ef5b4df72d6 100644 --- a/lib/galaxy/webapps/galaxy/api/remote_files.py +++ b/lib/galaxy/webapps/galaxy/api/remote_files.py @@ -62,9 +62,9 @@ ), ) -WriteIntentQueryParam: Optional[bool] = Query( +WriteableQueryParam: Optional[bool] = Query( default=None, - title="Write Intent", + title="Writeable", description=( "Whether the query is made with the intention of writing to the source." " If set to True, only entries that can be written to will be returned." @@ -110,10 +110,10 @@ async def index( format: Optional[RemoteFilesFormat] = FormatQueryParam, recursive: Optional[bool] = RecursiveQueryParam, disable: Optional[RemoteFilesDisableMode] = DisableModeQueryParam, - write_intent: Optional[bool] = WriteIntentQueryParam, + writeable: Optional[bool] = WriteableQueryParam, ) -> AnyRemoteFilesListResponse: """Lists all remote files available to the user from different sources.""" - return self.manager.index(user_ctx, target, format, recursive, disable, write_intent=write_intent) + return self.manager.index(user_ctx, target, format, recursive, disable, writeable) @router.get( "/api/remote_files/plugins", From f674acf69af833aa7c80a6e58cea722f46545a27 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 1 Aug 2023 13:53:54 +0200 Subject: [PATCH 22/41] Remove rdm_only parameter --- client/src/schema/schema.ts | 2 -- lib/galaxy/files/__init__.py | 3 --- lib/galaxy/managers/remote_files.py | 4 +--- lib/galaxy/webapps/galaxy/api/remote_files.py | 11 +---------- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index 0466dd8d2565..21e6d861505a 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -16196,10 +16196,8 @@ export interface operations { */ parameters?: { /** @description Whether to return browsable filesources only. The default is `True`, which will omit filesourceslike `http` and `base64` that do not implement a list method. */ - /** @description Whether to return only RDM compatible plugins. The default is `False`, which will return all plugins. */ query?: { browsable_only?: boolean; - rdm_only?: boolean; }; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ header?: { diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py index 97f8c4765054..88184c7862bc 100644 --- a/lib/galaxy/files/__init__.py +++ b/lib/galaxy/files/__init__.py @@ -165,14 +165,11 @@ def plugins_to_dict( for_serialization: bool = False, user_context: Optional["FileSourceDictifiable"] = None, browsable_only: Optional[bool] = False, - rdm_only: Optional[bool] = False, ) -> List[Dict[str, Any]]: rval = [] for file_source in self._file_sources: if not file_source.user_has_access(user_context): continue - if rdm_only and not getattr(file_source, "supports_rdm", False): - continue if browsable_only and not file_source.get_browsable(): continue el = file_source.to_dict(for_serialization=for_serialization, user_context=user_context) diff --git a/lib/galaxy/managers/remote_files.py b/lib/galaxy/managers/remote_files.py index 8964661b9f13..3075e6e55b8a 100644 --- a/lib/galaxy/managers/remote_files.py +++ b/lib/galaxy/managers/remote_files.py @@ -128,16 +128,14 @@ def index( return index def get_files_source_plugins( - self, user_context: ProvidesUserContext, browsable_only: Optional[bool] = True, rdm_only: Optional[bool] = False + self, user_context: ProvidesUserContext, browsable_only: Optional[bool] = True ) -> FilesSourcePluginList: """Display plugin information for each of the gxfiles:// URI targets available.""" user_file_source_context = ProvidesUserFileSourcesUserContext(user_context) browsable_only = True if browsable_only is None else browsable_only - rdm_only = rdm_only or False plugins_dict = self._file_sources.plugins_to_dict( user_context=user_file_source_context, browsable_only=browsable_only, - rdm_only=rdm_only, ) plugins = [FilesSourcePlugin(**plugin_dict) for plugin_dict in plugins_dict] return FilesSourcePluginList.construct(__root__=plugins) diff --git a/lib/galaxy/webapps/galaxy/api/remote_files.py b/lib/galaxy/webapps/galaxy/api/remote_files.py index 6ef5b4df72d6..06566b663863 100644 --- a/lib/galaxy/webapps/galaxy/api/remote_files.py +++ b/lib/galaxy/webapps/galaxy/api/remote_files.py @@ -80,14 +80,6 @@ ), ) -RDMOnlyQueryParam: Optional[bool] = Query( - default=False, - title="RDM only", - description=( - "Whether to return only RDM compatible plugins. The default is `False`, which will return all plugins." - ), -) - @router.cbv class FastAPIRemoteFiles: @@ -124,10 +116,9 @@ async def plugins( self, user_ctx: ProvidesUserContext = DependsOnTrans, browsable_only: Optional[bool] = BrowsableQueryParam, - rdm_only: Optional[bool] = RDMOnlyQueryParam, ) -> FilesSourcePluginList: """Display plugin information for each of the gxfiles:// URI targets available.""" - return self.manager.get_files_source_plugins(user_ctx, browsable_only, rdm_only) + return self.manager.get_files_source_plugins(user_ctx, browsable_only) @router.post( "/api/remote_files", From 00d79e2d216e8d334be3ec89f987ed5e452839a7 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 1 Aug 2023 14:23:28 +0200 Subject: [PATCH 23/41] Fix FilesSourcePluginList schema The common field `browsable` was missing and only when browsable is True we require the `uri_root`. We can also drop the `Extra.allow` config in FilesSourcePlugin model because the specific fields for different file sources are not returned in the API response since `plugins_to_dict` is called with for_serialization=False by default. --- client/src/schema/schema.ts | 73 ++++++++++++++++++++++++++--- lib/galaxy/managers/remote_files.py | 16 +++---- lib/galaxy/schema/remote_files.py | 28 ++++++----- 3 files changed, 89 insertions(+), 28 deletions(-) diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index 21e6d861505a..ee9f9644a2f9 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -2212,6 +2212,63 @@ export interface components { */ variant: components["schemas"]["NotificationVariant"]; }; + /** + * BrowsableFilesSourcePlugin + * @description Base model definition with common configuration used by all derived models. + */ + BrowsableFilesSourcePlugin: { + /** + * Browsable + * @enum {boolean} + */ + browsable: true; + /** + * Documentation + * @description Documentation or extended description for this plugin. + * @example Galaxy's library import directory + */ + doc: string; + /** + * ID + * @description The `FilesSource` plugin identifier + * @example _import + */ + id: string; + /** + * Label + * @description The display label for this plugin. + * @example Library Import Directory + */ + label: string; + /** + * Requires groups + * @description Only users belonging to the groups specified here can access this files source. + */ + requires_groups?: string; + /** + * Requires roles + * @description Only users with the roles specified here can access this files source. + */ + requires_roles?: string; + /** + * Type + * @description The type of the plugin. + * @example gximport + */ + type: string; + /** + * URI root + * @description The URI root used by this type of plugin. + * @example gximport:// + */ + uri_root: string; + /** + * Writeable + * @description Whether this files source plugin allows write access. + * @example false + */ + writable: boolean; + }; /** * BulkOperationItemError * @description Base model definition with common configuration used by all derived models. @@ -4345,6 +4402,11 @@ export interface components { * @description Base model definition with common configuration used by all derived models. */ FilesSourcePlugin: { + /** + * Browsable + * @description Whether this file source plugin can list items. + */ + browsable: boolean; /** * Documentation * @description Documentation or extended description for this plugin. @@ -4379,12 +4441,6 @@ export interface components { * @example gximport */ type: string; - /** - * URI root - * @description The URI root used by this type of plugin. - * @example gximport:// - */ - uri_root: string; /** * Writeable * @description Whether this files source plugin allows write access. @@ -4408,7 +4464,10 @@ export interface components { * } * ] */ - FilesSourcePluginList: components["schemas"]["FilesSourcePlugin"][]; + FilesSourcePluginList: ( + | components["schemas"]["BrowsableFilesSourcePlugin"] + | components["schemas"]["FilesSourcePlugin"] + )[]; /** * FolderLibraryFolderItem * @description Base model definition with common configuration used by all derived models. diff --git a/lib/galaxy/managers/remote_files.py b/lib/galaxy/managers/remote_files.py index 3075e6e55b8a..fed33051fda9 100644 --- a/lib/galaxy/managers/remote_files.py +++ b/lib/galaxy/managers/remote_files.py @@ -1,7 +1,12 @@ import hashlib import logging from operator import itemgetter -from typing import Optional +from typing import ( + Any, + Dict, + List, + Optional, +) from galaxy import exceptions from galaxy.files import ( @@ -14,8 +19,6 @@ AnyRemoteFilesListResponse, CreatedEntryResponse, CreateEntryPayload, - FilesSourcePlugin, - FilesSourcePluginList, RemoteFilesDisableMode, RemoteFilesFormat, RemoteFilesTarget, @@ -127,9 +130,7 @@ def index( return index - def get_files_source_plugins( - self, user_context: ProvidesUserContext, browsable_only: Optional[bool] = True - ) -> FilesSourcePluginList: + def get_files_source_plugins(self, user_context: ProvidesUserContext, browsable_only: Optional[bool] = True): """Display plugin information for each of the gxfiles:// URI targets available.""" user_file_source_context = ProvidesUserFileSourcesUserContext(user_context) browsable_only = True if browsable_only is None else browsable_only @@ -137,8 +138,7 @@ def get_files_source_plugins( user_context=user_file_source_context, browsable_only=browsable_only, ) - plugins = [FilesSourcePlugin(**plugin_dict) for plugin_dict in plugins_dict] - return FilesSourcePluginList.construct(__root__=plugins) + return plugins_dict @property def _file_sources(self) -> ConfiguredFileSources: diff --git a/lib/galaxy/schema/remote_files.py b/lib/galaxy/schema/remote_files.py index a408f076a9c8..100a61a3998f 100644 --- a/lib/galaxy/schema/remote_files.py +++ b/lib/galaxy/schema/remote_files.py @@ -7,7 +7,6 @@ ) from pydantic import ( - Extra, Field, Required, ) @@ -49,12 +48,6 @@ class FilesSourcePlugin(Model): description="The type of the plugin.", example="gximport", ) - uri_root: str = Field( - Required, - title="URI root", - description="The URI root used by this type of plugin.", - example="gximport://", - ) label: str = Field( Required, title="Label", @@ -67,6 +60,11 @@ class FilesSourcePlugin(Model): description="Documentation or extended description for this plugin.", example="Galaxy's library import directory", ) + browsable: bool = Field( + Required, + title="Browsable", + description="Whether this file source plugin can list items.", + ) writable: bool = Field( Required, title="Writeable", @@ -84,15 +82,19 @@ class FilesSourcePlugin(Model): description="Only users belonging to the groups specified here can access this files source.", ) - class Config: - # This allows additional fields (that are not validated) - # to be serialized/deserealized. This allows to have - # different fields depending on the plugin type - extra = Extra.allow + +class BrowsableFilesSourcePlugin(FilesSourcePlugin): + browsable: Literal[True] + uri_root: str = Field( + Required, + title="URI root", + description="The URI root used by this type of plugin.", + example="gximport://", + ) class FilesSourcePluginList(Model): - __root__: List[FilesSourcePlugin] = Field( + __root__: List[Union[BrowsableFilesSourcePlugin, FilesSourcePlugin]] = Field( default=[], title="List of files source plugins", example=[ From 8876828c3fa523812b342d43d5deee8ac56e84bf Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 1 Aug 2023 15:50:23 +0200 Subject: [PATCH 24/41] Add PluginKind to file sources and allow filtering --- .../src/components/Common/ExportDOIForm.vue | 8 +++-- client/src/components/Common/ExportForm.vue | 10 +++++- .../components/FilesDialog/FilesDialog.vue | 27 +++++++------- .../src/components/FilesDialog/FilesInput.vue | 9 +++-- client/src/components/FilesDialog/services.ts | 36 +++++++++++++++---- .../src/components/FilesDialog/testingData.ts | 7 ++-- .../History/Export/HistoryExport.vue | 4 +-- client/src/composables/fileSources.ts | 14 +++++--- client/src/schema/schema.ts | 10 ++++++ lib/galaxy/files/__init__.py | 11 +++++- lib/galaxy/files/sources/__init__.py | 36 +++++++++++++++++++ lib/galaxy/files/sources/_rdm.py | 5 ++- lib/galaxy/files/sources/base64.py | 2 ++ lib/galaxy/files/sources/drs.py | 2 ++ lib/galaxy/files/sources/galaxy.py | 4 +++ lib/galaxy/files/sources/http.py | 2 ++ lib/galaxy/managers/remote_files.py | 19 +++++++--- lib/galaxy/webapps/galaxy/api/remote_files.py | 32 +++++++++++++++-- 18 files changed, 193 insertions(+), 45 deletions(-) diff --git a/client/src/components/Common/ExportDOIForm.vue b/client/src/components/Common/ExportDOIForm.vue index 3b72eedb652f..10c14cc058a0 100644 --- a/client/src/components/Common/ExportDOIForm.vue +++ b/client/src/components/Common/ExportDOIForm.vue @@ -2,7 +2,7 @@ import { BButton, BCard, BFormGroup, BFormInput, BFormRadio, BFormRadioGroup } from "bootstrap-vue"; import { computed, ref } from "vue"; -import { CreatedEntry, createRemoteEntry } from "@/components/FilesDialog/services"; +import { CreatedEntry, createRemoteEntry, FilterFileSourcesOptions } from "@/components/FilesDialog/services"; import localize from "@/utils/localization"; import ExternalLink from "@/components/ExternalLink.vue"; @@ -28,6 +28,8 @@ const emit = defineEmits<{ type ExportChoice = "existing" | "new"; +const includeOnlyDOICompatible: FilterFileSourcesOptions = { include: ["rdm"] }; + const recordUri = ref(""); const sourceUri = ref(""); const fileName = ref(props.defaultFilename); @@ -115,7 +117,7 @@ function clearInputs() { v-model="sourceUri" mode="source" :require-writable="true" - :rdm-only="true" /> + :filter-options="includeOnlyDOICompatible" /> + :filter-options="includeOnlyDOICompatible" /> (); +const defaultExportFilterOptions: FilterFileSourcesOptions = { exclude: ["rdm"] }; + const directory = ref(""); const name = ref(""); @@ -43,7 +46,12 @@ const doExport = () => { From b793678a7086d34877140ab22553492f1ac616d7 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 14 Aug 2023 16:58:42 +0200 Subject: [PATCH 35/41] Integrate on History Archive Export Selector --- .../HistoryArchiveExportSelector.vue | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/client/src/components/History/Archiving/HistoryArchiveExportSelector.vue b/client/src/components/History/Archiving/HistoryArchiveExportSelector.vue index acad61d33d50..b3d372fa1125 100644 --- a/client/src/components/History/Archiving/HistoryArchiveExportSelector.vue +++ b/client/src/components/History/Archiving/HistoryArchiveExportSelector.vue @@ -1,6 +1,7 @@