diff --git a/client/src/api/groups.ts b/client/src/api/groups.ts index 4f0c3e236e87..e795ccfdbf47 100644 --- a/client/src/api/groups.ts +++ b/client/src/api/groups.ts @@ -1,9 +1,13 @@ import axios from "axios"; -import { components } from "@/api/schema"; +import { components, fetcher } from "@/api/schema"; type GroupModel = components["schemas"]["GroupModel"]; export async function getAllGroups(): Promise { const { data } = await axios.get("/api/groups"); return data; } + +export const deleteGroup = fetcher.path("/api/groups/{group_id}").method("delete").create(); +export const purgeGroup = fetcher.path("/api/groups/{group_id}/purge").method("post").create(); +export const undeleteGroup = fetcher.path("/api/groups/{group_id}/undelete").method("post").create(); diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 9b544b717fb0..9988744c92f9 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -392,6 +392,12 @@ export interface paths { get: operations["show_group_api_groups__group_id__get"]; /** Modifies a group. */ put: operations["update_api_groups__group_id__put"]; + /** Delete */ + delete: operations["delete_api_groups__group_id__delete"]; + }; + "/api/groups/{group_id}/purge": { + /** Purge */ + post: operations["purge_api_groups__group_id__purge_post"]; }; "/api/groups/{group_id}/roles": { /** Displays a collection (list) of groups. */ @@ -405,6 +411,10 @@ export interface paths { /** Removes a role from a group */ delete: operations["delete_api_groups__group_id__roles__role_id__delete"]; }; + "/api/groups/{group_id}/undelete": { + /** Undelete */ + post: operations["undelete_api_groups__group_id__undelete_post"]; + }; "/api/groups/{group_id}/user/{user_id}": { /** * Displays information about a group user. @@ -12044,6 +12054,58 @@ export interface operations { }; }; }; + delete_api_groups__group_id__delete: { + /** Delete */ + 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; + }; + path: { + group_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": Record; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + purge_api_groups__group_id__purge_post: { + /** Purge */ + 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; + }; + path: { + group_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": Record; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; group_roles_api_groups__group_id__roles_get: { /** Displays a collection (list) of groups. */ parameters: { @@ -12158,6 +12220,32 @@ export interface operations { }; }; }; + undelete_api_groups__group_id__undelete_post: { + /** Undelete */ + 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; + }; + path: { + group_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": Record; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; group_user_api_groups__group_id__user__user_id__get: { /** * Displays information about a group user. diff --git a/client/src/components/Grid/GridList.vue b/client/src/components/Grid/GridList.vue index 357b728ca65b..c5de3f921608 100644 --- a/client/src/components/Grid/GridList.vue +++ b/client/src/components/Grid/GridList.vue @@ -27,6 +27,8 @@ library.add(faCaretDown, faCaretUp, faShieldAlt); interface Props { // provide a grid configuration gridConfig: GridConfig; + // incoming initial message + gridMessage?: string; // debounce delay delay?: number; // rows per page to be shown @@ -81,6 +83,16 @@ function applyFilter(filter: string, value: string | boolean, quoted = false) { } } +/** + * Display initial message parsed through route query + */ +function displayInitialMessage() { + if (props.gridMessage) { + operationMessage.value = props.gridMessage; + operationStatus.value = "success"; + } +} + /** * Request grid data */ @@ -164,6 +176,7 @@ function onFilter(filter?: string) { onMounted(() => { getGridData(); eventBus.on(onRouterPush); + displayInitialMessage(); }); onUnmounted(() => { diff --git a/client/src/components/Grid/configs/adminGroups.ts b/client/src/components/Grid/configs/adminGroups.ts new file mode 100644 index 000000000000..495d73f2fb42 --- /dev/null +++ b/client/src/components/Grid/configs/adminGroups.ts @@ -0,0 +1,181 @@ +import { faEdit, faKey, faPlus, faTrash, faTrashRestore } from "@fortawesome/free-solid-svg-icons"; +import { useEventBus } from "@vueuse/core"; +import axios from "axios"; + +import { deleteGroup, purgeGroup, undeleteGroup } from "@/api/groups"; +import Filtering, { contains, equals, toBool, type ValidFilter } from "@/utils/filtering"; +import _l from "@/utils/localization"; +import { withPrefix } from "@/utils/redirect"; +import { errorMessageAsString } from "@/utils/simple-error"; + +import type { ActionArray, FieldArray, GridConfig } from "./types"; + +const { emit } = useEventBus("grid-router-push"); + +/** + * Local types + */ +type GroupEntry = Record; + +/** + * Request and return data from server + */ +async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) { + const query = { + limit: String(limit), + offset: String(offset), + search: search, + sort_by: sort_by, + sort_desc: String(sort_desc), + }; + const queryString = new URLSearchParams(query).toString(); + const { data } = await axios.get(withPrefix(`/admin/groups_list?${queryString}`)); + return [data.rows, data.rows_total]; +} + +/** + * Actions are grid-wide operations + */ +const actions: ActionArray = [ + { + title: "Create New Group", + icon: faPlus, + handler: () => { + emit("/admin/form/create_group"); + }, + }, +]; + +/** + * Declare columns to be displayed + */ +const fields: FieldArray = [ + { + key: "name", + title: "Name", + type: "operations", + operations: [ + { + title: "Edit Name", + icon: faEdit, + condition: (data: GroupEntry) => !data.deleted, + handler: (data: GroupEntry) => { + emit(`/admin/form/rename_group?id=${data.id}`); + }, + }, + { + title: "Edit Permissions", + icon: faKey, + condition: (data: GroupEntry) => !data.deleted, + handler: (data: GroupEntry) => { + emit(`/admin/form/manage_users_and_roles_for_group?id=${data.id}`); + }, + }, + { + title: "Delete", + icon: faTrash, + condition: (data: GroupEntry) => !data.deleted, + handler: async (data: GroupEntry) => { + if (confirm(_l("Are you sure that you want to delete this group?"))) { + try { + await deleteGroup({ group_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: "Purge", + icon: faTrash, + condition: (data: GroupEntry) => !!data.deleted, + handler: async (data: GroupEntry) => { + if (confirm(_l("Are you sure that you want to purge this group?"))) { + try { + await purgeGroup({ group_id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been purged.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to purge '${data.name}': ${errorMessageAsString(e)}`, + }; + } + } + }, + }, + { + title: "Restore", + icon: faTrashRestore, + condition: (data: GroupEntry) => !!data.deleted, + handler: async (data: GroupEntry) => { + try { + await undeleteGroup({ group_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: "roles", + title: "Roles", + type: "text", + }, + { + key: "users", + title: "Users", + type: "text", + }, + { + key: "update_time", + title: "Updated", + type: "date", + }, +]; + +const validFilters: Record> = { + name: { placeholder: "name", type: String, handler: contains("name"), 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: "groups-grid", + actions: actions, + fields: fields, + filtering: new Filtering(validFilters, undefined, false, false), + getData: getData, + plural: "Groups", + sortBy: "name", + sortDesc: true, + sortKeys: ["name", "update_time"], + title: "Groups", +}; + +export default gridConfig; diff --git a/client/src/components/Grid/configs/adminRoles.ts b/client/src/components/Grid/configs/adminRoles.ts index b1365e27b7c9..f1f08012c326 100644 --- a/client/src/components/Grid/configs/adminRoles.ts +++ b/client/src/components/Grid/configs/adminRoles.ts @@ -4,6 +4,7 @@ import axios from "axios"; import { deleteRole, purgeRole, undeleteRole } from "@/api/roles"; import Filtering, { contains, equals, toBool, type ValidFilter } from "@/utils/filtering"; +import _l from "@/utils/localization"; import { withPrefix } from "@/utils/redirect"; import { errorMessageAsString } from "@/utils/simple-error"; @@ -75,17 +76,19 @@ const fields: FieldArray = [ icon: faTrash, condition: (data: RoleEntry) => !data.deleted, handler: async (data: RoleEntry) => { - try { - await deleteRole({ 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)}`, - }; + if (confirm(_l("Are you sure that you want to delete this role?"))) { + try { + await deleteRole({ 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)}`, + }; + } } }, }, @@ -94,17 +97,19 @@ const fields: FieldArray = [ icon: faTrash, condition: (data: RoleEntry) => !!data.deleted, handler: async (data: RoleEntry) => { - try { - await purgeRole({ id: String(data.id) }); - return { - status: "success", - message: `'${data.name}' has been purged.`, - }; - } catch (e) { - return { - status: "danger", - message: `Failed to purge '${data.name}': ${errorMessageAsString(e)}`, - }; + if (confirm(_l("Are you sure that you want to purge this role?"))) { + try { + await purgeRole({ id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been purged.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to purge '${data.name}': ${errorMessageAsString(e)}`, + }; + } } }, }, diff --git a/client/src/entry/analysis/routes/admin-routes.js b/client/src/entry/analysis/routes/admin-routes.js index 7be38abcfc62..ad2e3437f9ca 100644 --- a/client/src/entry/analysis/routes/admin-routes.js +++ b/client/src/entry/analysis/routes/admin-routes.js @@ -18,6 +18,7 @@ import NotificationsManagement from "components/admin/Notifications/Notification import ResetMetadata from "components/admin/ResetMetadata"; import SanitizeAllow from "components/admin/SanitizeAllow"; import FormGeneric from "components/Form/FormGeneric"; +import adminGroupsGridConfig from "components/Grid/configs/adminGroups"; import adminRolesGridConfig from "components/Grid/configs/adminRoles"; import adminUsersGridConfig from "components/Grid/configs/adminUsers"; import Grid from "components/Grid/Grid"; @@ -135,10 +136,11 @@ export default [ }, { path: "groups", - component: Grid, - props: { - urlBase: "admin/groups_list", - }, + component: GridList, + props: (route) => ({ + gridConfig: adminGroupsGridConfig, + gridMessage: route.query.message, + }), }, { path: "quotas", @@ -150,16 +152,18 @@ export default [ { path: "roles", component: GridList, - props: { + props: (route) => ({ gridConfig: adminRolesGridConfig, - }, + gridMessage: route.query.message, + }), }, { path: "users", component: GridList, - props: { + props: (route) => ({ gridConfig: adminUsersGridConfig, - }, + gridMessage: route.query.message, + }), }, { path: "tool_versions", @@ -194,7 +198,7 @@ export default [ component: FormGeneric, props: (route) => ({ url: `/admin/manage_users_and_groups_for_role?id=${route.query.id}`, - redirect: "/admin/users", + redirect: "/admin/roles", }), }, { @@ -202,7 +206,7 @@ export default [ component: FormGeneric, props: (route) => ({ url: `/admin/manage_users_and_roles_for_group?id=${route.query.id}`, - redirect: "/admin/users", + redirect: "/admin/groups", }), }, { diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml index e1dba3b8c8ca..1eb623ea1869 100644 --- a/client/src/utils/navigation/navigation.yml +++ b/client/src/utils/navigation/navigation.yml @@ -956,7 +956,7 @@ admin: dm_table_card: '#data-table-card' users_grid: '#users-grid' users_grid_create_button: '[data-description="grid action create new user"]' - groups_grid_create_button: '.manage-table-actions .action-button' + groups_grid_create_button: '[data-description="grid action create new group"]' registration_form: 'form#registration' groups_grid: '#groups-grid' roles_grid: '#roles-grid' diff --git a/lib/galaxy/managers/groups.py b/lib/galaxy/managers/groups.py index e8919d4db72a..6cbea985d74a 100644 --- a/lib/galaxy/managers/groups.py +++ b/lib/galaxy/managers/groups.py @@ -9,6 +9,7 @@ Conflict, ObjectAttributeMissingException, ObjectNotFound, + RequestParameterInvalidException, ) from galaxy.managers.context import ProvidesAppContext from galaxy.managers.roles import get_roles_by_ids @@ -101,6 +102,39 @@ def update(self, trans: ProvidesAppContext, group_id: int, payload: GroupCreateP item["url"] = self._url_for(trans, "show_group", group_id=encoded_id) return item + def delete(self, trans: ProvidesAppContext, group_id: int): + group = self._get_group(trans.sa_session, group_id) + group.deleted = True + trans.sa_session.add(group) + with transaction(trans.sa_session): + trans.sa_session.commit() + + def purge(self, trans: ProvidesAppContext, group_id: int): + group = self._get_group(trans.sa_session, group_id) + if not group.deleted: + raise RequestParameterInvalidException( + f"Group '{group.name}' has not been deleted, so it cannot be purged." + ) + # Delete UserGroupAssociations + for uga in group.users: + trans.sa_session.delete(uga) + # Delete GroupRoleAssociations + for gra in group.roles: + trans.sa_session.delete(gra) + with transaction(trans.sa_session): + trans.sa_session.commit() + + def undelete(self, trans: ProvidesAppContext, group_id: int): + group = self._get_group(trans.sa_session, group_id) + if not group.deleted: + raise RequestParameterInvalidException( + f"Group '{group.name}' has not been deleted, so it cannot be undeleted." + ) + group.deleted = False + trans.sa_session.add(group) + with transaction(trans.sa_session): + trans.sa_session.commit() + def _url_for(self, trans, name, **kwargs): return trans.url_builder(name, **kwargs) diff --git a/lib/galaxy/web/framework/helpers/grids.py b/lib/galaxy/web/framework/helpers/grids.py index d34aac73829f..1306b7306f6f 100644 --- a/lib/galaxy/web/framework/helpers/grids.py +++ b/lib/galaxy/web/framework/helpers/grids.py @@ -1114,9 +1114,3 @@ def get_current_item(self, trans, **kwargs): def build_initial_query(self, trans, **kwargs): return trans.sa_session.query(self.model_class) - - def apply_query_filter(self, trans, query, **kwargs): - # Applies a database filter that holds for all items in the grid. - # (gvk) Is this method necessary? Why not simply build the entire query, - # including applying filters in the build_initial_query() method? - return query diff --git a/lib/galaxy/webapps/galaxy/api/groups.py b/lib/galaxy/webapps/galaxy/api/groups.py index 2b4c7e1ca1d5..c571983df48a 100644 --- a/lib/galaxy/webapps/galaxy/api/groups.py +++ b/lib/galaxy/webapps/galaxy/api/groups.py @@ -82,3 +82,15 @@ def update( payload: GroupCreatePayload = Body(...), ) -> GroupResponse: return self.manager.update(trans, group_id, payload) + + @router.delete("/api/groups/{group_id}", require_admin=True) + def delete(self, group_id: DecodedDatabaseIdField, trans: ProvidesAppContext = DependsOnTrans): + self.manager.delete(trans, group_id) + + @router.post("/api/groups/{group_id}/purge", require_admin=True) + def purge(self, group_id: DecodedDatabaseIdField, trans: ProvidesAppContext = DependsOnTrans): + self.manager.purge(trans, group_id) + + @router.post("/api/groups/{group_id}/undelete", require_admin=True) + def undelete(self, group_id: DecodedDatabaseIdField, trans: ProvidesAppContext = DependsOnTrans): + self.manager.undelete(trans, group_id) diff --git a/lib/galaxy/webapps/galaxy/controllers/admin.py b/lib/galaxy/webapps/galaxy/controllers/admin.py index f6068d87df13..ac2109d310bc 100644 --- a/lib/galaxy/webapps/galaxy/controllers/admin.py +++ b/lib/galaxy/webapps/galaxy/controllers/admin.py @@ -26,7 +26,6 @@ ) from galaxy.security.validate_user_input import validate_password from galaxy.structured_app import StructuredApp -from galaxy.util import pretty_print_time_interval from galaxy.util.search import ( FilteredTerm, parse_filters_structured, @@ -224,17 +223,7 @@ def apply_query_filter(self, query, **kwargs): return query -class GroupListGrid(grids.Grid): - class NameColumn(grids.TextColumn): - def get_value(self, trans, grid, group): - return escape(group.name) - - class StatusColumn(grids.GridColumn): - def get_value(self, trans, grid, group): - if group.deleted: - return "deleted" - return "" - +class GroupListGrid(grids.GridData): class RolesColumn(grids.GridColumn): def get_value(self, trans, grid, group): if group.roles: @@ -253,51 +242,42 @@ def get_value(self, trans, grid, group): model_class = model.Group default_sort_key = "name" columns = [ - NameColumn( - "Name", - key="name", - link=(lambda item: dict(action="form/manage_users_and_roles_for_group", id=item.id, webapp="galaxy")), - model_class=model.Group, - attach_popup=True, - filterable="advanced", - ), - UsersColumn("Users", attach_popup=False), - RolesColumn("Roles", attach_popup=False), - StatusColumn("Status", attach_popup=False), - # Columns that are valid for filtering but are not visible. - grids.DeletedColumn("Deleted", key="deleted", visible=False, filterable="advanced"), - grids.GridColumn("Last Updated", key="update_time", format=pretty_print_time_interval), - ] - columns.append( - grids.MulticolFilterColumn( - "Search", cols_to_filter=[columns[0]], key="free-text-search", visible=False, filterable="standard" - ) - ) - global_actions = [grids.GridAction("Add new group", url_args=dict(action="form/create_group"))] - operations = [ - grids.GridOperation( - "Edit Name", - condition=(lambda item: not item.deleted), - allow_multiple=False, - url_args=dict(action="form/rename_group"), - ), - grids.GridOperation( - "Edit Permissions", - condition=(lambda item: not item.deleted), - allow_multiple=False, - url_args=dict(action="form/manage_users_and_roles_for_group", webapp="galaxy"), - ), - grids.GridOperation("Delete", condition=(lambda item: not item.deleted), allow_multiple=True), - grids.GridOperation("Undelete", condition=(lambda item: item.deleted), allow_multiple=True), - grids.GridOperation("Purge", condition=(lambda item: item.deleted), allow_multiple=True), - ] - standard_filters = [ - grids.GridColumnFilter("Active", args=dict(deleted=False)), - grids.GridColumnFilter("Deleted", args=dict(deleted=True)), - grids.GridColumnFilter("All", args=dict(deleted="All")), + grids.GridColumn("Name", key="name"), + UsersColumn("Users", key="users"), + RolesColumn("Roles", key="roles"), + grids.DeletedColumn("Deleted", key="deleted", escape=False), + grids.GridColumn("Last Updated", key="update_time"), ] - num_rows_per_page = 50 - use_paging = True + + def apply_query_filter(self, query, **kwargs): + INDEX_SEARCH_FILTERS = { + "name": "name", + "is": "is", + } + deleted = False + search_query = kwargs.get("search") + if search_query: + parsed_search = parse_filters_structured(search_query, INDEX_SEARCH_FILTERS) + for term in parsed_search.terms: + if isinstance(term, FilteredTerm): + key = term.filter + q = term.text + if key == "name": + query = query.filter(text_column_filter(self.model_class.name, term)) + elif key == "is": + if q == "deleted": + deleted = True + elif isinstance(term, RawTextTerm): + query = query.filter( + raw_text_column_filter( + [ + self.model_class.name, + ], + term, + ) + ) + query = query.filter(self.model_class.deleted == (true() if deleted else false())) + return query class QuotaListGrid(grids.Grid): @@ -1021,23 +1001,6 @@ def manage_users_and_groups_for_role(self, trans, payload=None, **kwd): @web.legacy_expose_api @web.require_admin def groups_list(self, trans, **kwargs): - message = kwargs.get("message") - status = kwargs.get("status") - if "operation" in kwargs: - id = kwargs.get("id") - if not id: - return self.message_exception(trans, f"Invalid group id ({str(id)}) received.") - ids = util.listify(id) - operation = kwargs["operation"].lower().replace("+", " ") - if operation == "delete": - message, status = self._delete_group(trans, ids) - elif operation == "undelete": - message, status = self._undelete_group(trans, ids) - elif operation == "purge": - message, status = self._purge_group(trans, ids) - if message and status: - kwargs["message"] = util.sanitize_text(message) - kwargs["status"] = status return self.group_list_grid(trans, **kwargs) @web.legacy_expose_api @@ -1220,49 +1183,6 @@ def create_group(self, trans, payload=None, **kwd): ) return {"message": message} - def _delete_group(self, trans, ids): - message = "Deleted %d groups: " % len(ids) - for group_id in ids: - group = get_group(trans, group_id) - group.deleted = True - trans.sa_session.add(group) - with transaction(trans.sa_session): - trans.sa_session.commit() - message += f" {group.name} " - return (message, "done") - - def _undelete_group(self, trans, ids): - count = 0 - undeleted_groups = "" - for group_id in ids: - group = get_group(trans, group_id) - if not group.deleted: - return (f"Group '{group.name}' has not been deleted, so it cannot be undeleted.", "error") - group.deleted = False - trans.sa_session.add(group) - with transaction(trans.sa_session): - trans.sa_session.commit() - count += 1 - undeleted_groups += f" {group.name}" - return ("Undeleted %d groups: %s" % (count, undeleted_groups), "done") - - def _purge_group(self, trans, ids): - message = "Purged %d groups: " % len(ids) - for group_id in ids: - group = get_group(trans, group_id) - if not group.deleted: - return (f"Group '{group.name}' has not been deleted, so it cannot be purged.", "error") - # Delete UserGroupAssociations - for uga in group.users: - trans.sa_session.delete(uga) - # Delete GroupRoleAssociations - for gra in group.roles: - trans.sa_session.delete(gra) - with transaction(trans.sa_session): - trans.sa_session.commit() - message += f" {group.name} " - return (message, "done") - @web.expose @web.require_admin def create_new_user(self, trans, **kwd):