From d89749bad7482f02b10ad57db1314daed4b09961 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Thu, 8 Feb 2018 17:57:07 -0500 Subject: [PATCH] Bulk delete and hide operations. --- .../scripts/mvc/history/history-view-edit.js | 59 ++++++++++-- lib/galaxy/managers/hdas.py | 7 ++ .../webapps/galaxy/api/history_contents.py | 96 ++++++++++++++----- lib/galaxy/webapps/galaxy/buildapp.py | 5 + test/api/test_history_contents.py | 13 +++ 5 files changed, 147 insertions(+), 33 deletions(-) diff --git a/client/galaxy/scripts/mvc/history/history-view-edit.js b/client/galaxy/scripts/mvc/history/history-view-edit.js index 6999a4ee586..0f9c17612b9 100644 --- a/client/galaxy/scripts/mvc/history/history-view-edit.js +++ b/client/galaxy/scripts/mvc/history/history-view-edit.js @@ -247,35 +247,76 @@ var HistoryViewEdit = _super.extend( * ajax calls made for multiple datasets are queued */ multiselectActions: function() { + var Galaxy = getGalaxyInstance(); var panel = this; + const batchUpdate = (key, value) => { + const actionModels = {}; + const items = panel + .getSelectedModels() + .filter(model => { + console.log(model.attributes[key]); + return model.attributes[key] != value; + }) + .map(model => { + const res = { + history_content_type: model.attributes.history_content_type, + id: model.attributes.id + }; + actionModels[model.id] = model; + // Tried "optimistically" deleting the dataset - but it didn't quite work - + // maybe history polling restored the previous state? + return res; + }); + + if (items.length == 0) { + return; + } + + const options = {}; + options.url = `${Galaxy.root}api/histories/${this.model.id}/contents`; + options.type = "PUT"; + const requestData = { items: items }; + requestData[key] = value; + options.data = JSON.stringify(requestData); + options.contentType = "application/json"; + + var xhr = jQuery.ajax(options); + xhr.done((message, status, responseObj) => { + for (let updated of message) { + const typeId = updated.history_content_type + "-" + updated.id; + actionModels[typeId].set(updated); + } + }); + xhr.fail((xhr, status, message) => { + console.error(message); + }); + return xhr; + }; + var actions = [ { html: _l("Hide datasets"), func: function() { - var action = HDA_MODEL.HistoryDatasetAssociation.prototype.hide; - panel.getSelectedModels().ajaxQueue(action); + batchUpdate("visible", false); } }, { html: _l("Unhide datasets"), func: function() { - var action = HDA_MODEL.HistoryDatasetAssociation.prototype.unhide; - panel.getSelectedModels().ajaxQueue(action); + batchUpdate("visible", true); } }, { html: _l("Delete datasets"), - func: function() { - var action = HDA_MODEL.HistoryDatasetAssociation.prototype["delete"]; - panel.getSelectedModels().ajaxQueue(action); + func: () => { + batchUpdate("deleted", true); } }, { html: _l("Undelete datasets"), func: function() { - var action = HDA_MODEL.HistoryDatasetAssociation.prototype.undelete; - panel.getSelectedModels().ajaxQueue(action); + batchUpdate("deleted", false); } } ]; diff --git a/lib/galaxy/managers/hdas.py b/lib/galaxy/managers/hdas.py index 38c03c86784..867583b2c80 100644 --- a/lib/galaxy/managers/hdas.py +++ b/lib/galaxy/managers/hdas.py @@ -47,6 +47,13 @@ class HDAManager(datasets.DatasetAssociationManager, super(HDAManager, self).__init__(app) self.user_manager = users.UserManager(app) + def get_owned_ids(self, object_ids, history=None): + """Get owned IDs. + """ + log.info("object_ids are %s" % object_ids) + filters = [self.model_class.id.in_(object_ids), self.model_class.history_id == history.id] + return self.list(filters=filters) + # .... security and permissions def is_accessible(self, hda, user, **kwargs): """ diff --git a/lib/galaxy/webapps/galaxy/api/history_contents.py b/lib/galaxy/webapps/galaxy/api/history_contents.py index d875bb6882d..81f612663fb 100644 --- a/lib/galaxy/webapps/galaxy/api/history_contents.py +++ b/lib/galaxy/webapps/galaxy/api/history_contents.py @@ -600,15 +600,60 @@ class HistoryContentsController(BaseAPIController, UsesLibraryMixin, UsesLibrary self.hda_manager.update_permissions(trans, hda, **kwd) return self.hda_manager.serialize_dataset_association_roles(trans, hda) + @expose_api_anonymous + def update_batch(self, trans, history_id, payload, **kwd): + """ + update( self, trans, history_id, id, payload, **kwd ) + * PUT /api/histories/{history_id}/contents + + :type history_id: str + :param history_id: encoded id string of the history containing supplied items + :type id: str + :param id: the encoded id of the history to update + :type payload: dict + :param payload: a dictionary containing any or all the + + :rtype: dict + :returns: an error object if an error occurred or a dictionary containing + any values that were different from the original and, therefore, updated + """ + items = payload.get("items") + hda_ids = [] + hdca_ids = [] + for item in items: + contents_type = item["history_content_type"] + if contents_type == "dataset": + decoded_id = self.decode_id(item["id"]) + hda_ids.append(decoded_id) + else: + hdca_ids.append(item["id"]) + + history = self.history_manager.get_owned(self.decode_id(history_id), trans.user, + current_history=trans.history) + hdas = self.__datasets_for_update(trans, history, hda_ids, payload) + rval = [] + for hda in hdas: + self.__deserialize_dataset(hda, payload, trans) + rval.append(self.hda_serializer.serialize_to_view(hda, + user=trans.user, trans=trans, **self._parse_serialization_params(kwd, 'summary'))) + for hdca_id in hdca_ids: + self.__update_dataset_collection(trans, history_id, hdca_id, payload, **kwd) + dataset_collection_instance = self.__get_accessible_collection(trans, hdca_id, history_id) + rval.append(self.__collection_dict(trans, dataset_collection_instance, view="summary")) + return rval + # aggregate by ids. + # hdas_manager.by_ids() with history or user. + # dataset_collection_service.by_ids() with history or user. + @expose_api_anonymous def update(self, trans, history_id, id, payload, **kwd): """ update( self, trans, history_id, id, payload, **kwd ) * PUT /api/histories/{history_id}/contents/{id} - updates the values for the HDA with the given ``id`` + updates the values for the history content item with the given ``id`` :type history_id: str - :param history_id: encoded id string of the HDA's History + :param history_id: encoded id string of the items's History :type id: str :param id: the encoded id of the history to update :type payload: dict @@ -634,13 +679,20 @@ class HistoryContentsController(BaseAPIController, UsesLibraryMixin, UsesLibrary def __update_dataset(self, trans, history_id, id, payload, **kwd): # anon user: ensure that history ids match up and the history is the current, # check for uploading, and use only the subset of attribute keys manipulatable by anon users + decoded_id = self.decode_id(id) + history = self.history_manager.get_owned(self.decode_id(history_id), trans.user, + current_history=trans.history) + hda = self.__datasets_for_update(trans, history, [decoded_id], payload)[0] + if hda: + self.__deserialize_dataset(hda, payload, trans) + return self.hda_serializer.serialize_to_view(hda, + user=trans.user, trans=trans, **self._parse_serialization_params(kwd, 'detailed')) + + return {} + + def __datasets_for_update(self, trans, history, hda_ids, payload): anonymous_user = not trans.user_is_admin and trans.user is None if anonymous_user: - hda = self.hda_manager.by_id(self.decode_id(id)) - if hda.history != trans.history: - raise exceptions.AuthenticationRequired('API authentication required for this request') - hda = self.hda_manager.error_if_uploading(hda) - anon_allowed_payload = {} if 'deleted' in payload: anon_allowed_payload['deleted'] = payload['deleted'] @@ -648,25 +700,21 @@ class HistoryContentsController(BaseAPIController, UsesLibraryMixin, UsesLibrary anon_allowed_payload['visible'] = payload['visible'] payload = anon_allowed_payload - # logged in user: use full payload, check state if deleting, and make sure the history is theirs - else: - hda = self.hda_manager.get_owned(self.decode_id(id), trans.user, current_history=trans.history, trans=trans) + hdas = self.hda_manager.get_owned_ids(hda_ids, history=history) - # only check_state if not deleting, otherwise cannot delete uploading files - check_state = not payload.get('deleted', False) - if check_state: + # only check_state if not deleting, otherwise cannot delete uploading files + check_state = not payload.get('deleted', False) + if check_state: + for hda in hdas: hda = self.hda_manager.error_if_uploading(hda) - # make the actual changes - if hda: - self.hda_deserializer.deserialize(hda, payload, user=trans.user, trans=trans) - # TODO: this should be an effect of deleting the hda - if payload.get('deleted', False): - self.hda_manager.stop_creating_job(hda) - return self.hda_serializer.serialize_to_view(hda, - user=trans.user, trans=trans, **self._parse_serialization_params(kwd, 'detailed')) + return hdas - return {} + def __deserialize_dataset(self, hda, payload, trans): + self.hda_deserializer.deserialize(hda, payload, user=trans.user, trans=trans) + # TODO: this should be an effect of deleting the hda + if payload.get('deleted', False): + self.hda_manager.stop_creating_job(hda) def __update_dataset_collection(self, trans, history_id, id, payload, **kwd): return trans.app.dataset_collections_service.update(trans, "history", id, payload) @@ -700,8 +748,8 @@ class HistoryContentsController(BaseAPIController, UsesLibraryMixin, UsesLibrary :rtype: dict :returns: an error object if an error occurred or a dictionary containing: * id: the encoded id of the history, - * deleted: if the history was marked as deleted, - * purged: if the history was purged + * deleted: if the history content was marked as deleted, + * purged: if the history content was purged """ contents_type = kwd.get('type', 'dataset') if contents_type == "dataset": diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 59d6ae65a69..b10c23002f4 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -216,6 +216,11 @@ def populate_api_routes(webapp, app): name_prefix='history_', path_prefix='/api/histories/{history_id}', parent_resources=dict(member_name='history', collection_name='histories')) + webapp.mapper.connect("history_contents_batch_update", + "/api/histories/{history_id}/contents", + controller="history_contents", + action="update_batch", + conditions=dict(method=["PUT"])) webapp.mapper.connect("history_contents_display", "/api/histories/{history_id}/contents/{history_content_id}/display", controller="datasets", diff --git a/test/api/test_history_contents.py b/test/api/test_history_contents.py index 58a3ada5ac6..3b994496156 100644 --- a/test/api/test_history_contents.py +++ b/test/api/test_history_contents.py @@ -218,6 +218,14 @@ class HistoryContentsApiTestCase(api.ApiTestCase, TestsDatasets): # and this is an invalid ID. assert update_response.status_code == 400, update_response.content + def test_update_batch(self): + hda1 = self._wait_for_new_hda() + assert str(hda1["deleted"]).lower() == "false" + payload = dict(items=[{"history_content_type": "dataset", "id": hda1["id"]}], deleted=True) + update_response = self._raw_update_batch(payload) + objects = update_response.json() + assert objects[0]["deleted"] + def test_update_type_failures(self): hda1 = self._wait_for_new_hda() update_response = self._raw_update(hda1["id"], dict(deleted='not valid')) @@ -246,6 +254,11 @@ class HistoryContentsApiTestCase(api.ApiTestCase, TestsDatasets): update_response = put(update_url, json=data) return update_response + def _raw_update_batch(self, data): + update_url = self._api_url("histories/%s/contents" % (self.history_id), use_key=True) + update_response = put(update_url, json=data) + return update_response + def test_delete(self): hda1 = self._new_dataset(self.history_id) self._wait_for_history(self.history_id)