diff --git a/api/api.py b/api/api.py index 25f1d3611..6de93e09d 100644 --- a/api/api.py +++ b/api/api.py @@ -13,6 +13,7 @@ from .handlers.reporthandler import ReportHandler from .handlers.resolvehandler import ResolveHandler from .handlers.roothandler import RootHandler +from .handlers.savesearchhandler import SaveSearchHandler from .handlers.schemahandler import SchemaHandler from .handlers.userhandler import UserHandler from .jobs.handlers import BatchHandler, JobsHandler, JobHandler, GearsHandler, GearHandler, RulesHandler, RuleHandler @@ -109,6 +110,12 @@ def prefix(path, routes): route('/dataexplorer/search/nodes', DataExplorerHandler, h='get_nodes', m=['POST']), route('/dataexplorer/index/fields', DataExplorerHandler, h='index_field_names', m=['POST']), + # Search Saving + route('/savesearches', SaveSearchHandler, m=['POST']), + route('/savesearches', SaveSearchHandler, h='get_all', m=['GET']), + route('/savesearches/', SaveSearchHandler, m=['GET','DELETE']), + route('/savesearches/', SaveSearchHandler, h='replace_search', m=['POST']), + # Users route( '/users', UserHandler, h='get_all', m=['GET']), @@ -233,7 +240,7 @@ def prefix(path, routes): # Collections / Projects - prefix('/', [ + prefix('/', [ prefix('/', [ route('/', PermissionsListHandler, m=['POST']), route('//<_id:{uid}>', PermissionsListHandler, m=['GET', 'PUT', 'DELETE']), diff --git a/api/config.py b/api/config.py index 74657503d..1ad3d7649 100644 --- a/api/config.py +++ b/api/config.py @@ -172,6 +172,7 @@ def apply_env_variables(config): 'project-update.json', 'rule-new.json', 'rule-update.json', + 'search-input.json', 'session.json', 'session-update.json', 'subject.json', @@ -228,6 +229,7 @@ def initialize_db(): # TODO review all indexes db.users.create_index('api_key.key') db.projects.create_index([('gid', 1), ('name', 1)]) + db.savesearches.create_index('creator') db.sessions.create_index('project') db.sessions.create_index('uid') db.sessions.create_index('created') diff --git a/api/dao/containerstorage.py b/api/dao/containerstorage.py index de2790385..24ac359a1 100644 --- a/api/dao/containerstorage.py +++ b/api/dao/containerstorage.py @@ -1,5 +1,5 @@ import datetime - +import pymongo import bson import copy @@ -10,7 +10,7 @@ from ..jobs.jobs import Job from ..jobs.queue import Queue from ..jobs.rules import copy_site_rules_for_project -from ..web.errors import APIStorageException, APINotFoundException +from ..web.errors import APIStorageException, APINotFoundException, APIConflictException from .basecontainerstorage import ContainerStorage log = config.log @@ -434,3 +434,19 @@ def inflate_job_info(self, analysis): analysis['job'] = job return analysis + +class SearchStorage(ContainerStorage): + + def __init__(self): + super(SearchStorage, self).__init__('savesearches', use_object_id=True) + + def create_el(self, payload): + try: + result = self.dbc.insert_one(payload) + except pymongo.errors.DuplicateKeyError: + raise APIConflictException('Object with id {} already exists.'.format(payload['_id'])) + return result + + def replace_el(self, search): + self.delete_el(search['_id']) + return self.create_el(search) diff --git a/api/dao/containerutil.py b/api/dao/containerutil.py index bc748a0c5..836d1cf41 100644 --- a/api/dao/containerutil.py +++ b/api/dao/containerutil.py @@ -17,6 +17,7 @@ 'project': 'projects', 'session': 'sessions', 'user': 'users', + 'savesearch': 'savesearches', } PLURAL_TO_SINGULAR = {p: s for s, p in SINGULAR_TO_PLURAL.iteritems()} diff --git a/api/handlers/listhandler.py b/api/handlers/listhandler.py index fd1ab0405..5e7da6dae 100644 --- a/api/handlers/listhandler.py +++ b/api/handlers/listhandler.py @@ -82,6 +82,7 @@ def initialize_list_configurations(): 'acquisitions': copy.deepcopy(container_default_configurations), 'collections': copy.deepcopy(container_default_configurations), 'analyses': copy.deepcopy(container_default_configurations), + 'savesearches': copy.deepcopy(container_default_configurations), } # preload the Storage instances for all configurations for cont_name, cont_config in list_container_configurations.iteritems(): diff --git a/api/handlers/savesearchhandler.py b/api/handlers/savesearchhandler.py new file mode 100644 index 000000000..b12d761ab --- /dev/null +++ b/api/handlers/savesearchhandler.py @@ -0,0 +1,99 @@ +import bson +from ast import literal_eval +from ..web import base +from .. import config, validators +from ..auth import require_login +from ..dao.containerstorage import SearchStorage + +from ..auth import groupauth +from ..dao import noop + + +log = config.log +storage = SearchStorage() + + +def string_filters(payload): + if payload.get('search') and payload['search'].get('filters'): + filters = [] + for filter_ in payload['search'].get('filters',[]): + filters.append(str(filter_)) + payload['search']['filters'] = filters + return payload + +def unstring_filters(payload): + if payload['search'].get('filters'): + filters= [] + for filter_ in payload['search'].get('filters',[]): + filters.append(literal_eval(filter_)) + payload['search']['filters']= filters + return payload + +class SaveSearchHandler(base.RequestHandler): + + def __init__(self, request=None, response=None): + super(SaveSearchHandler, self).__init__(request, response) + + @require_login + def post(self): + payload = self.request.json_body + validators.validate_data(payload, 'search-input.json', 'input', 'POST') + payload = string_filters(payload) + payload['permissions'] = [{"_id": self.uid, "access": "admin"}] + payload['creator'] = self.uid + result = storage.create_el(payload) + if result.acknowledged: + if result.inserted_id: + return {'_id': result.inserted_id} + else: + self.abort(404, 'Search not created') + + def get_all(self): + log.debug(self.uid) + return storage.get_all_el({}, {'_id': self.uid}, {'label': 1}) + + def get(self, sid): + result = storage.get_el(sid) + if result is None: + self.abort(404, 'Element {} not found'.format(sid)) + unstring_filters(result) + return result + + def delete(self, sid): + search = storage.get_container(sid) + permchecker = groupauth.default(self, search) + result = permchecker(storage.exec_op)('DELETE', sid) + if result.deleted_count == 1: + return {'deleted': result.deleted_count} + else: + self.abort(404, 'Group {} not removed'.format(sid)) + return result + + def replace_search(self, sid): + payload = self.request.json_body + payload = self._scrub_replace(payload) + validators.validate_data(payload, 'search-input.json', 'input', 'POST') + payload = string_filters(payload) + payload['_id'] = bson.ObjectId(sid) + search = storage.get_container(sid) + payload['permissions'] = search['permissions'] + permchecker = groupauth.default(self, search) + permchecker(noop)('DELETE', sid) + result = storage.replace_el(payload) + if result.acknowledged: + if result.inserted_id: + return {'_id': result.inserted_id} + return {"hi" : "bye"} + + def _scrub_replace(self, payload): + ''' + Function to turn a search returned from a GET to a legal post/replace + ''' + if payload.get('_id'): + del(payload['_id']) + if payload.get('permissions'): + del(payload['permissions']) + if payload.get('creator'): + del(payload['creator']) + return payload + diff --git a/raml/api.raml b/raml/api.raml index 27623b518..94a5e2361 100644 --- a/raml/api.raml +++ b/raml/api.raml @@ -55,3 +55,4 @@ resourceTypes: /acquisitions: !include resources/acquisitions.raml /projects: !include resources/projects.raml /report: !include resources/report.raml +/savesearches: !include resources/savesearch.raml diff --git a/raml/examples/input/search-input.json b/raml/examples/input/search-input.json new file mode 100644 index 000000000..4ce20dd6c --- /dev/null +++ b/raml/examples/input/search-input.json @@ -0,0 +1,7 @@ +{ +"search": { + "return_type": "file", + "filters": [{"terms": {"file.type":["nifti"]}}] +}, +"label" : "Test Search" +} diff --git a/raml/examples/output/search-list.json b/raml/examples/output/search-list.json new file mode 100644 index 000000000..6d4ccf812 --- /dev/null +++ b/raml/examples/output/search-list.json @@ -0,0 +1,4 @@ +[{ + "label": "Test Search", + "_id": "57e452791cff88b85f9f9c23" +}] \ No newline at end of file diff --git a/raml/examples/output/search-output.json b/raml/examples/output/search-output.json new file mode 100644 index 000000000..a005e15c2 --- /dev/null +++ b/raml/examples/output/search-output.json @@ -0,0 +1,10 @@ +{ + "label": "Test Search", + "_id": "57e452791cff88b85f9f9c23", + "search": { + "return_type": "file", + "filters": [{"terms": {"file.type":["nifti"]}}] + }, + "permissions": [{"access": "admin", "_id": "harshakethineni@invenshure.com"}], + "creator": "harshakethineni@invenshure.com" +} diff --git a/raml/resources/savesearch.raml b/raml/resources/savesearch.raml new file mode 100644 index 000000000..49dde59b3 --- /dev/null +++ b/raml/resources/savesearch.raml @@ -0,0 +1,38 @@ +type: container +get: + description: List all saved searches user has access to. + responses: + 200: + body: + application/json: + example: !include ../examples/output/search-list.json + schema: !include ../schemas/output/search-list.json +post: + body: + application/json: + schema: !include ../schemas/input/search-input.json +/{SearchId}: + type: container + get: + responses: + 200: + body: + application/json: + schema: !include ../schemas/output/search-output.json + post: + description: Replace saved search with a new search + body: + application/json: + schema: !include ../schemas/input/search-input.json + /permissions: + type: permissions-list + /{UserId}: + type: permissions-item + delete: + description: Delete a saved search + responses: + 200: + body: + application/json: + schema: !include ../schemas/output/container-delete.json + example: !include ../examples/output/container-delete.json diff --git a/raml/schemas/definitions/search.json b/raml/schemas/definitions/search.json new file mode 100644 index 000000000..6f98cba15 --- /dev/null +++ b/raml/schemas/definitions/search.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions":{ + "filter" : { + "type" : "object" + + }, + "search" : { + "type": "object", + "properties": { + "filters": { + "type" : "array", + "items" : {"$ref": "#/definitions/filter"} + }, + "search_string": {"type" : "string"}, + "all_data": {"type": "boolean"}, + "return_type": {"enum": ["session", "acquisition", "analysis", "file"]} + }, + "additionalProperties": false, + "required": ["return_type"] + }, + "search-input":{ + "type": "object", + "properties": { + "label": {"$ref": "../definitions/container.json#/definitions/label"}, + "search": {"$ref": "#/definitions/search"} + }, + "additionalProperties": false + }, + "search-output":{ + "type": "object", + "properties": { + "_id": {"$ref":"../definitions/objectid.json#"}, + "label": {"$ref": "../definitions/container.json#/definitions/label"}, + "creator": {"$ref":"../definitions/container.json#/definitions/uid"}, + "created": {"$ref":"../definitions/created-modified.json#/definitions/created"}, + "modified": {"$ref":"../definitions/created-modified.json#/definitions/modified"}, + "permissions": { + "type":"array", + "items":{"$ref":"../definitions/permission.json#/definitions/permission-output-default-required"} + }, + "search": {"$ref":"#/definitions/search"} + } + } + } +} \ No newline at end of file diff --git a/raml/schemas/input/search-input.json b/raml/schemas/input/search-input.json new file mode 100644 index 000000000..053cd9e5f --- /dev/null +++ b/raml/schemas/input/search-input.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Search", + "type": "object", + "allOf":[{"$ref":"../definitions/search.json#/definitions/search-input"}] +} \ No newline at end of file diff --git a/raml/schemas/output/search-list.json b/raml/schemas/output/search-list.json new file mode 100644 index 000000000..529c9744c --- /dev/null +++ b/raml/schemas/output/search-list.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type":"array", + "items":{ + "type":"object", + "allOf":[{"$ref":"../definitions/search.json#/definitions/search-output"}], + "required":[ + "_id", "label" + ] + } +} \ No newline at end of file diff --git a/raml/schemas/output/search-output.json b/raml/schemas/output/search-output.json new file mode 100644 index 000000000..03fb92b8a --- /dev/null +++ b/raml/schemas/output/search-output.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type":"object", + "allOf":[{"$ref":"../definitions/search.json#/definitions/search-output"}], + "required":[ + "_id", "label", "permissions", "search" + ] +} \ No newline at end of file diff --git a/test/integration_tests/python/test_savedsearch.py b/test/integration_tests/python/test_savedsearch.py new file mode 100644 index 000000000..97b4320f3 --- /dev/null +++ b/test/integration_tests/python/test_savedsearch.py @@ -0,0 +1,70 @@ + +def test_search_saving(as_admin, data_builder): + + # Try posting a malformed search + r = as_admin.post('/savesearches', json={"not-label":"random-string"}) + assert r.status_code == 400 + + # Try getting a non-existent saved search + r = as_admin.get('/savesearches/000000000000000000000000') + assert r.status_code == 404 + + # Save a search + r = as_admin.post('/savesearches', json={'label': 'search1', 'search': {'return_type': 'session'}}) + assert r.ok + search = r.json()['_id'] + + # Get all searched user has access to + r = as_admin.get('/savesearches') + assert r.ok + + # Get the saved search by id + r = as_admin.get('/savesearches/' + search) + assert r.ok + assert r.json()['label'] == 'search1' + + # Malformed search replace + payload = {'label': 'good-label', 'search' : { 'not-return-type' : 'not-container'}} + r = as_admin.post('/savesearches/' + search, json=payload) + assert r.status_code == 400 + + # Replace search + r = as_admin.get('/savesearches/' + search) + assert r.ok + assert r.json()['label'] == 'search1' + payload = r.json() + payload['label'] = 'newSearch' + r = as_admin.post('/savesearches/' + search, json=payload) + assert r.ok + assert r.json()['_id'] == search + r = as_admin.get('/savesearches/' + search) + assert r.ok + assert r.json()['label'] == 'newSearch' + + # Add permission to search + r = as_admin.post('/savesearches/' + search + '/permissions', json={'access': 'admin', '_id': 'user@user.com'}) + assert r.ok + r = as_admin.get('/savesearches/' + search) + assert r.ok + assert r.json()['permissions'][1]['_id'] == 'user@user.com' + + # Modify permission + r = as_admin.put('/savesearches/' + search + '/permissions/user@user.com', json={'access': 'ro'}) + assert r.ok + r = as_admin.get('/savesearches/' + search) + assert r.ok + assert r.json()['permissions'][1]['access'] == 'ro' + + # Remove permission + r = as_admin.delete('/savesearches/' + search + '/permissions/user@user.com') + assert r.ok + r = as_admin.get('/savesearches/' + search) + assert r.ok + assert len(r.json()['permissions']) == 1 + + # Delete saved search + r = as_admin.delete('/savesearches/' + search) + assert r.ok + r = as_admin.get('/savesearches') + assert r.ok + assert len(r.json()) == 0 diff --git a/tests/integration_tests/abao/abao_test_hooks.js b/tests/integration_tests/abao/abao_test_hooks.js index 270c43aae..94f0aea60 100644 --- a/tests/integration_tests/abao/abao_test_hooks.js +++ b/tests/integration_tests/abao/abao_test_hooks.js @@ -21,8 +21,11 @@ var example_acquisition_id = ''; var test_project_1 = null; var test_project_tag = 'test-project-tag'; var delete_project_id = ''; -var device_id = 'bootstrapper_Bootstrapper' -var injected_api_key = 'XZpXI40Uk85eozjQkU1zHJ6yZHpix+j0mo1TMeGZ4dPzIqVPVGPmyfeK' +var device_id = 'bootstrapper_Bootstrapper'; +var injected_api_key = 'XZpXI40Uk85eozjQkU1zHJ6yZHpix+j0mo1TMeGZ4dPzIqVPVGPmyfeK'; +var search_id = ''; +var delete_search_id = ''; +var user_id = 'user@user.com' // Tests we're skipping, fix these @@ -1461,3 +1464,128 @@ hooks.before("GET /devices/{DeviceId} -> 404", function(test, done) { test.request.params.DeviceId = 'bad_device_id'; done(); }); + +// Save Search Tests +hooks.before("POST /savesearches -> 200", function(test, done) { + test.request.body = { + "label": "Lable", + "search": { + "return_type": "session" + } + }; + done(); +}) +hooks.after("POST /savesearches -> 200", function(test, done) { + delete_search_id = test.response.body['_id']; + done(); +}) + +hooks.before("POST /savesearches -> 400", function(test, done) { + test.request.body = { + "not-label": "Label" + }; + done(); +}) + +hooks.after("GET /savesearches -> 200", function(test, done) { + search_id = test.response.body[0]._id; + done(); +}) + +hooks.before("GET /savesearches/{SearchId} -> 200", function(test, done) { + test.request.params = { + SearchId: search_id + }; + done(); +}) + +hooks.before("POST /savesearches/{SearchId} -> 200", function(test, done) { + test.request.params = { + SearchId: search_id + }; + test.request.body = { + "label": "New Label", + "search": { + "return_type": "session" + }, + "_id": search_id + }; + done(); +}) + +hooks.before("POST /savesearches/{SearchId} -> 400", function(test, done) { + test.request.params = { + SearchId: search_id + }; + test.request.body = { + "not-label": "Label2" + }; + done(); +}) + +hooks.before("POST /savesearches/{SearchId}/permissions -> 200", function(test, done) { + test.request.params = { + SearchId: search_id + }; + test.request.body = { + "access" : "admin", + "_id": user_id + }; + done(); +}) + +hooks.before("POST /savesearches/{SearchId}/permissions -> 400", function(test, done) { + test.request.params = { + SearchId: search_id + }; + test.request.body = { + "not-access" : "admin", + "not_id": user_id + }; + done(); +}) + +hooks.before("GET /savesearches/{SearchId}/permissions/{UserId} -> 200", function(test, done) { + test.request.params = { + SearchId: search_id, + UserId: user_id + }; + done(); +}) + +hooks.before("PUT /savesearches/{SearchId}/permissions/{UserId} -> 200", function(test, done) { + test.request.params = { + SearchId: search_id, + UserId: user_id + }; + test.request.body = { + "access" : "ro" + }; + done(); +}) + +hooks.before("PUT /savesearches/{SearchId}/permissions/{UserId} -> 400", function(test, done) { + test.request.params = { + SearchId: search_id, + UserId: user_id + }; + test.request.body = { + "access" : "not_an_access_level" + }; + done(); +}) + +hooks.before("DELETE /savesearches/{SearchId}/permissions/{UserId} -> 200", function(test, done) { + test.request.params = { + SearchId: search_id, + UserId: user_id + }; + done(); +}) + +hooks.before("DELETE /savesearches/{SearchId} -> 200", function(test, done) { + test.request.params = { + SearchId: delete_search_id + }; + done(); +}) diff --git a/tests/integration_tests/abao/load_fixture.py b/tests/integration_tests/abao/load_fixture.py index 32c036bdc..3db3cbc42 100644 --- a/tests/integration_tests/abao/load_fixture.py +++ b/tests/integration_tests/abao/load_fixture.py @@ -81,6 +81,19 @@ def main(): }) assert r.ok + # create a saved search + r = as_root.post('/savesearches', json={ + "label": "Test Search", + "search": { + "return_type": "file", + "filters": [{"terms": {"file.type":["nifti"]}}] + }, + }) + assert r.ok + r = as_root.get('/savesearches') + assert r.ok + assert r.json()[0]['label'] == 'Test Search' + # list projects # depends on 'upload file to test-project-1/test-session-1/test-acquisition-1' r = as_root.get('/projects')