From 1dda750dd512724dc8451e309598c821cfaf2e85 Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 9 Dec 2023 10:13:08 +0300 Subject: [PATCH 01/50] Add histories grid to router --- client/src/entry/analysis/router.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index b0618f00a18d..e9b5bd6bdf11 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -7,6 +7,7 @@ import DatasetAttributes from "components/DatasetInformation/DatasetAttributes"; import DatasetDetails from "components/DatasetInformation/DatasetDetails"; import DatasetError from "components/DatasetInformation/DatasetError"; import FormGeneric from "components/Form/FormGeneric"; +import historiesGridConfig from "components/Grid/configs/histories"; import visualizationsGridConfig from "components/Grid/configs/visualizations"; import visualizationsPublishedGridConfig from "components/Grid/configs/visualizationsPublished"; import GridHistory from "components/Grid/GridHistory"; @@ -291,6 +292,13 @@ export function getRouter(Galaxy) { path: "histories/archived", component: HistoryArchive, }, + { + path: "histories/list", + component: GridList, + props: { + gridConfig: historiesGridConfig, + }, + }, { path: "histories/:actionId", component: GridHistory, From 69b616a747f20dafde33f0fe300d58c1724fc53f Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 9 Dec 2023 20:18:06 +0300 Subject: [PATCH 02/50] Add query endpoint to history api --- lib/galaxy/webapps/galaxy/api/histories.py | 78 +++++++++++++++++++--- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index 089441283fb6..5f8feb998863 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -54,13 +54,20 @@ WriteStoreToPayload, ) from galaxy.schema.types import LatestLiteral +from galaxy.schema.history import ( + HistoryIndexQueryPayload, + HistorySortByEnum, + HistorySummaryList, +) from galaxy.webapps.base.api import GalaxyFileResponse from galaxy.webapps.galaxy.api import ( as_form, depends, DependsOnTrans, + IndexQueryTag, Router, try_get_request_body_as_json, + search_query_param, ) from galaxy.webapps.galaxy.api.common import ( get_filter_query_params, @@ -75,6 +82,25 @@ router = Router(tags=["histories"]) +query_tags = [ + IndexQueryTag("title", "The history's title."), + IndexQueryTag("description", "The history's description.", "d"), + IndexQueryTag("tag", "The history's tags.", "t"), + IndexQueryTag("user", "The history's owner's username.", "u"), +] + +AllHistoriesQueryParam = Query( + default=False, + title="All Histories", + description=( + "Whether all histories from other users in this Galaxy should be included. " + "Only admins are allowed to query all histories." + ), +) + +HistoryIDPathParam: DecodedDatabaseIdField = Path( + ..., title="History ID", description="The encoded database identifier of the History." +) JehaIDPathParam: Union[DecodedDatabaseIdField, LatestLiteral] = Path( title="Job Export History ID", @@ -85,15 +111,25 @@ examples=["latest"], ) -AllHistoriesQueryParam = Query( - default=False, - title="All Histories", - description=( - "Whether all histories from other users in this Galaxy should be included. " - "Only admins are allowed to query all histories." - ), +SearchQueryParam: Optional[str] = search_query_param( + model_name="History", + tags=query_tags, + free_text_fields=["title", "description", "slug", "tag"], ) +ShowPublishedQueryParam: bool = Query(default=False, title="Restrict to published histories and those shared with authenticated user.", description="") + +SortByQueryParam: HistorySortByEnum = Query( + default="update_time", + title="Sort attribute", + description="Sort index by this specified attribute", +) + +SortDescQueryParam: bool = Query( + default=True, + title="Sort Descending", + description="Sort in descending order?", +) class DeleteHistoryPayload(BaseModel): purge: bool = Field( @@ -105,7 +141,6 @@ class DeleteHistoryPayload(BaseModel): class CreateHistoryFormData(CreateHistoryPayload): """Uses Form data instead of JSON""" - @router.cbv class FastAPIHistories: service: HistoriesService = depends(HistoriesService) @@ -131,6 +166,33 @@ def index( trans, serialization_params, filter_query_params, deleted_only=deleted, all_histories=all ) + @router.get( + "/api/histories/query", + summary="Returns histories available to the current user.", + ) + async def index_query( + self, + response: Response, + trans: ProvidesUserContext = DependsOnTrans, + limit: Optional[int] = LimitQueryParam, + offset: Optional[int] = OffsetQueryParam, + show_published: bool = ShowPublishedQueryParam, + sort_by: HistorySortByEnum = SortByQueryParam, + sort_desc: bool = SortDescQueryParam, + search: Optional[str] = SearchQueryParam, + ) -> HistorySummaryList: + payload = HistoryIndexQueryPayload.construct( + show_published=show_published, + sort_by=sort_by, + sort_desc=sort_desc, + limit=limit, + offset=offset, + search=search, + ) + entries, total_matches = self.service.index(trans, payload, include_total_count=True) + response.headers["total_matches"] = str(total_matches) + return entries + @router.get( "/api/histories/count", summary="Returns number of histories for the current user.", From 2dbbbb2e2acc57f27d7e304d908d8ee0bc778381 Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 9 Dec 2023 20:24:44 +0300 Subject: [PATCH 03/50] Add service layer for history query endpoint --- .../webapps/galaxy/services/histories.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/galaxy/webapps/galaxy/services/histories.py b/lib/galaxy/webapps/galaxy/services/histories.py index 6042bec0be29..a398146c3180 100644 --- a/lib/galaxy/webapps/galaxy/services/histories.py +++ b/lib/galaxy/webapps/galaxy/services/histories.py @@ -51,6 +51,10 @@ SerializationParams, ) from galaxy.schema.fields import DecodedDatabaseIdField +from galaxy.schema.history import ( + HistoryIndexQueryPayload, + HistorySummaryList, +) from galaxy.schema.schema import ( AnyArchivedHistoryView, AnyHistoryView, @@ -211,6 +215,23 @@ def _get_deleted_filter(self, deleted: Optional[bool], filter_params: List[Tuple # otherwise, do the default filter of removing the deleted histories return [model.History.deleted == false()] + def index_query( + self, + trans, + payload: HistoryIndexQueryPayload, + include_total_count: bool = False, + ) -> Tuple[HistorySummaryList, int]: + """Return a list of History accessible by the user + + :rtype: list + :returns: dictionaries containing History details + """ + entries, total_matches = self.manager.index_query(trans, payload, include_total_count) + return ( + HistorySummaryList(__root__=[entry.to_dict() for entry in entries]), + total_matches, + ) + def create( self, trans: ProvidesHistoryContext, From edd60e612444992f246d6eebe7573a26a4e72ad9 Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 9 Dec 2023 20:40:47 +0300 Subject: [PATCH 04/50] Add draft of history query to history manager --- lib/galaxy/managers/histories.py | 103 +++++++++++++++++- lib/galaxy/webapps/galaxy/api/histories.py | 20 ++-- .../webapps/galaxy/services/histories.py | 2 +- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index 77f6ca5d11c7..3abb97cde7af 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -12,6 +12,7 @@ List, Optional, Set, + Tuple, Union, ) @@ -21,6 +22,7 @@ exists, false, func, + or_, select, true, ) @@ -43,6 +45,7 @@ SortableManager, StorageCleanerManager, ) +from galaxy.managers.context import ProvidesUserContext from galaxy.managers.export_tracker import StoreExportTracker from galaxy.model import ( History, @@ -50,7 +53,11 @@ Job, ) from galaxy.model.base import transaction -from galaxy.schema.fields import Security +from galaxy.schema.fields import ( + DecodedDatabaseIdField, + HistoryIndexQueryPayload, + Security, +) from galaxy.schema.schema import ( ExportObjectMetadata, ExportObjectType, @@ -69,6 +76,13 @@ log = logging.getLogger(__name__) +INDEX_SEARCH_FILTERS = { + "name": "name", + "annotation": "annotation", + "tag": "tag", + "is": "is", +} + class HistoryManager(sharable.SharableModelManager, deletable.PurgableManagerMixin, SortableManager): model_class = model.History @@ -93,6 +107,93 @@ def __init__( self.contents_manager = contents_manager self.contents_filters = contents_filters + def index_query( + self, trans: ProvidesUserContext, payload: HistoryIndexQueryPayload, include_total_count: bool = False + ) -> Tuple[List[model.History], int]: + show_deleted = False + show_published = payload.show_published + is_admin = trans.user_is_admin + user = trans.user + + query = trans.sa_session.query(self.model_class) + + filters = [] + if not show_published and not is_admin: + filters = [self.model_class.user == user] + if show_published: + filters.append(self.model_class.published == true()) + if user and show_published: + filters.append(self.user_share_model.user == user) + query = query.outerjoin(self.model_class.users_shared_with) + query = query.filter(or_(*filters)) + + if payload.search: + search_query = payload.search + parsed_search = parse_filters_structured(search_query, INDEX_SEARCH_FILTERS) + + def p_tag_filter(term_text: str, quoted: bool): + nonlocal query + alias = aliased(model.HistoryTagAssociation) + query = query.outerjoin(self.model_class.tags.of_type(alias)) + return tag_filter(alias, term_text, quoted) + + for term in parsed_search.terms: + if isinstance(term, FilteredTerm): + key = term.filter + q = term.text + if key == "tag": + pg = p_tag_filter(term.text, term.quoted) + query = query.filter(pg) + elif key == "name": + query = query.filter(text_column_filter(self.model_class.name, term)) + elif key == "annotation": + query = query.filter(text_column_filter(self.model_class.annotation, term)) + elif key == "user": + query = append_user_filter(query, self.model_class, term) + elif key == "is": + if q == "deleted": + show_deleted = True + if q == "published": + query = query.filter(self.model_class.published == true()) + if q == "importable": + query = query.filter(self.model_class.importable == true()) + elif q == "shared_with_me": + if not show_published: + message = "Can only use tag is:shared_with_me if show_published parameter also true." + raise exceptions.RequestParameterInvalidException(message) + query = query.filter(self.user_share_model.user == user) + elif isinstance(term, RawTextTerm): + tf = p_tag_filter(term.text, False) + alias = aliased(model.User) + query = query.outerjoin(self.model_class.user.of_type(alias)) + query = query.filter( + raw_text_column_filter( + [ + self.model_class.title, + self.model_class.annotation, + tf, + alias.username, + ], + term, + ) + ) + + query = query.filter(self.model_class.deleted == (true() if show_deleted else false())) + + if include_total_count: + total_matches = query.count() + else: + total_matches = None + sort_column = getattr(model.History, payload.sort_by) + if payload.sort_desc: + sort_column = sort_column.desc() + query = query.order_by(sort_column) + if payload.limit is not None: + query = query.limit(payload.limit) + if payload.offset is not None: + query = query.offset(payload.offset) + return query, total_matches + def copy(self, history, user, **kwargs): """ Copy and return the given `history`. diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index 5f8feb998863..ce30b732de00 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -32,6 +32,11 @@ SerializationParams, ) from galaxy.schema.fields import DecodedDatabaseIdField +from galaxy.schema.history import ( + HistoryIndexQueryPayload, + HistorySortByEnum, + HistorySummaryList, +) from galaxy.schema.schema import ( AnyArchivedHistoryView, AnyHistoryView, @@ -54,11 +59,6 @@ WriteStoreToPayload, ) from galaxy.schema.types import LatestLiteral -from galaxy.schema.history import ( - HistoryIndexQueryPayload, - HistorySortByEnum, - HistorySummaryList, -) from galaxy.webapps.base.api import GalaxyFileResponse from galaxy.webapps.galaxy.api import ( as_form, @@ -66,8 +66,8 @@ DependsOnTrans, IndexQueryTag, Router, - try_get_request_body_as_json, search_query_param, + try_get_request_body_as_json, ) from galaxy.webapps.galaxy.api.common import ( get_filter_query_params, @@ -117,7 +117,9 @@ free_text_fields=["title", "description", "slug", "tag"], ) -ShowPublishedQueryParam: bool = Query(default=False, title="Restrict to published histories and those shared with authenticated user.", description="") +ShowPublishedQueryParam: bool = Query( + default=False, title="Restrict to published histories and those shared with authenticated user.", description="" +) SortByQueryParam: HistorySortByEnum = Query( default="update_time", @@ -131,6 +133,7 @@ description="Sort in descending order?", ) + class DeleteHistoryPayload(BaseModel): purge: bool = Field( default=False, title="Purge", description="Whether to definitely remove this history from disk." @@ -141,6 +144,7 @@ class DeleteHistoryPayload(BaseModel): class CreateHistoryFormData(CreateHistoryPayload): """Uses Form data instead of JSON""" + @router.cbv class FastAPIHistories: service: HistoriesService = depends(HistoriesService) @@ -189,7 +193,7 @@ async def index_query( offset=offset, search=search, ) - entries, total_matches = self.service.index(trans, payload, include_total_count=True) + entries, total_matches = self.service.index_query(trans, payload, include_total_count=True) response.headers["total_matches"] = str(total_matches) return entries diff --git a/lib/galaxy/webapps/galaxy/services/histories.py b/lib/galaxy/webapps/galaxy/services/histories.py index a398146c3180..c9262d8ba746 100644 --- a/lib/galaxy/webapps/galaxy/services/histories.py +++ b/lib/galaxy/webapps/galaxy/services/histories.py @@ -228,7 +228,7 @@ def index_query( """ entries, total_matches = self.manager.index_query(trans, payload, include_total_count) return ( - HistorySummaryList(__root__=[entry.to_dict() for entry in entries]), + HistorySummaryList(__root__=[entry.to_dict(view="element") for entry in entries]), total_matches, ) From 7faae388202c8391d416bafc5e04926aac4dd8e2 Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 9 Dec 2023 22:23:38 +0300 Subject: [PATCH 05/50] Add attributes to the history serializer? --- lib/galaxy/model/__init__.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index dfda561433a0..ae4ca1045fe3 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -3020,16 +3020,20 @@ class History(Base, HasTags, Dictifiable, UsesAnnotations, HasName, Serializable dict_element_visible_keys = [ "id", "name", - "genome_build", + "archived", + "annotation", + "create_time", "deleted", + "empty", + "genome_build", + "importable", + "preferred_object_store_id", "purged", - "archived", - "update_time", "published", - "importable", "slug", - "empty", - "preferred_object_store_id", + "tags", + "update_time", + "user", ] default_name = "Unnamed history" From 0260d4543cec46f9d73dfb4d1674149278d62570 Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 9 Dec 2023 22:27:22 +0300 Subject: [PATCH 06/50] Sketch out history grid operations --- client/src/api/histories.ts | 1 + client/src/api/schema/schema.ts | 128 ++++++++++ .../src/components/Grid/configs/histories.ts | 224 ++++++++++++++++++ lib/galaxy/managers/histories.py | 22 +- lib/galaxy/model/__init__.py | 2 +- lib/galaxy/webapps/galaxy/api/histories.py | 9 +- .../webapps/galaxy/services/histories.py | 6 +- 7 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 client/src/components/Grid/configs/histories.ts diff --git a/client/src/api/histories.ts b/client/src/api/histories.ts index 65ad7e2d6b1d..ca19660b2c34 100644 --- a/client/src/api/histories.ts +++ b/client/src/api/histories.ts @@ -4,3 +4,4 @@ export const historiesFetcher = fetcher.path("/api/histories").method("get").cre export const archivedHistoriesFetcher = fetcher.path("/api/histories/archived").method("get").create(); export const undeleteHistory = fetcher.path("/api/histories/deleted/{history_id}/undelete").method("post").create(); export const purgeHistory = fetcher.path("/api/histories/{history_id}").method("delete").create(); +export const historiesQuery = fetcher.path("/api/histories/query").method("get").create(); diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 6175fdc94d4b..726e4d0c9fc0 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -521,6 +521,10 @@ export interface paths { /** Return all histories that are published. */ get: operations["published_api_histories_published_get"]; }; + "/api/histories/query": { + /** Returns histories available to the current user. */ + get: operations["index_query_api_histories_query_get"]; + }; "/api/histories/shared_with_me": { /** Return all histories that are shared with the current user. */ get: operations["shared_with_me_api_histories_shared_with_me_get"]; @@ -6347,6 +6351,62 @@ export interface components { user_id?: string | null; [key: string]: unknown | undefined; }; + /** HistoryQueryResult */ + HistoryQueryResult: { + /** + * Annotation + * @description The annotation of this History. + */ + annotation?: string; + /** + * Create Time + * Format: date-time + * @description The time and date this item was created. + */ + create_time?: string; + /** + * Deleted + * @description Whether this History has been deleted. + */ + deleted: boolean; + /** + * ID + * @description Encoded ID of the History. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Importable + * @description Whether this History can be imported. + */ + importable: boolean; + /** + * Name + * @description The name of the History. + */ + name: string; + /** + * Published + * @description Whether this History has been published. + */ + published: boolean; + /** + * Tags + * @description A list of tags to add to this item. + */ + tags: components["schemas"]["TagCollection"]; + /** + * Update Time + * Format: date-time + * @description The last time and date this item was updated. + */ + update_time?: string; + }; + /** + * HistoryQueryResultList + * @default [] + */ + HistoryQueryResultList: components["schemas"]["HistoryQueryResult"][]; /** * HistorySummary * @description History summary information. @@ -14012,6 +14072,74 @@ export interface operations { }; }; }; + index_query_api_histories_query_get: { + /** Returns histories available to the current user. */ + parameters?: { + /** @description The maximum number of items to return. */ + /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item */ + /** @description Sort index by this specified attribute */ + /** @description Sort in descending order? */ + /** + * @description A mix of free text and GitHub-style tags used to filter the index operation. + * + * ## Query Structure + * + * GitHub-style filter tags (not be confused with Galaxy tags) are tags of the form + * `:` or `:''`. The tag name + * *generally* (but not exclusively) corresponds to the name of an attribute on the model + * being indexed (i.e. a column in the database). + * + * If the tag is quoted, the attribute will be filtered exactly. If the tag is unquoted, + * generally a partial match will be used to filter the query (i.e. in terms of the implementation + * this means the database operation `ILIKE` will typically be used). + * + * Once the tagged filters are extracted from the search query, the remaining text is just + * used to search various documented attributes of the object. + * + * ## GitHub-style Tags Available + * + * `name` + * : The history's name. + * + * `annotation` + * : The history's annotation. (The tag `a` can be used a short hand alias for this tag to filter on this attribute.) + * + * `tag` + * : The history's tags. (The tag `t` can be used a short hand alias for this tag to filter on this attribute.) + * + * ## Free Text + * + * Free text search terms will be searched against the following attributes of the + * Historys: `title`, `description`, `slug`, `tag`. + */ + query?: { + limit?: number; + offset?: number; + show_published?: boolean; + sort_by?: "create_time" | "name" | "update_time"; + sort_desc?: boolean; + search?: string; + }; + /** @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; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["HistoryQueryResultList"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; shared_with_me_api_histories_shared_with_me_get: { /** Return all histories that are shared with the current user. */ parameters?: { diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts new file mode 100644 index 000000000000..ccde1f89a9c1 --- /dev/null +++ b/client/src/components/Grid/configs/histories.ts @@ -0,0 +1,224 @@ +import { faCopy, faEdit, faExchangeAlt, faEye, faPlus, faShareAlt, faSignature, faTrash, faTrashRestore } from "@fortawesome/free-solid-svg-icons"; +import { useEventBus } from "@vueuse/core"; + +import { deleteForm, undeleteForm } from "@/api/forms"; +import { historiesQuery } from "@/api/histories"; +import Filtering, { contains, equals, toBool, type ValidFilter } from "@/utils/filtering"; +import _l from "@/utils/localization"; +import { errorMessageAsString } from "@/utils/simple-error"; + +import type { ActionArray, FieldArray, GridConfig } from "./types"; + +const { emit } = useEventBus("grid-router-push"); + +/** + * Local types + */ +type FormEntry = Record; +type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined; + +/** + * Request and return data from server + */ +async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) { + const { data, headers } = await historiesQuery({ + limit, + offset, + search, + sort_by: sort_by as SortKeyLiteral, + sort_desc, + show_published: false, + }); + const totalMatches = parseInt(headers.get("total_matches") ?? "0"); + return [data, totalMatches]; +} + +/** + * Actions are grid-wide operations + */ +const actions: ActionArray = [ + { + title: "Import New History", + icon: faPlus, + handler: () => { + emit("/admin/form/create_form"); + }, + }, +]; + +/** + * Declare columns to be displayed + */ +const fields: FieldArray = [ + { + key: "name", + title: "Name", + type: "operations", + operations: [ + { + title: "Switch", + icon: faExchangeAlt, + condition: (data: FormEntry) => !data.deleted, + handler: (data: FormEntry) => { + emit(`/histories/view?id=${data.id}`); + }, + }, + { + title: "View", + icon: faEye, + condition: (data: FormEntry) => !data.deleted, + handler: (data: FormEntry) => { + emit(`/histories/view?id=${data.id}`); + }, + }, + { + title: "Share and Publish", + icon: faShareAlt, + condition: (data: FormEntry) => !data.deleted, + handler: (data: FormEntry) => { + emit(`/histories/sharing?id=${data.id}`); + }, + }, + { + title: "Copy", + icon: faCopy, + condition: (data: FormEntry) => !data.deleted, + handler: (data: FormEntry) => { + emit(`/histories/sharing?id=${data.id}`); + }, + }, + { + title: "Change Permissions", + icon: faEdit, + condition: (data: FormEntry) => !data.deleted, + handler: (data: FormEntry) => { + emit(`/histories/permissions?id=${data.id}`); + }, + }, + { + title: "Rename", + icon: faSignature, + condition: (data: FormEntry) => !data.deleted, + handler: (data: FormEntry) => { + emit(`/histories/rename?id=${data.id}`); + }, + }, + { + title: "Delete", + icon: faTrash, + condition: (data: FormEntry) => !data.deleted, + handler: async (data: FormEntry) => { + if (confirm(_l("Are you sure that you want to delete this form?"))) { + try { + await deleteForm({ id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been deleted.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to delete '${data.name}': ${errorMessageAsString(e)}`, + }; + } + } + }, + }, + { + title: "Delete Permanently", + icon: faTrash, + condition: (data: FormEntry) => !data.deleted, + handler: async (data: FormEntry) => { + if (confirm(_l("Are you sure that you want to delete this form?"))) { + try { + await deleteForm({ id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been deleted.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to delete '${data.name}': ${errorMessageAsString(e)}`, + }; + } + } + }, + }, + { + title: "Restore", + icon: faTrashRestore, + condition: (data: FormEntry) => !!data.deleted, + handler: async (data: FormEntry) => { + try { + await undeleteForm({ id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been restored.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to restore '${data.name}': ${errorMessageAsString(e)}`, + }; + } + }, + }, + ], + }, + { + key: "hid_counter", + title: "Items", + type: "text", + }, + { + key: "tags", + title: "Tags", + type: "tags", + }, + { + key: "create_time", + title: "Created", + type: "date", + }, + { + key: "update_time", + title: "Updated", + type: "date", + }, + { + key: "sharing", + title: "Sharing", + type: "sharing", + }, +]; + +const validFilters: Record> = { + name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, + description: { placeholder: "description", type: String, handler: contains("desc"), menuItem: true }, + deleted: { + placeholder: "Filter on deleted entries", + type: Boolean, + boolType: "is", + handler: equals("deleted", "deleted", toBool), + menuItem: true, + }, +}; + +/** + * Grid configuration + */ +const gridConfig: GridConfig = { + id: "histories-grid", + actions: actions, + fields: fields, + filtering: new Filtering(validFilters, undefined, false, false), + getData: getData, + plural: "Histories", + sortBy: "name", + sortDesc: true, + sortKeys: ["create_time", "name", "update_time"], + title: "Histories", +}; + +export default gridConfig; diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index 3abb97cde7af..8d7d83c9ede8 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -26,6 +26,7 @@ select, true, ) +from sqlalchemy.orm import aliased from typing_extensions import Literal from galaxy import ( @@ -58,6 +59,13 @@ HistoryIndexQueryPayload, Security, ) +from galaxy.model.index_filter_util import ( + append_user_filter, + raw_text_column_filter, + tag_filter, + text_column_filter, +) +from galaxy.schema.history import HistoryIndexQueryPayload from galaxy.schema.schema import ( ExportObjectMetadata, ExportObjectType, @@ -73,6 +81,11 @@ ) from galaxy.security.validate_user_input import validate_preferred_object_store_id from galaxy.structured_app import MinimalManagerApp +from galaxy.util.search import ( + FilteredTerm, + parse_filters_structured, + RawTextTerm, +) log = logging.getLogger(__name__) @@ -118,9 +131,9 @@ def index_query( query = trans.sa_session.query(self.model_class) filters = [] - if not show_published and not is_admin: + if not show_published: filters = [self.model_class.user == user] - if show_published: + else: filters.append(self.model_class.published == true()) if user and show_published: filters.append(self.user_share_model.user == user) @@ -146,8 +159,6 @@ def p_tag_filter(term_text: str, quoted: bool): query = query.filter(pg) elif key == "name": query = query.filter(text_column_filter(self.model_class.name, term)) - elif key == "annotation": - query = query.filter(text_column_filter(self.model_class.annotation, term)) elif key == "user": query = append_user_filter(query, self.model_class, term) elif key == "is": @@ -169,8 +180,7 @@ def p_tag_filter(term_text: str, quoted: bool): query = query.filter( raw_text_column_filter( [ - self.model_class.title, - self.model_class.annotation, + self.model_class.name, tf, alias.username, ], diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index ae4ca1045fe3..377870ef9c78 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -3021,11 +3021,11 @@ class History(Base, HasTags, Dictifiable, UsesAnnotations, HasName, Serializable "id", "name", "archived", - "annotation", "create_time", "deleted", "empty", "genome_build", + "hid_counter", "importable", "preferred_object_store_id", "purged", diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index ce30b732de00..3c288a89ab2a 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -34,8 +34,8 @@ from galaxy.schema.fields import DecodedDatabaseIdField from galaxy.schema.history import ( HistoryIndexQueryPayload, + HistoryQueryResultList, HistorySortByEnum, - HistorySummaryList, ) from galaxy.schema.schema import ( AnyArchivedHistoryView, @@ -83,10 +83,9 @@ router = Router(tags=["histories"]) query_tags = [ - IndexQueryTag("title", "The history's title."), - IndexQueryTag("description", "The history's description.", "d"), + IndexQueryTag("name", "The history's name."), + IndexQueryTag("annotation", "The history's annotation.", "a"), IndexQueryTag("tag", "The history's tags.", "t"), - IndexQueryTag("user", "The history's owner's username.", "u"), ] AllHistoriesQueryParam = Query( @@ -184,7 +183,7 @@ async def index_query( sort_by: HistorySortByEnum = SortByQueryParam, sort_desc: bool = SortDescQueryParam, search: Optional[str] = SearchQueryParam, - ) -> HistorySummaryList: + ) -> HistoryQueryResultList: payload = HistoryIndexQueryPayload.construct( show_published=show_published, sort_by=sort_by, diff --git a/lib/galaxy/webapps/galaxy/services/histories.py b/lib/galaxy/webapps/galaxy/services/histories.py index c9262d8ba746..167ab8f7f3fb 100644 --- a/lib/galaxy/webapps/galaxy/services/histories.py +++ b/lib/galaxy/webapps/galaxy/services/histories.py @@ -53,7 +53,7 @@ from galaxy.schema.fields import DecodedDatabaseIdField from galaxy.schema.history import ( HistoryIndexQueryPayload, - HistorySummaryList, + HistoryQueryResultList, ) from galaxy.schema.schema import ( AnyArchivedHistoryView, @@ -220,7 +220,7 @@ def index_query( trans, payload: HistoryIndexQueryPayload, include_total_count: bool = False, - ) -> Tuple[HistorySummaryList, int]: + ) -> Tuple[HistoryQueryResultList, int]: """Return a list of History accessible by the user :rtype: list @@ -228,7 +228,7 @@ def index_query( """ entries, total_matches = self.manager.index_query(trans, payload, include_total_count) return ( - HistorySummaryList(__root__=[entry.to_dict(view="element") for entry in entries]), + HistoryQueryResultList(__root__=[entry.to_dict(view="element") for entry in entries]), total_matches, ) From ee65fbfa27c756bf098e09289b21bc0e70483064 Mon Sep 17 00:00:00 2001 From: guerler Date: Tue, 12 Dec 2023 07:39:13 +0300 Subject: [PATCH 07/50] Add advanced filter options to history grid --- .../src/components/Grid/configs/histories.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts index ccde1f89a9c1..38ee57492013 100644 --- a/client/src/components/Grid/configs/histories.ts +++ b/client/src/components/Grid/configs/histories.ts @@ -3,7 +3,7 @@ import { useEventBus } from "@vueuse/core"; import { deleteForm, undeleteForm } from "@/api/forms"; import { historiesQuery } from "@/api/histories"; -import Filtering, { contains, equals, toBool, type ValidFilter } from "@/utils/filtering"; +import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; import _l from "@/utils/localization"; import { errorMessageAsString } from "@/utils/simple-error"; @@ -188,14 +188,36 @@ const fields: FieldArray = [ }, { key: "sharing", - title: "Sharing", + title: "Shared", type: "sharing", }, ]; +/** + * Declare filter options + */ const validFilters: Record> = { - name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, - description: { placeholder: "description", type: String, handler: contains("desc"), menuItem: true }, + title: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, + tag: { + placeholder: "tag(s)", + type: "MultiTags", + handler: contains("tag", "tag", expandNameTag), + menuItem: true, + }, + published: { + placeholder: "Filter on published entries", + type: Boolean, + boolType: "is", + handler: equals("published", "published", toBool), + menuItem: true, + }, + importable: { + placeholder: "Filter on importable entries", + type: Boolean, + boolType: "is", + handler: equals("importable", "importable", toBool), + menuItem: true, + }, deleted: { placeholder: "Filter on deleted entries", type: Boolean, From 2f15d3a7e6745ecec1f6e0120c09a20df3556697 Mon Sep 17 00:00:00 2001 From: guerler Date: Tue, 12 Dec 2023 07:55:35 +0300 Subject: [PATCH 08/50] Add delete, undelete and purge operations to history grid, fix naming --- client/src/api/histories.ts | 1 + .../src/components/Grid/configs/histories.ts | 83 ++++++++++--------- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/client/src/api/histories.ts b/client/src/api/histories.ts index ca19660b2c34..1482700169f4 100644 --- a/client/src/api/histories.ts +++ b/client/src/api/histories.ts @@ -2,6 +2,7 @@ import { fetcher } from "@/api/schema"; export const historiesFetcher = fetcher.path("/api/histories").method("get").create(); export const archivedHistoriesFetcher = fetcher.path("/api/histories/archived").method("get").create(); +export const deleteHistory = fetcher.path("/api/histories/{history_id}").method("delete").create(); export const undeleteHistory = fetcher.path("/api/histories/deleted/{history_id}/undelete").method("post").create(); export const purgeHistory = fetcher.path("/api/histories/{history_id}").method("delete").create(); export const historiesQuery = fetcher.path("/api/histories/query").method("get").create(); diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts index 38ee57492013..34311ab38a92 100644 --- a/client/src/components/Grid/configs/histories.ts +++ b/client/src/components/Grid/configs/histories.ts @@ -1,8 +1,17 @@ -import { faCopy, faEdit, faExchangeAlt, faEye, faPlus, faShareAlt, faSignature, faTrash, faTrashRestore } from "@fortawesome/free-solid-svg-icons"; +import { + faCopy, + faExchangeAlt, + faEye, + faPlus, + faShareAlt, + faSignature, + faTrash, + faTrashRestore, + faUsers, +} from "@fortawesome/free-solid-svg-icons"; import { useEventBus } from "@vueuse/core"; -import { deleteForm, undeleteForm } from "@/api/forms"; -import { historiesQuery } from "@/api/histories"; +import { deleteHistory, historiesQuery, purgeHistory, undeleteHistory } from "@/api/histories"; import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; import _l from "@/utils/localization"; import { errorMessageAsString } from "@/utils/simple-error"; @@ -14,7 +23,7 @@ const { emit } = useEventBus("grid-router-push"); /** * Local types */ -type FormEntry = Record; +type HistoryEntry = Record; type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined; /** @@ -41,7 +50,7 @@ const actions: ActionArray = [ title: "Import New History", icon: faPlus, handler: () => { - emit("/admin/form/create_form"); + emit("/histories/import"); }, }, ]; @@ -58,59 +67,59 @@ const fields: FieldArray = [ { title: "Switch", icon: faExchangeAlt, - condition: (data: FormEntry) => !data.deleted, - handler: (data: FormEntry) => { + condition: (data: HistoryEntry) => !data.deleted, + handler: (data: HistoryEntry) => { emit(`/histories/view?id=${data.id}`); }, }, { title: "View", icon: faEye, - condition: (data: FormEntry) => !data.deleted, - handler: (data: FormEntry) => { + condition: (data: HistoryEntry) => !data.deleted, + handler: (data: HistoryEntry) => { emit(`/histories/view?id=${data.id}`); }, }, { - title: "Share and Publish", - icon: faShareAlt, - condition: (data: FormEntry) => !data.deleted, - handler: (data: FormEntry) => { - emit(`/histories/sharing?id=${data.id}`); + title: "Rename", + icon: faSignature, + condition: (data: HistoryEntry) => !data.deleted, + handler: (data: HistoryEntry) => { + emit(`/histories/rename?id=${data.id}`); }, }, { title: "Copy", icon: faCopy, - condition: (data: FormEntry) => !data.deleted, - handler: (data: FormEntry) => { + condition: (data: HistoryEntry) => !data.deleted, + handler: (data: HistoryEntry) => { emit(`/histories/sharing?id=${data.id}`); }, }, { - title: "Change Permissions", - icon: faEdit, - condition: (data: FormEntry) => !data.deleted, - handler: (data: FormEntry) => { - emit(`/histories/permissions?id=${data.id}`); + title: "Share and Publish", + icon: faShareAlt, + condition: (data: HistoryEntry) => !data.deleted, + handler: (data: HistoryEntry) => { + emit(`/histories/sharing?id=${data.id}`); }, }, { - title: "Rename", - icon: faSignature, - condition: (data: FormEntry) => !data.deleted, - handler: (data: FormEntry) => { - emit(`/histories/rename?id=${data.id}`); + title: "Change Permissions", + icon: faUsers, + condition: (data: HistoryEntry) => !data.deleted, + handler: (data: HistoryEntry) => { + emit(`/histories/permissions?id=${data.id}`); }, }, { title: "Delete", icon: faTrash, - condition: (data: FormEntry) => !data.deleted, - handler: async (data: FormEntry) => { - if (confirm(_l("Are you sure that you want to delete this form?"))) { + condition: (data: HistoryEntry) => !data.deleted, + handler: async (data: HistoryEntry) => { + if (confirm(_l("Are you sure that you want to delete this history?"))) { try { - await deleteForm({ id: String(data.id) }); + await deleteHistory({ history_id: String(data.id) }); return { status: "success", message: `'${data.name}' has been deleted.`, @@ -127,11 +136,11 @@ const fields: FieldArray = [ { title: "Delete Permanently", icon: faTrash, - condition: (data: FormEntry) => !data.deleted, - handler: async (data: FormEntry) => { - if (confirm(_l("Are you sure that you want to delete this form?"))) { + condition: (data: HistoryEntry) => !!data.deleted, + handler: async (data: HistoryEntry) => { + if (confirm(_l("Are you sure that you want to delete this history?"))) { try { - await deleteForm({ id: String(data.id) }); + await purgeHistory({ history_id: String(data.id) }); return { status: "success", message: `'${data.name}' has been deleted.`, @@ -148,10 +157,10 @@ const fields: FieldArray = [ { title: "Restore", icon: faTrashRestore, - condition: (data: FormEntry) => !!data.deleted, - handler: async (data: FormEntry) => { + condition: (data: HistoryEntry) => !!data.deleted, + handler: async (data: HistoryEntry) => { try { - await undeleteForm({ id: String(data.id) }); + await undeleteHistory({ history_id: String(data.id) }); return { status: "success", message: `'${data.name}' has been restored.`, From 2c0ef7ab578d90822eed5fa07649bd5173b590dc Mon Sep 17 00:00:00 2001 From: guerler Date: Tue, 12 Dec 2023 08:06:44 +0300 Subject: [PATCH 09/50] Remove copy operation modal from history grid for now --- client/src/components/Grid/configs/histories.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts index 34311ab38a92..a44ad89e6f5c 100644 --- a/client/src/components/Grid/configs/histories.ts +++ b/client/src/components/Grid/configs/histories.ts @@ -1,5 +1,4 @@ import { - faCopy, faExchangeAlt, faEye, faPlus, @@ -12,6 +11,7 @@ import { import { useEventBus } from "@vueuse/core"; import { deleteHistory, historiesQuery, purgeHistory, undeleteHistory } from "@/api/histories"; +import { useHistoryStore } from "@/stores/historyStore"; import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; import _l from "@/utils/localization"; import { errorMessageAsString } from "@/utils/simple-error"; @@ -69,7 +69,8 @@ const fields: FieldArray = [ icon: faExchangeAlt, condition: (data: HistoryEntry) => !data.deleted, handler: (data: HistoryEntry) => { - emit(`/histories/view?id=${data.id}`); + const historyStore = useHistoryStore(); + historyStore.setCurrentHistory(String(data.id)); }, }, { @@ -88,14 +89,6 @@ const fields: FieldArray = [ emit(`/histories/rename?id=${data.id}`); }, }, - { - title: "Copy", - icon: faCopy, - condition: (data: HistoryEntry) => !data.deleted, - handler: (data: HistoryEntry) => { - emit(`/histories/sharing?id=${data.id}`); - }, - }, { title: "Share and Publish", icon: faShareAlt, From 61f44af936291055dd18a0fffc296f988aad5984 Mon Sep 17 00:00:00 2001 From: guerler Date: Tue, 12 Dec 2023 14:08:48 +0300 Subject: [PATCH 10/50] Add history stats grid column --- .../Grid/GridElements/GridDatasets.vue | 75 +++++++++++++++++++ client/src/components/Grid/GridList.vue | 2 + .../src/components/Grid/configs/histories.ts | 5 ++ client/src/components/Grid/configs/types.ts | 2 +- client/src/style/scss/unsorted.scss | 10 --- 5 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 client/src/components/Grid/GridElements/GridDatasets.vue diff --git a/client/src/components/Grid/GridElements/GridDatasets.vue b/client/src/components/Grid/GridElements/GridDatasets.vue new file mode 100644 index 000000000000..967d4b27565e --- /dev/null +++ b/client/src/components/Grid/GridElements/GridDatasets.vue @@ -0,0 +1,75 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/Grid/GridList.vue b/client/src/components/Grid/GridList.vue index c5de3f921608..ad088d5adf19 100644 --- a/client/src/components/Grid/GridList.vue +++ b/client/src/components/Grid/GridList.vue @@ -10,6 +10,7 @@ import { useRouter } from "vue-router/composables"; import { FieldHandler, GridConfig, Operation, RowData } from "./configs/types"; import GridBoolean from "./GridElements/GridBoolean.vue"; +import GridDatasets from "./GridElements/GridDatasets.vue"; import GridLink from "./GridElements/GridLink.vue"; import GridOperations from "./GridElements/GridOperations.vue"; import GridText from "./GridElements/GridText.vue"; @@ -273,6 +274,7 @@ watch(operationMessage, () => { :title="rowData[fieldEntry.key]" @execute="onOperation($event, rowData)" /> + | void; export type RowData = Record; -type validTypes = "boolean" | "date" | "link" | "operations" | "sharing" | "tags" | "text"; +type validTypes = "boolean" | "date" | "datasets" | "link" | "operations" | "sharing" | "tags" | "text"; diff --git a/client/src/style/scss/unsorted.scss b/client/src/style/scss/unsorted.scss index 4c3799859c8f..f330afc362d3 100644 --- a/client/src/style/scss/unsorted.scss +++ b/client/src/style/scss/unsorted.scss @@ -384,16 +384,6 @@ div.debug { .grid .current { background-color: lighten($brand-success, 20%); } - -// Pulled out of grid base -.count-box { - min-width: 1.1em; - padding: 5px; - border-width: 1px; - border-style: solid; - text-align: center; - display: inline-block; -} .text-filter-val { border: solid 1px #aaaaaa; padding: 1px 2px 1px 3px; From 441c0a0f15971ec6f1e1d981faf2d07b6ba51e85 Mon Sep 17 00:00:00 2001 From: guerler Date: Wed, 13 Dec 2023 07:47:31 +0300 Subject: [PATCH 11/50] Add tags handler, adjust dataset state box --- .../Grid/GridElements/GridDatasets.vue | 60 +++++++++++-------- .../src/components/Grid/configs/histories.ts | 22 ++++--- .../components/Grid/configs/visualizations.ts | 8 +-- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/client/src/components/Grid/GridElements/GridDatasets.vue b/client/src/components/Grid/GridElements/GridDatasets.vue index 967d4b27565e..21b7d764c6f5 100644 --- a/client/src/components/Grid/GridElements/GridDatasets.vue +++ b/client/src/components/Grid/GridElements/GridDatasets.vue @@ -1,8 +1,9 @@ - \ No newline at end of file + diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts index eb11f291883a..e4d0a469f0b3 100644 --- a/client/src/components/Grid/configs/histories.ts +++ b/client/src/components/Grid/configs/histories.ts @@ -11,10 +11,11 @@ import { import { useEventBus } from "@vueuse/core"; import { deleteHistory, historiesQuery, purgeHistory, undeleteHistory } from "@/api/histories"; +import { updateTags } from "@/api/tags"; import { useHistoryStore } from "@/stores/historyStore"; import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; import _l from "@/utils/localization"; -import { errorMessageAsString } from "@/utils/simple-error"; +import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error"; import type { ActionArray, FieldArray, GridConfig } from "./types"; @@ -129,7 +130,7 @@ const fields: FieldArray = [ { title: "Delete Permanently", icon: faTrash, - condition: (data: HistoryEntry) => !!data.deleted, + condition: (data: HistoryEntry) => !data.deleted, handler: async (data: HistoryEntry) => { if (confirm(_l("Are you sure that you want to delete this history?"))) { try { @@ -173,15 +174,22 @@ const fields: FieldArray = [ title: "Items", type: "text", }, + { + key: "id", + title: "Size", + type: "datasets", + }, { key: "tags", title: "Tags", type: "tags", - }, - { - key: "id", - title: "Datasets", - type: "datasets", + handler: async (data: HistoryEntry) => { + try { + await updateTags(data.id as string, "History", data.tags as Array); + } catch (e) { + rethrowSimple(e); + } + }, }, { key: "create_time", diff --git a/client/src/components/Grid/configs/visualizations.ts b/client/src/components/Grid/configs/visualizations.ts index 61a1f947a4cf..e93eb60268d7 100644 --- a/client/src/components/Grid/configs/visualizations.ts +++ b/client/src/components/Grid/configs/visualizations.ts @@ -3,6 +3,7 @@ import { useEventBus } from "@vueuse/core"; import axios from "axios"; import { fetcher } from "@/api/schema"; +import { updateTags } from "@/api/tags"; import { getGalaxyInstance } from "@/app"; import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; import { withPrefix } from "@/utils/redirect"; @@ -16,7 +17,6 @@ const { emit } = useEventBus("grid-router-push"); * Api endpoint handlers */ const getVisualizations = fetcher.path("/api/visualizations").method("get").create(); -const updateTags = fetcher.path("/api/tags").method("put").create(); /** * Local types @@ -172,11 +172,7 @@ const fields: FieldArray = [ type: "tags", handler: async (data: VisualizationEntry) => { try { - await updateTags({ - item_id: data.id as string, - item_class: "Visualization", - item_tags: data.tags as Array, - }); + await updateTags(data.id as string, "Visualization", data.tags as Array); } catch (e) { rethrowSimple(e); } From ffcce81bd65c4f6b90a137885f6c3bbb6b0b62d1 Mon Sep 17 00:00:00 2001 From: guerler Date: Wed, 13 Dec 2023 08:16:48 +0300 Subject: [PATCH 12/50] Fix naming, prep username filter Search by username? --- client/src/entry/analysis/router.js | 11 +++++++---- lib/galaxy/managers/histories.py | 16 +++++++++++++--- lib/galaxy/managers/visualizations.py | 14 +++++++------- lib/galaxy/model/__init__.py | 8 +++++++- lib/galaxy/webapps/galaxy/api/histories.py | 12 ++++++++++-- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index e9b5bd6bdf11..94f7564f5d5c 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -8,9 +8,9 @@ import DatasetDetails from "components/DatasetInformation/DatasetDetails"; import DatasetError from "components/DatasetInformation/DatasetError"; import FormGeneric from "components/Form/FormGeneric"; import historiesGridConfig from "components/Grid/configs/histories"; +import historiesSharedGridConfig from "components/Grid/configs/historiesShared"; import visualizationsGridConfig from "components/Grid/configs/visualizations"; import visualizationsPublishedGridConfig from "components/Grid/configs/visualizationsPublished"; -import GridHistory from "components/Grid/GridHistory"; import GridList from "components/Grid/GridList"; import HistoryExportTasks from "components/History/Export/HistoryExport"; import HistoryPublished from "components/History/HistoryPublished"; @@ -298,11 +298,14 @@ export function getRouter(Galaxy) { props: { gridConfig: historiesGridConfig, }, + redirect: redirectAnon(), }, { - path: "histories/:actionId", - component: GridHistory, - props: true, + path: "histories/list_shared", + component: GridList, + props: { + gridConfig: historiesSharedGridConfig, + }, redirect: redirectAnon(), }, { diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index 8d7d83c9ede8..31a3e628bbd4 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -124,18 +124,25 @@ def index_query( self, trans: ProvidesUserContext, payload: HistoryIndexQueryPayload, include_total_count: bool = False ) -> Tuple[List[model.History], int]: show_deleted = False + show_own = payload.show_own show_published = payload.show_published + show_shared = payload.show_shared is_admin = trans.user_is_admin user = trans.user + if not user: + message = "Requires user to log in." + raise exceptions.RequestParameterInvalidException(message) + query = trans.sa_session.query(self.model_class) + query = query.outerjoin(self.model_class.user) filters = [] - if not show_published: + if show_own or (not show_published and not is_admin): filters = [self.model_class.user == user] - else: + if show_published: filters.append(self.model_class.published == true()) - if user and show_published: + if show_shared: filters.append(self.user_share_model.user == user) query = query.outerjoin(self.model_class.users_shared_with) query = query.filter(or_(*filters)) @@ -188,6 +195,9 @@ def p_tag_filter(term_text: str, quoted: bool): ) ) + if show_published and not is_admin: + deleted = False + query = query.filter(self.model_class.deleted == (true() if show_deleted else false())) if include_total_count: diff --git a/lib/galaxy/managers/visualizations.py b/lib/galaxy/managers/visualizations.py index 6a1db77d3570..52a104932858 100644 --- a/lib/galaxy/managers/visualizations.py +++ b/lib/galaxy/managers/visualizations.py @@ -75,17 +75,14 @@ def index_query( self, trans: ProvidesUserContext, payload: VisualizationIndexQueryPayload, include_total_count: bool = False ) -> Tuple[List[model.Visualization], int]: show_deleted = payload.deleted - show_shared = payload.show_shared - show_published = payload.show_published show_own = payload.show_own + show_published = payload.show_published + show_shared = payload.show_shared is_admin = trans.user_is_admin user = trans.user - if show_shared is None: - show_shared = not show_deleted - - if show_shared and show_deleted: - message = "show_shared and show_deleted cannot both be specified as true" + if not user: + message = "Requires user to log in." raise exceptions.RequestParameterInvalidException(message) query = trans.sa_session.query(self.model_class) @@ -154,6 +151,9 @@ def p_tag_filter(term_text: str, quoted: bool): ) ) + if show_published and not is_admin: + deleted = False + query = query.filter(self.model_class.deleted == (true() if show_deleted else false())) if include_total_count: diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 377870ef9c78..6df2a015d00d 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -3033,7 +3033,7 @@ class History(Base, HasTags, Dictifiable, UsesAnnotations, HasName, Serializable "slug", "tags", "update_time", - "user", + "username", ] default_name = "Unnamed history" @@ -3067,6 +3067,12 @@ def stage_addition(self, items): def empty(self): return self.hid_counter is None or self.hid_counter == 1 + @property + def username(self): + if self.user: + return self.user.username + return None + @property def count(self): return self.hid_counter - 1 diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index 3c288a89ab2a..760bb4e9796e 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -116,8 +116,12 @@ free_text_fields=["title", "description", "slug", "tag"], ) -ShowPublishedQueryParam: bool = Query( - default=False, title="Restrict to published histories and those shared with authenticated user.", description="" +ShowOwnQueryParam: bool = Query(default=True, title="Show histories owned by user.", description="") + +ShowPublishedQueryParam: bool = Query(default=True, title="Include published histories.", description="") + +ShowSharedQueryParam: bool = Query( + default=False, title="Include histories shared with authenticated user.", description="" ) SortByQueryParam: HistorySortByEnum = Query( @@ -179,13 +183,17 @@ async def index_query( trans: ProvidesUserContext = DependsOnTrans, limit: Optional[int] = LimitQueryParam, offset: Optional[int] = OffsetQueryParam, + show_own: bool = ShowOwnQueryParam, show_published: bool = ShowPublishedQueryParam, + show_shared: bool = ShowSharedQueryParam, sort_by: HistorySortByEnum = SortByQueryParam, sort_desc: bool = SortDescQueryParam, search: Optional[str] = SearchQueryParam, ) -> HistoryQueryResultList: payload = HistoryIndexQueryPayload.construct( + show_own=show_own, show_published=show_published, + show_shared=show_shared, sort_by=sort_by, sort_desc=sort_desc, limit=limit, From beccfd3182fa07e903b4a196a3045f48d4d4c78b Mon Sep 17 00:00:00 2001 From: guerler Date: Thu, 14 Dec 2023 22:07:56 +0300 Subject: [PATCH 13/50] Add initial draft of history shared grid config --- .../Grid/configs/historiesShared.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 client/src/components/Grid/configs/historiesShared.ts diff --git a/client/src/components/Grid/configs/historiesShared.ts b/client/src/components/Grid/configs/historiesShared.ts new file mode 100644 index 000000000000..1fdd13d94538 --- /dev/null +++ b/client/src/components/Grid/configs/historiesShared.ts @@ -0,0 +1,127 @@ +import { + faExchangeAlt, + faEye, + faPlus, + faShareAlt, + faSignature, + faTrash, + faTrashRestore, + faUsers, +} from "@fortawesome/free-solid-svg-icons"; +import { useEventBus } from "@vueuse/core"; + +import { deleteHistory, historiesQuery, purgeHistory, undeleteHistory } from "@/api/histories"; +import { updateTags } from "@/api/tags"; +import { useHistoryStore } from "@/stores/historyStore"; +import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; +import _l from "@/utils/localization"; +import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error"; + +import type { ActionArray, FieldArray, GridConfig } from "./types"; + +const { emit } = useEventBus("grid-router-push"); + +/** + * Local types + */ +type HistoryEntry = Record; +type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined; + +/** + * Request and return data from server + */ +async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) { + const { data, headers } = await historiesQuery({ + limit, + offset, + search, + sort_by: sort_by as SortKeyLiteral, + sort_desc, + show_published: false, + }); + const totalMatches = parseInt(headers.get("total_matches") ?? "0"); + return [data, totalMatches]; +} + +/** + * Declare columns to be displayed + */ +const fields: FieldArray = [ + { + key: "name", + title: "Name", + type: "operations", + operations: [ + { + title: "View", + icon: faEye, + condition: (data: HistoryEntry) => !data.deleted, + handler: (data: HistoryEntry) => { + emit(`/histories/view?id=${data.id}`); + }, + }, + ], + }, + { + key: "id", + title: "Size", + type: "datasets", + }, + { + key: "tags", + title: "Tags", + type: "tags", + handler: async (data: HistoryEntry) => { + try { + await updateTags(data.id as string, "History", data.tags as Array); + } catch (e) { + rethrowSimple(e); + } + }, + }, + { + key: "create_time", + title: "Created", + type: "date", + }, + { + key: "update_time", + title: "Updated", + type: "date", + }, + { + key: "username", + title: "Username", + type: "text", + }, +]; + +/** + * Declare filter options + */ +const validFilters: Record> = { + title: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, + tag: { + placeholder: "tag(s)", + type: "MultiTags", + handler: contains("tag", "tag", expandNameTag), + menuItem: true, + }, +}; + +/** + * Grid configuration + */ +const gridConfig: GridConfig = { + id: "histories-shared-grid", + fields: fields, + filtering: new Filtering(validFilters, undefined, false, false), + getData: getData, + plural: "Histories", + sortBy: "name", + sortDesc: true, + sortKeys: ["create_time", "name", "update_time", "username"], + title: "Shared Histories", +}; + +export default gridConfig; From ad06e7077877e0228c65e670cdea1ee4228e3276 Mon Sep 17 00:00:00 2001 From: guerler Date: Tue, 19 Dec 2023 20:00:29 +0300 Subject: [PATCH 14/50] Remove false condition from visualizations grid --- client/src/components/Grid/configs/visualizations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/components/Grid/configs/visualizations.ts b/client/src/components/Grid/configs/visualizations.ts index e93eb60268d7..940d065ae2bc 100644 --- a/client/src/components/Grid/configs/visualizations.ts +++ b/client/src/components/Grid/configs/visualizations.ts @@ -69,7 +69,6 @@ const fields: FieldArray = [ key: "title", type: "operations", width: 40, - condition: (data: VisualizationEntry) => !data.deleted, operations: [ { title: "Open", From 3ba70f1af4d89deb1bd2a8229b98a24762adcb03 Mon Sep 17 00:00:00 2001 From: guerler Date: Wed, 20 Dec 2023 06:53:10 +0300 Subject: [PATCH 15/50] Lint and remove unused imports --- .../src/components/Grid/configs/histories.ts | 2 +- .../Grid/configs/historiesShared.ts | 22 +++++------------- lib/galaxy/managers/histories.py | 23 ++++++++++--------- lib/galaxy/managers/visualizations.py | 2 +- 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts index e4d0a469f0b3..952b0de81be2 100644 --- a/client/src/components/Grid/configs/histories.ts +++ b/client/src/components/Grid/configs/histories.ts @@ -212,7 +212,7 @@ const fields: FieldArray = [ * Declare filter options */ const validFilters: Record> = { - title: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, + name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, tag: { placeholder: "tag(s)", type: "MultiTags", diff --git a/client/src/components/Grid/configs/historiesShared.ts b/client/src/components/Grid/configs/historiesShared.ts index 1fdd13d94538..7be0f77e0dbe 100644 --- a/client/src/components/Grid/configs/historiesShared.ts +++ b/client/src/components/Grid/configs/historiesShared.ts @@ -1,23 +1,13 @@ -import { - faExchangeAlt, - faEye, - faPlus, - faShareAlt, - faSignature, - faTrash, - faTrashRestore, - faUsers, -} from "@fortawesome/free-solid-svg-icons"; +import { faEye } from "@fortawesome/free-solid-svg-icons"; import { useEventBus } from "@vueuse/core"; -import { deleteHistory, historiesQuery, purgeHistory, undeleteHistory } from "@/api/histories"; +import { historiesQuery } from "@/api/histories"; import { updateTags } from "@/api/tags"; -import { useHistoryStore } from "@/stores/historyStore"; -import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; +import Filtering, { contains, expandNameTag, type ValidFilter } from "@/utils/filtering"; import _l from "@/utils/localization"; -import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error"; +import { rethrowSimple } from "@/utils/simple-error"; -import type { ActionArray, FieldArray, GridConfig } from "./types"; +import type { FieldArray, GridConfig } from "./types"; const { emit } = useEventBus("grid-router-push"); @@ -100,7 +90,7 @@ const fields: FieldArray = [ * Declare filter options */ const validFilters: Record> = { - title: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, + name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, tag: { placeholder: "tag(s)", type: "MultiTags", diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index 31a3e628bbd4..239722d1f47f 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -29,9 +29,11 @@ from sqlalchemy.orm import aliased from typing_extensions import Literal -from galaxy import ( - exceptions as glx_exceptions, - model, +from galaxy import model +from galaxy.exceptions import ( + RequestParameterInvalidException, + MessageException, + ObjectNotFound, ) from galaxy.managers import ( deletable, @@ -91,7 +93,6 @@ INDEX_SEARCH_FILTERS = { "name": "name", - "annotation": "annotation", "tag": "tag", "is": "is", } @@ -132,7 +133,7 @@ def index_query( if not user: message = "Requires user to log in." - raise exceptions.RequestParameterInvalidException(message) + raise RequestParameterInvalidException(message) query = trans.sa_session.query(self.model_class) query = query.outerjoin(self.model_class.user) @@ -178,7 +179,7 @@ def p_tag_filter(term_text: str, quoted: bool): elif q == "shared_with_me": if not show_published: message = "Can only use tag is:shared_with_me if show_published parameter also true." - raise exceptions.RequestParameterInvalidException(message) + raise RequestParameterInvalidException(message) query = query.filter(self.user_share_model.user == user) elif isinstance(term, RawTextTerm): tf = p_tag_filter(term.text, False) @@ -196,7 +197,7 @@ def p_tag_filter(term_text: str, quoted: bool): ) if show_published and not is_admin: - deleted = False + show_deleted = False query = query.filter(self.model_class.deleted == (true() if show_deleted else false())) @@ -331,7 +332,7 @@ def parse_order_by(self, order_by_string, default=None): # TODO: add functional/non-orm orders (such as rating) if default: return self.parse_order_by(default) - raise glx_exceptions.RequestParameterInvalidException( + raise RequestParameterInvalidException( "Unknown order_by", order_by=order_by_string, available=["create_time", "update_time", "name", "size"] ) @@ -507,7 +508,7 @@ def restore_archived_history(self, history: model.History, force: bool = False): record to restore the history and its datasets as a new copy. """ if history.archive_export_id is not None and history.purged and not force: - raise glx_exceptions.RequestParameterInvalidException( + raise RequestParameterInvalidException( "Cannot restore an archived (and purged) history that is associated with an archive export record. " "Please try importing it back as a new copy from the associated archive export record instead. " "You can still force the un-archiving of the purged history by setting the 'force' parameter." @@ -717,11 +718,11 @@ def get_ready_jeha(self, trans, history_id: int, jeha_id: Union[int, Literal["la if jeha_id != "latest": matching_exports = [e for e in matching_exports if e.id == jeha_id] if len(matching_exports) == 0: - raise glx_exceptions.ObjectNotFound("Failed to find target history export") + raise ObjectNotFound("Failed to find target history export") jeha = matching_exports[0] if not jeha.ready: - raise glx_exceptions.MessageException("Export not available or not yet ready.") + raise MessageException("Export not available or not yet ready.") return jeha diff --git a/lib/galaxy/managers/visualizations.py b/lib/galaxy/managers/visualizations.py index 52a104932858..ef48eb383d7b 100644 --- a/lib/galaxy/managers/visualizations.py +++ b/lib/galaxy/managers/visualizations.py @@ -152,7 +152,7 @@ def p_tag_filter(term_text: str, quoted: bool): ) if show_published and not is_admin: - deleted = False + show_deleted = False query = query.filter(self.model_class.deleted == (true() if show_deleted else false())) From 7acb3a7ef8e9cbefe18c1ce6ccd4c8b00827ac93 Mon Sep 17 00:00:00 2001 From: guerler Date: Wed, 20 Dec 2023 07:12:37 +0300 Subject: [PATCH 16/50] Add history schema --- client/src/api/schema/schema.ts | 50 +++++++++++++++++++ lib/galaxy/managers/histories.py | 2 +- lib/galaxy/schema/history.py | 83 ++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 lib/galaxy/schema/history.py diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 726e4d0c9fc0..13cfaae2efb3 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -14932,6 +14932,56 @@ export interface operations { extra_files_history_api_histories__history_id__contents__history_content_id__extra_files_get: { /** Get the list of extra files/directories associated with a dataset. */ parameters: { + index_query_api_histories_query_get: { + /** Returns histories available to the current user. */ + parameters?: { + /** @description The maximum number of items to return. */ + /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item */ + /** @description Sort index by this specified attribute */ + /** @description Sort in descending order? */ + /** + * @description A mix of free text and GitHub-style tags used to filter the index operation. + * + * ## Query Structure + * + * GitHub-style filter tags (not be confused with Galaxy tags) are tags of the form + * `:` or `:''`. The tag name + * *generally* (but not exclusively) corresponds to the name of an attribute on the model + * being indexed (i.e. a column in the database). + * + * If the tag is quoted, the attribute will be filtered exactly. If the tag is unquoted, + * generally a partial match will be used to filter the query (i.e. in terms of the implementation + * this means the database operation `ILIKE` will typically be used). + * + * Once the tagged filters are extracted from the search query, the remaining text is just + * used to search various documented attributes of the object. + * + * ## GitHub-style Tags Available + * + * `name` + * : The history's name. + * + * `annotation` + * : The history's annotation. (The tag `a` can be used a short hand alias for this tag to filter on this attribute.) + * + * `tag` + * : The history's tags. (The tag `t` can be used a short hand alias for this tag to filter on this attribute.) + * + * ## Free Text + * + * Free text search terms will be searched against the following attributes of the + * Historys: `title`, `description`, `slug`, `tag`. + */ + query?: { + limit?: number; + offset?: number; + show_own?: boolean; + show_published?: boolean; + show_shared?: boolean; + sort_by?: "create_time" | "name" | "update_time" | "username"; + sort_desc?: boolean; + search?: string; + }; /** @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 | null; diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index 239722d1f47f..f032cc1688d7 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -31,9 +31,9 @@ from galaxy import model from galaxy.exceptions import ( - RequestParameterInvalidException, MessageException, ObjectNotFound, + RequestParameterInvalidException, ) from galaxy.managers import ( deletable, diff --git a/lib/galaxy/schema/history.py b/lib/galaxy/schema/history.py new file mode 100644 index 000000000000..fa926dd98571 --- /dev/null +++ b/lib/galaxy/schema/history.py @@ -0,0 +1,83 @@ +from datetime import datetime +from typing import ( + List, + Optional, +) + +from pydantic import ( + Extra, + Field, +) +from typing_extensions import Literal + +from galaxy.schema.fields import EncodedDatabaseIdField +from galaxy.schema.schema import ( + CreateTimeField, + Model, + TagCollection, + UpdateTimeField, +) + +HistorySortByEnum = Literal["create_time", "name", "update_time", "username"] + + +class HistoryIndexQueryPayload(Model): + show_own: Optional[bool] = None + show_published: Optional[bool] = None + show_shared: Optional[bool] = None + sort_by: HistorySortByEnum = Field("update_time", title="Sort By", description="Sort by this attribute.") + sort_desc: Optional[bool] = Field(default=True, title="Sort descending", description="Sort in descending order.") + search: Optional[str] = Field(default=None, title="Filter text", description="Freetext to search.") + limit: Optional[int] = Field( + default=100, lt=1000, title="Limit", description="Maximum number of entries to return." + ) + offset: Optional[int] = Field(default=0, title="Offset", description="Number of entries to skip.") + + +class HistoryQueryResult(Model): + id: EncodedDatabaseIdField = Field( + ..., + title="ID", + description="Encoded ID of the History.", + ) + annotation: Optional[str] = Field( + default=None, + title="Annotation", + description="The annotation of this History.", + ) + deleted: bool = Field( + ..., # Required + title="Deleted", + description="Whether this History has been deleted.", + ) + importable: bool = Field( + ..., # Required + title="Importable", + description="Whether this History can be imported.", + ) + published: bool = Field( + ..., # Required + title="Published", + description="Whether this History has been published.", + ) + tags: Optional[TagCollection] = Field( + ..., + title="Tags", + description="A list of tags to add to this item.", + ) + name: str = Field( + title="Name", + description="The name of the History.", + ) + create_time: Optional[datetime] = CreateTimeField + update_time: Optional[datetime] = UpdateTimeField + + class Config: + extra = Extra.allow # Allow any other extra fields + + +class HistoryQueryResultList(Model): + __root__: List[HistoryQueryResult] = Field( + default=[], + title="List with detailed information of Histories.", + ) From d645a7b7d2e334829cbf8d19d4518a13041e3afe Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 23 Dec 2023 12:25:20 +0300 Subject: [PATCH 17/50] Switch user manager from session query to stmt --- .../Grid/configs/historiesShared.ts | 3 ++ lib/galaxy/managers/histories.py | 45 ++++++++++--------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/client/src/components/Grid/configs/historiesShared.ts b/client/src/components/Grid/configs/historiesShared.ts index 7be0f77e0dbe..6e7be23e0ab6 100644 --- a/client/src/components/Grid/configs/historiesShared.ts +++ b/client/src/components/Grid/configs/historiesShared.ts @@ -28,6 +28,8 @@ async function getData(offset: number, limit: number, search: string, sort_by: s sort_by: sort_by as SortKeyLiteral, sort_desc, show_published: false, + show_own: false, + show_shared: true, }); const totalMatches = parseInt(headers.get("total_matches") ?? "0"); return [data, totalMatches]; @@ -91,6 +93,7 @@ const fields: FieldArray = [ */ const validFilters: Record> = { name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, + user: { placeholder: "user", type: String, handler: contains("username"), menuItem: true }, tag: { placeholder: "tag(s)", type: "MultiTags", diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index f032cc1688d7..41c12212d475 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -93,6 +93,7 @@ INDEX_SEARCH_FILTERS = { "name": "name", + "user": "user", "tag": "tag", "is": "is", } @@ -135,8 +136,7 @@ def index_query( message = "Requires user to log in." raise RequestParameterInvalidException(message) - query = trans.sa_session.query(self.model_class) - query = query.outerjoin(self.model_class.user) + stmt = select(self.model_class).outerjoin(self.model_class.user) filters = [] if show_own or (not show_published and not is_admin): @@ -145,17 +145,17 @@ def index_query( filters.append(self.model_class.published == true()) if show_shared: filters.append(self.user_share_model.user == user) - query = query.outerjoin(self.model_class.users_shared_with) - query = query.filter(or_(*filters)) + stmt = stmt.outerjoin(self.model_class.users_shared_with) + stmt = stmt.where(or_(*filters)) if payload.search: search_query = payload.search parsed_search = parse_filters_structured(search_query, INDEX_SEARCH_FILTERS) def p_tag_filter(term_text: str, quoted: bool): - nonlocal query + nonlocal stmt alias = aliased(model.HistoryTagAssociation) - query = query.outerjoin(self.model_class.tags.of_type(alias)) + stmt = stmt.outerjoin(self.model_class.tags.of_type(alias)) return tag_filter(alias, term_text, quoted) for term in parsed_search.terms: @@ -164,28 +164,28 @@ def p_tag_filter(term_text: str, quoted: bool): q = term.text if key == "tag": pg = p_tag_filter(term.text, term.quoted) - query = query.filter(pg) + stmt = stmt.where(pg) elif key == "name": - query = query.filter(text_column_filter(self.model_class.name, term)) + stmt = stmt.where(text_column_filter(self.model_class.name, term)) elif key == "user": - query = append_user_filter(query, self.model_class, term) + stmt = append_user_filter(stmt, self.model_class, term) elif key == "is": if q == "deleted": show_deleted = True if q == "published": - query = query.filter(self.model_class.published == true()) + stmt = stmt.where(self.model_class.published == true()) if q == "importable": - query = query.filter(self.model_class.importable == true()) + stmt = stmt.where(self.model_class.importable == true()) elif q == "shared_with_me": if not show_published: message = "Can only use tag is:shared_with_me if show_published parameter also true." raise RequestParameterInvalidException(message) - query = query.filter(self.user_share_model.user == user) + stmt = stmt.where(self.user_share_model.user == user) elif isinstance(term, RawTextTerm): tf = p_tag_filter(term.text, False) alias = aliased(model.User) - query = query.outerjoin(self.model_class.user.of_type(alias)) - query = query.filter( + stmt = stmt.outerjoin(self.model_class.user.of_type(alias)) + stmt = stmt.where( raw_text_column_filter( [ self.model_class.name, @@ -199,21 +199,21 @@ def p_tag_filter(term_text: str, quoted: bool): if show_published and not is_admin: show_deleted = False - query = query.filter(self.model_class.deleted == (true() if show_deleted else false())) + stmt = stmt.where(self.model_class.deleted == (true() if show_deleted else false())) if include_total_count: - total_matches = query.count() + total_matches = get_count(trans.sa_session, stmt) else: total_matches = None sort_column = getattr(model.History, payload.sort_by) if payload.sort_desc: sort_column = sort_column.desc() - query = query.order_by(sort_column) + stmt = stmt.order_by(sort_column) if payload.limit is not None: - query = query.limit(payload.limit) + stmt = stmt.limit(payload.limit) if payload.offset is not None: - query = query.offset(payload.offset) - return query, total_matches + stmt = stmt.offset(payload.offset) + return trans.sa_session.scalars(stmt), total_matches def copy(self, history, user, **kwargs): """ @@ -1019,3 +1019,8 @@ def username_eq(self, item, val: str) -> bool: def username_contains(self, item, val: str) -> bool: return val.lower() in str(item.user.username).lower() + + +def get_count(session, statement): + stmt = select(func.count()).select_from(statement) + return session.scalar(stmt) From b33f4d98c8a71e7c3270736511d7db854057334a Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 23 Dec 2023 12:47:45 +0300 Subject: [PATCH 18/50] Adjust query allow for sorting by username --- client/src/components/Grid/configs/histories.ts | 1 + .../src/components/Grid/configs/historiesShared.ts | 2 +- lib/galaxy/managers/histories.py | 13 ++++++++----- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts index 952b0de81be2..916da8fdd89e 100644 --- a/client/src/components/Grid/configs/histories.ts +++ b/client/src/components/Grid/configs/histories.ts @@ -37,6 +37,7 @@ async function getData(offset: number, limit: number, search: string, sort_by: s search, sort_by: sort_by as SortKeyLiteral, sort_desc, + show_own: true, show_published: false, }); const totalMatches = parseInt(headers.get("total_matches") ?? "0"); diff --git a/client/src/components/Grid/configs/historiesShared.ts b/client/src/components/Grid/configs/historiesShared.ts index 6e7be23e0ab6..2d9616110e1e 100644 --- a/client/src/components/Grid/configs/historiesShared.ts +++ b/client/src/components/Grid/configs/historiesShared.ts @@ -27,8 +27,8 @@ async function getData(offset: number, limit: number, search: string, sort_by: s search, sort_by: sort_by as SortKeyLiteral, sort_desc, - show_published: false, show_own: false, + show_published: false, show_shared: true, }); const totalMatches = parseInt(headers.get("total_matches") ?? "0"); diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index 41c12212d475..280a4e64393f 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -136,10 +136,10 @@ def index_query( message = "Requires user to log in." raise RequestParameterInvalidException(message) - stmt = select(self.model_class).outerjoin(self.model_class.user) + stmt = select(self.model_class).outerjoin(model.User) filters = [] - if show_own or (not show_published and not is_admin): + if show_own or (not show_published and not show_shared and not is_admin): filters = [self.model_class.user == user] if show_published: filters.append(self.model_class.published == true()) @@ -177,8 +177,8 @@ def p_tag_filter(term_text: str, quoted: bool): if q == "importable": stmt = stmt.where(self.model_class.importable == true()) elif q == "shared_with_me": - if not show_published: - message = "Can only use tag is:shared_with_me if show_published parameter also true." + if not show_shared: + message = "Can only use tag is:shared_with_me if show_shared parameter also true." raise RequestParameterInvalidException(message) stmt = stmt.where(self.user_share_model.user == user) elif isinstance(term, RawTextTerm): @@ -205,7 +205,10 @@ def p_tag_filter(term_text: str, quoted: bool): total_matches = get_count(trans.sa_session, stmt) else: total_matches = None - sort_column = getattr(model.History, payload.sort_by) + if payload.sort_by == "username": + sort_column = model.User.username + else: + sort_column = getattr(model.History, payload.sort_by) if payload.sort_desc: sort_column = sort_column.desc() stmt = stmt.order_by(sort_column) From 591d8e5bf867d866eebe931906ca0e268d7654d3 Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 23 Dec 2023 16:40:40 +0300 Subject: [PATCH 19/50] Fix alignment of history states --- .../Grid/GridElements/GridDatasets.vue | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/client/src/components/Grid/GridElements/GridDatasets.vue b/client/src/components/Grid/GridElements/GridDatasets.vue index 21b7d764c6f5..6890a5e6b39c 100644 --- a/client/src/components/Grid/GridElements/GridDatasets.vue +++ b/client/src/components/Grid/GridElements/GridDatasets.vue @@ -18,10 +18,11 @@ interface HistoryStats { active?: number; }; contents_states: { + error?: number; ok?: number; + new?: number; running?: number; queued?: number; - new?: number; }; } const historyStats: Ref = ref(null); @@ -49,22 +50,18 @@ onMounted(() => { {{ historyStats.nice_size }} - - - {{ stateCount }} - + + {{ stateCount }} + + + {{ historyStats.contents_active.deleted }} - - {{ historyStats.contents_active.deleted }} - - - {{ historyStats.contents_active.hidden }} + + {{ historyStats.contents_active.hidden }} @@ -72,14 +69,14 @@ onMounted(() => { From c4873ea8823c07f5c45b3244be27cdccbaf184ca Mon Sep 17 00:00:00 2001 From: guerler Date: Sat, 23 Dec 2023 17:18:11 +0300 Subject: [PATCH 20/50] Adjust selenium tests --- client/src/components/Grid/GridList.vue | 8 +- client/src/utils/navigation/navigation.yml | 13 ++- lib/galaxy/selenium/navigates_galaxy.py | 5 +- .../selenium/test_histories_list.py | 82 +++++-------------- .../selenium/test_history_sharing.py | 3 +- 5 files changed, 36 insertions(+), 75 deletions(-) diff --git a/client/src/components/Grid/GridList.vue b/client/src/components/Grid/GridList.vue index ad088d5adf19..2117bd4244d7 100644 --- a/client/src/components/Grid/GridList.vue +++ b/client/src/components/Grid/GridList.vue @@ -300,13 +300,7 @@ watch(operationMessage, () => {
diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml index e1e473a59c75..9cf01615cc78 100644 --- a/client/src/utils/navigation/navigation.yml +++ b/client/src/utils/navigation/navigation.yml @@ -408,8 +408,8 @@ published_histories: shared_histories: selectors: - _: ".histories-shared-with-you-by-others" - histories: "#grid-table-body tr" + _: '#histories-shared-grid' + histories: '.grid-table tr' history_copy_elements: selectors: @@ -430,8 +430,13 @@ collection_builders: name: "input.collection-name" histories: - labels: - import_button: 'Import history' + selectors: + advanced_search_toggle: '#histories-grid [data-description="toggle advanced search"]' + advanced_search_name_input: '#histories-advanced-filter-name' + advanced_search_tag_input: '#histories-advanced-filter-tag .stateless-tags' + advanced_search_submit: '#histories-advanced-filter-submit' + import_button: '[data-description="grid action import new history"]' + histories: '.grid-table tr' sharing: selectors: unshare_user_button: '.share_with_view .multiselect__tag-icon' diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 246955f8fc50..148524e2b6c7 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -1706,12 +1706,11 @@ def histories_click_advanced_search(self): def histories_get_history_names(self): self.sleep_for(self.wait_types.UX_RENDER) names = [] - grid = self.wait_for_selector("#grid-table-body") + grid = self.wait_for_selector("#histories-grid") for row in grid.find_elements(By.TAG_NAME, "tr"): td = row.find_elements(By.TAG_NAME, "td") name = td[1].text if td[0].text == "" else td[0].text - if name != "No items" and not name.startswith("No matching entries found"): - names.append(name) + names.append(name) return names @edit_details diff --git a/lib/galaxy_test/selenium/test_histories_list.py b/lib/galaxy_test/selenium/test_histories_list.py index fe52b8ec1208..e7142bbd94b2 100644 --- a/lib/galaxy_test/selenium/test_histories_list.py +++ b/lib/galaxy_test/selenium/test_histories_list.py @@ -1,3 +1,5 @@ +from selenium.webdriver.common.by import By + from .framework import ( retry_assertion_during_transitions, selenium_test, @@ -110,35 +112,6 @@ def test_permanently_delete_history(self): self.assert_histories_in_grid([self.history4_name]) - @selenium_test - def test_delete_and_undelete_multiple_histories(self): - self._login() - self.navigate_to_histories_page() - - delete_button_selector = 'input[type="button"][value="Delete"]' - undelete_button_selector = 'input[type="button"][value="Undelete"]' - - # Delete multiple histories - self.check_histories([self.history2_name, self.history3_name]) - self.wait_for_and_click_selector(delete_button_selector) - - self.assert_histories_in_grid([self.history2_name, self.history3_name], False) - - self.histories_click_advanced_search() - self.select_filter("status", "deleted") - self.sleep_for(self.wait_types.UX_RENDER) - - # Restore multiple histories - self.check_histories([self.history2_name, self.history3_name]) - self.wait_for_and_click_selector(undelete_button_selector) - - self.assert_grid_histories_are([]) - # Following msg popups but goes away and so can cause transient errors. - # self.wait_for_selector_visible('.donemessage') - self.select_filter("status", "active") - - self.assert_histories_in_grid([self.history2_name, self.history3_name]) - @selenium_test def test_sort_by_name(self): self._login() @@ -183,29 +156,27 @@ def test_standard_search(self): def test_advanced_search(self): self._login() self.navigate_to_histories_page() + self.sleep_for(self.wait_types.UX_RENDER) + self.components.histories.advanced_search_toggle.wait_for_and_click() + # search by tag and name + self.components.histories.advanced_search_name_input.wait_for_and_send_keys(self.history3_name) + self.components.histories.advanced_search_submit.wait_for_and_click() + self.assert_histories_present([self.history3_name]) - self.histories_click_advanced_search() - - name_filter_selector = "#input-name-filter" - tags_filter_selector = "#input-tags-filter" - - # Search by name - self.set_filter(name_filter_selector, self.history2_name) - self.assert_grid_histories_are([self.history2_name]) - self.unset_filter("name", self.history2_name) - - self.set_filter(name_filter_selector, self.history4_name) - self.assert_grid_histories_are([]) - self.unset_filter("name", self.history4_name) - - # Search by tags - self.set_filter(tags_filter_selector, self.history3_tags[0]) - self.assert_grid_histories_are([self.history3_name]) - self.unset_filter("tags", self.history3_tags[0]) - - self.set_filter(tags_filter_selector, self.history4_tags[0]) - self.assert_grid_histories_are([]) - self.unset_filter("tags", self.history4_tags[0]) + @retry_assertion_during_transitions + def assert_histories_present(self, expected_histories, sort_by_matters=False): + present_histories = self.get_present_histories() + assert len(present_histories) == len(expected_histories) + for index, row in enumerate(present_histories): + cell = row.find_elements(By.TAG_NAME, "td")[0] + if not sort_by_matters: + assert cell.text in expected_histories + else: + assert cell.text == expected_histories[index] + + def get_present_histories(self): + self.sleep_for(self.wait_types.UX_RENDER) + return self.components.histories.histories.all() @selenium_test def test_tags(self): @@ -300,12 +271,3 @@ def get_history_tags_cell(self, history_name): raise AssertionError(f"Failed to find history with name [{history_name}]") return tags_cell - - def check_histories(self, histories): - grid = self.wait_for_selector("#grid-table-body") - for row in grid.find_elements(self.by.CSS_SELECTOR, "tr"): - td = row.find_elements(self.by.CSS_SELECTOR, "td") - history_name = td[1].text - if history_name in histories: - checkbox = td[0].find_element(self.by.CSS_SELECTOR, "input") - checkbox.click() diff --git a/lib/galaxy_test/selenium/test_history_sharing.py b/lib/galaxy_test/selenium/test_history_sharing.py index eb53a0297649..0b39a8d0cbab 100644 --- a/lib/galaxy_test/selenium/test_history_sharing.py +++ b/lib/galaxy_test/selenium/test_history_sharing.py @@ -79,9 +79,10 @@ def test_shared_with_me(self): self.submit_login(user2_email, retries=VALID_LOGIN_RETRIES) self.navigate_to_histories_shared_with_me_page() self.components.shared_histories.selector.wait_for_present() + self.components.shared_histories.histories.wait_for_present() rows = self.components.shared_histories.histories.all() assert len(rows) > 0 - assert any(user1_email in row.text for row in rows) + assert any(user1_email.split("@")[0] in row.text for row in rows) def setup_two_users_with_one_shared_history(self, share_by_id=False): user1_email = self._get_random_email() From 104f223663c8d86b87e8addec2dae75d88d82ef2 Mon Sep 17 00:00:00 2001 From: guerler Date: Wed, 27 Dec 2023 08:31:49 +0300 Subject: [PATCH 21/50] Adjust histories advanced filter selenium tests --- .../Grid/GridElements/GridOperations.vue | 1 + client/src/utils/navigation/navigation.yml | 11 +++--- lib/galaxy/selenium/navigates_galaxy.py | 16 +++++---- .../selenium/test_histories_list.py | 36 ++++--------------- 4 files changed, 24 insertions(+), 40 deletions(-) diff --git a/client/src/components/Grid/GridElements/GridOperations.vue b/client/src/components/Grid/GridElements/GridOperations.vue index 8f1fae493d6b..62b5ddcadfbb 100644 --- a/client/src/components/Grid/GridElements/GridOperations.vue +++ b/client/src/components/Grid/GridElements/GridOperations.vue @@ -47,6 +47,7 @@ function hasCondition(conditionHandler: (rowData: RowData, config: GalaxyConfigu