From 57ea4384d147f59f0a74383591a6375b23c0f7a8 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Thu, 12 Dec 2024 15:04:55 -0500 Subject: [PATCH 01/15] add project clone api endpoint --- pybossa/api/__init__.py | 44 ++++++++++++++++++++++++++++++- test/test_api/test_project_api.py | 28 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index d2c02b05a..569cdc083 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -90,7 +90,7 @@ from pybossa.cache import users as cached_users, ONE_MONTH from pybossa.cache.task_browse_helpers import get_searchable_columns from pybossa.cache.users import get_user_pref_metadata -from pybossa.view.projects import get_locked_tasks +from pybossa.view.projects import get_locked_tasks, clone_project from pybossa.redis_lock import EXPIRE_LOCK_DELAY from pybossa.api.bulktasks import BulkTasksAPI @@ -1022,3 +1022,45 @@ def get_project_progress(project_id=None, short_name=None): return Response(json.dumps(response), status=200, mimetype="application/json") else: return abort(403) + + +@jsonpify +@blueprint.route('/project//clone', methods=['POST']) +@blueprint.route('/project//clone', methods=['POST']) +@login_required +def project_clone(project_id=None, short_name=None): + + if current_user.is_anonymous: + return abort(401) + + if not (project_id or short_name): + return abort(404) + if short_name: + project = project_repo.get_by_shortname(short_name) + elif project_id: + project = project_repo.get(project_id) + if not project: + return abort(404) + + if not (current_user.admin or (current_user.subadmin and current_user.id in project.owners_ids)): + return abort(401) + + payload = json.loads(request.form['request_json']) if 'request_json' in request.form else request.json + # User must post a payload + if not payload: + return abort(400) + + project.input_data_class = project.info.get('data_classification', {}).get('input_data') + project.output_data_class = project.info.get('data_classification', {}).get('output_data') + + new_project = clone_project(project, payload) + + current_app.logger.info( project, + current_user, + 'clone', + 'project.clone', + # TODO: clean logs + json.dumps(project), + json.dumps(new_project)) + + return Response(json.dumps(new_project), status=200, mimetype="application/json") diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index e67797ebc..47ecb43fe 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2559,3 +2559,31 @@ def test_project_progress(self): res = self.app.get('/api/project/testproject/projectprogress', follow_redirects=True, headers=headers) assert res.status_code == 200 assert res.json == dict(n_completed_tasks=3, n_tasks=3) + + + @with_context + def test_clone_project(self): + """Test API clone project works""" + [admin, subadminowner, subadmin, reguser] = UserFactory.create_batch(4) + make_admin(admin) + make_subadmin(subadminowner) + make_subadmin(subadmin) + + project = ProjectFactory.create(owner=subadminowner, short_name="testproject") + tasks = TaskFactory.create_batch(3, project=project) + headers = [('Authorization', subadminowner.api_key)] + + # check 404 response when no project param + res = self.app.post('/api/project//clone', follow_redirects=True, headers=headers) + error_msg = "A valid project must be used" + assert res.status_code == 404, error_msg + + # check 404 response when the project doesn't exist + res = self.app.post('/api/project/9999/clone', follow_redirects=True, headers=headers) + error_msg = "A valid project must be used" + assert res.status_code == 404, error_msg + + # check 404 response when the project doesn't exist + res = self.app.post('/api/project/xyz/clone', follow_redirects=True, headers=headers) + error_msg = "A valid project must be used" + assert res.status_code == 404, error_msg From 6d48764f3ed07ac0419479cf020f3f70073a8573 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Fri, 13 Dec 2024 09:43:29 -0500 Subject: [PATCH 02/15] update access tests --- test/test_api/test_project_api.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index 47ecb43fe..e3d3f5fbf 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2562,14 +2562,16 @@ def test_project_progress(self): @with_context - def test_clone_project(self): - """Test API clone project works""" + def test_clone_project_access(self): + """Test API clone project access control and edge cases""" [admin, subadminowner, subadmin, reguser] = UserFactory.create_batch(4) make_admin(admin) make_subadmin(subadminowner) make_subadmin(subadmin) - project = ProjectFactory.create(owner=subadminowner, short_name="testproject") + short_name = "testproject" + + project = ProjectFactory.create(owner=subadminowner, short_name=short_name) tasks = TaskFactory.create_batch(3, project=project) headers = [('Authorization', subadminowner.api_key)] @@ -2587,3 +2589,19 @@ def test_clone_project(self): res = self.app.post('/api/project/xyz/clone', follow_redirects=True, headers=headers) error_msg = "A valid project must be used" assert res.status_code == 404, error_msg + + # check 401 response when user not logged in + res = self.app.post('/api/project/xyz/clone', follow_redirects=True) + error_msg = "User must be logged in" + assert res.status_code == 401, error_msg + + # check 400 response when user does not post a payload + res = self.app.post(f'/api/project/{short_name}/clone', follow_redirects=True) + error_msg = "User must post valid payload" + assert res.status_code == 400, error_msg + + # check 401 response when use is not authorized + headers = [('Authorization', reguser.api_key)] + res = self.app.post('/api/project/xyz/clone', follow_redirects=True) + error_msg = "User must have permissions" + assert res.status_code == 401, error_msg From 0e269b39f307675682d4738e8f687446c9d4aff4 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Fri, 13 Dec 2024 10:36:52 -0500 Subject: [PATCH 03/15] check required fields --- pybossa/api/__init__.py | 4 ++++ test/test_api/test_project_api.py | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index 569cdc083..8f0611ab8 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -1049,6 +1049,10 @@ def project_clone(project_id=None, short_name=None): # User must post a payload if not payload: return abort(400) + # check required fields + if not (payload.get('input_data_class') and payload.get('output_data_class') \ + and payload.get('name') and payload.get('short_name')): + return abort(400) project.input_data_class = project.info.get('data_classification', {}).get('input_data') project.output_data_class = project.info.get('data_classification', {}).get('output_data') diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index e3d3f5fbf..259c94514 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2576,32 +2576,38 @@ def test_clone_project_access(self): headers = [('Authorization', subadminowner.api_key)] # check 404 response when no project param - res = self.app.post('/api/project//clone', follow_redirects=True, headers=headers) + res = self.app.post('/api/project//clone', headers=headers) error_msg = "A valid project must be used" assert res.status_code == 404, error_msg # check 404 response when the project doesn't exist - res = self.app.post('/api/project/9999/clone', follow_redirects=True, headers=headers) + res = self.app.post('/api/project/9999/clone', headers=headers) error_msg = "A valid project must be used" assert res.status_code == 404, error_msg # check 404 response when the project doesn't exist - res = self.app.post('/api/project/xyz/clone', follow_redirects=True, headers=headers) + res = self.app.post('/api/project/xyz/clone', headers=headers) error_msg = "A valid project must be used" assert res.status_code == 404, error_msg # check 401 response when user not logged in - res = self.app.post('/api/project/xyz/clone', follow_redirects=True) + res = self.app.post(f'/api/project/{short_name}/clone') error_msg = "User must be logged in" assert res.status_code == 401, error_msg # check 400 response when user does not post a payload - res = self.app.post(f'/api/project/{short_name}/clone', follow_redirects=True) + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) + error_msg = "User must post valid payload" + assert res.status_code == 400, error_msg + + # check 400 response when user posts payload missed req fields + data = { "name": "Test Project Clone"} + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers, data=json.dumps(data)) error_msg = "User must post valid payload" assert res.status_code == 400, error_msg # check 401 response when use is not authorized headers = [('Authorization', reguser.api_key)] - res = self.app.post('/api/project/xyz/clone', follow_redirects=True) + res = self.app.post('/api/project/xyz/clone', headers=headers) error_msg = "User must have permissions" assert res.status_code == 401, error_msg From 82545bcf2f0e44e442840444c881b970490ff988 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Mon, 16 Dec 2024 11:18:09 -0500 Subject: [PATCH 04/15] update test --- test/test_api/test_project_api.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index 259c94514..202bf1a07 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2590,11 +2590,6 @@ def test_clone_project_access(self): error_msg = "A valid project must be used" assert res.status_code == 404, error_msg - # check 401 response when user not logged in - res = self.app.post(f'/api/project/{short_name}/clone') - error_msg = "User must be logged in" - assert res.status_code == 401, error_msg - # check 400 response when user does not post a payload res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) error_msg = "User must post valid payload" @@ -2608,6 +2603,6 @@ def test_clone_project_access(self): # check 401 response when use is not authorized headers = [('Authorization', reguser.api_key)] - res = self.app.post('/api/project/xyz/clone', headers=headers) + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) error_msg = "User must have permissions" assert res.status_code == 401, error_msg From 438d1bed5dfd53854cf16c35cef65197adf892b3 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Mon, 16 Dec 2024 14:32:07 -0500 Subject: [PATCH 05/15] spacing --- pybossa/api/__init__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index 8f0611ab8..80ff40255 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -1032,7 +1032,6 @@ def project_clone(project_id=None, short_name=None): if current_user.is_anonymous: return abort(401) - if not (project_id or short_name): return abort(404) if short_name: @@ -1041,16 +1040,13 @@ def project_clone(project_id=None, short_name=None): project = project_repo.get(project_id) if not project: return abort(404) - if not (current_user.admin or (current_user.subadmin and current_user.id in project.owners_ids)): return abort(401) payload = json.loads(request.form['request_json']) if 'request_json' in request.form else request.json - # User must post a payload - if not payload: - return abort(400) + # check required fields - if not (payload.get('input_data_class') and payload.get('output_data_class') \ + if not (payload and payload.get('input_data_class') and payload.get('output_data_class') \ and payload.get('name') and payload.get('short_name')): return abort(400) From c39db4c9c06afcf70cc7efd6f9ab863f573a8da7 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Wed, 18 Dec 2024 12:14:21 -0500 Subject: [PATCH 06/15] error handling --- pybossa/api/__init__.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index 80ff40255..6f725503b 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -1025,6 +1025,7 @@ def get_project_progress(project_id=None, short_name=None): @jsonpify +@csrf.exempt @blueprint.route('/project//clone', methods=['POST']) @blueprint.route('/project//clone', methods=['POST']) @login_required @@ -1050,17 +1051,29 @@ def project_clone(project_id=None, short_name=None): and payload.get('name') and payload.get('short_name')): return abort(400) + if not payload.get('password'): + payload['password'] = "" + project.input_data_class = project.info.get('data_classification', {}).get('input_data') project.output_data_class = project.info.get('data_classification', {}).get('output_data') - new_project = clone_project(project, payload) - - current_app.logger.info( project, + try: + new_project = clone_project(project, payload) + if not new_project: + raise Exception("Failed to clone project.") + current_app.logger.info( current_user, 'clone', 'project.clone', - # TODO: clean logs - json.dumps(project), - json.dumps(new_project)) + 'old project id: %s'.format(project.id), + 'new project id: %s'.format(new_project.id) + ) - return Response(json.dumps(new_project), status=200, mimetype="application/json") + return Response(json.dumps(new_project.dictize()), status=200, mimetype="application/json") + except Exception as e: + current_app.logger.error( + 'clone', + 'project.clone', + e.message + ) + raise abort(400, "Invalid project metadata.") From 139cbbfcb685891cee2ba65673a163cffcc0149f Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Wed, 18 Dec 2024 14:25:40 -0500 Subject: [PATCH 07/15] tests --- pybossa/api/__init__.py | 13 ++++-- test/test_api/test_project_api.py | 70 +++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index 6f725503b..bec44fba8 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -36,6 +36,7 @@ from time import time from datetime import datetime, timedelta from werkzeug.exceptions import NotFound +from pybossa.exc.repository import DBIntegrityError from pybossa.util import jsonpify, get_user_id_or_ip, fuzzyboolean, \ PARTIAL_ANSWER_KEY, SavedTaskPositionEnum, PARTIAL_ANSWER_POSITION_KEY, \ get_user_saved_partial_tasks @@ -1046,7 +1047,6 @@ def project_clone(project_id=None, short_name=None): payload = json.loads(request.form['request_json']) if 'request_json' in request.form else request.json - # check required fields if not (payload and payload.get('input_data_class') and payload.get('output_data_class') \ and payload.get('name') and payload.get('short_name')): return abort(400) @@ -1059,8 +1059,6 @@ def project_clone(project_id=None, short_name=None): try: new_project = clone_project(project, payload) - if not new_project: - raise Exception("Failed to clone project.") current_app.logger.info( current_user, 'clone', @@ -1070,10 +1068,17 @@ def project_clone(project_id=None, short_name=None): ) return Response(json.dumps(new_project.dictize()), status=200, mimetype="application/json") - except Exception as e: + except DBIntegrityError as e: current_app.logger.error( 'clone', 'project.clone', e.message ) + raise abort(400, "Duplicate project keys.") + except Exception as e: + current_app.logger.error( + 'clone', + 'project.clone', + str(e) + ) raise abort(400, "Invalid project metadata.") diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index 202bf1a07..dc18bcd2d 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2563,7 +2563,7 @@ def test_project_progress(self): @with_context def test_clone_project_access(self): - """Test API clone project access control and edge cases""" + """Test API clone project access control""" [admin, subadminowner, subadmin, reguser] = UserFactory.create_batch(4) make_admin(admin) make_subadmin(subadminowner) @@ -2590,19 +2590,75 @@ def test_clone_project_access(self): error_msg = "A valid project must be used" assert res.status_code == 404, error_msg + # check 401 response when use is not authorized + headers = [('Authorization', reguser.api_key)] + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) + error_msg = "User must have permissions" + assert res.status_code == 401, error_msg + + # check 401 response when use is not authorized + headers = [('Authorization', subadmin.api_key)] + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) + error_msg = "User must have permissions" + assert res.status_code == 401, error_msg + + + @with_context + def test_clone_project_edge_cases(self): + """Test API clone project edge cases""" + [admin, subadminowner] = UserFactory.create_batch(2) + make_admin(admin) + make_subadmin(subadminowner) + + short_name = "testproject" + + project = ProjectFactory.create(owner=subadminowner, short_name=short_name) + tasks = TaskFactory.create_batch(3, project=project) + headers = [('Authorization', subadminowner.api_key)] + # check 400 response when user does not post a payload res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) error_msg = "User must post valid payload" assert res.status_code == 400, error_msg # check 400 response when user posts payload missed req fields - data = { "name": "Test Project Clone"} + data = { "name": "Test Project Clone" } res = self.app.post(f'/api/project/{short_name}/clone', headers=headers, data=json.dumps(data)) error_msg = "User must post valid payload" assert res.status_code == 400, error_msg - # check 401 response when use is not authorized - headers = [('Authorization', reguser.api_key)] - res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) - error_msg = "User must have permissions" - assert res.status_code == 401, error_msg + # test duplicate short name + from pybossa.view.projects import data_access_levels + + data = { + "name": "Test Project Clone", + "short_name": short_name + 'clone', + "input_data_class": "L4 - Public Third-Party Data", + "output_data_class": "L4 - Public Third-Party Data" + } + with patch.dict(data_access_levels, self.patch_data_access_levels): + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers, data=data, content_type='application/json') + error_msg = "User must post a unique project short name" + assert res.status_code == 400, error_msg + + + @with_context + def test_clone_project_success(self): + """Test API clone project success state""" + from pybossa.view.projects import data_access_levels + + [admin, subadminowner] = UserFactory.create_batch(2) + make_admin(admin) + make_subadmin(subadminowner) + + short_name = "testproject" + + project = ProjectFactory.create(owner=subadminowner, short_name=short_name) + tasks = TaskFactory.create_batch(3, project=project) + headers = [('Authorization', subadminowner.api_key)] + + data = {'short_name': 'newname', 'name': 'newname', 'password': 'Test123', 'input_data_class': 'L4 - public','output_data_class': 'L4 - public'} + with patch.dict(data_access_levels, self.patch_data_access_levels): + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers, data=json.dumps(data), content_type='application/json') + error_msg = "Error cloning project" + assert res.status_code == 200, error_msg From 19ed00e4479b4bac5c61c4a63a3e694c1fd416fc Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Wed, 18 Dec 2024 15:25:02 -0500 Subject: [PATCH 08/15] update --- pybossa/api/__init__.py | 31 ++++++++----------- test/test_api/test_project_api.py | 49 +++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index bec44fba8..302591257 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -1060,25 +1060,20 @@ def project_clone(project_id=None, short_name=None): try: new_project = clone_project(project, payload) current_app.logger.info( - current_user, - 'clone', - 'project.clone', - 'old project id: %s'.format(project.id), - 'new project id: %s'.format(new_project.id) - ) - - return Response(json.dumps(new_project.dictize()), status=200, mimetype="application/json") - except DBIntegrityError as e: - current_app.logger.error( - 'clone', - 'project.clone', - e.message + 'project.clone: user: %s, old project id: %s, new project id: %s'.format( + current_user.id, + project.id, + new_project.id + ) ) - raise abort(400, "Duplicate project keys.") + return Response(json.dumps(new_project.dictize()), status=200, mimetype="application/json") + except Exception as e: + msg = e.message if hasattr(e, 'message') else str(e) current_app.logger.error( - 'clone', - 'project.clone', - str(e) + 'project.clone: user: %s, msg: %s'.format( + current_user.id, + msg ) - raise abort(400, "Invalid project metadata.") + ) + raise abort(400, f"Error cloning project - {e}") diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index dc18bcd2d..b6b845a70 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2572,7 +2572,6 @@ def test_clone_project_access(self): short_name = "testproject" project = ProjectFactory.create(owner=subadminowner, short_name=short_name) - tasks = TaskFactory.create_batch(3, project=project) headers = [('Authorization', subadminowner.api_key)] # check 404 response when no project param @@ -2613,7 +2612,6 @@ def test_clone_project_edge_cases(self): short_name = "testproject" project = ProjectFactory.create(owner=subadminowner, short_name=short_name) - tasks = TaskFactory.create_batch(3, project=project) headers = [('Authorization', subadminowner.api_key)] # check 400 response when user does not post a payload @@ -2653,12 +2651,51 @@ def test_clone_project_success(self): short_name = "testproject" - project = ProjectFactory.create(owner=subadminowner, short_name=short_name) - tasks = TaskFactory.create_batch(3, project=project) + task_presenter = 'test; url="project/oldname/" pybossa.run("oldname"); test;' + project = ProjectFactory.create(id=40, + short_name=short_name, + info={'task_presenter': task_presenter, + 'quiz': {'test': 123}, + 'enrichments': [{'test': 123}], + 'passwd_hash': 'testpass', + 'ext_config': {'test': 123} + }, + owner=subadminowner) + headers = [('Authorization', subadminowner.api_key)] data = {'short_name': 'newname', 'name': 'newname', 'password': 'Test123', 'input_data_class': 'L4 - public','output_data_class': 'L4 - public'} with patch.dict(data_access_levels, self.patch_data_access_levels): res = self.app.post(f'/api/project/{short_name}/clone', headers=headers, data=json.dumps(data), content_type='application/json') - error_msg = "Error cloning project" - assert res.status_code == 200, error_msg + data = json.loads(res.data) + assert res.status_code == 200, data + + + @with_context + def test_clone_project_success_no_password(self): + """Test API clone project success state without project password""" + from pybossa.view.projects import data_access_levels + + [admin, subadminowner] = UserFactory.create_batch(2) + make_admin(admin) + make_subadmin(subadminowner) + + short_name = "testproject" + + task_presenter = 'test; url="project/oldname/" pybossa.run("oldname"); test;' + project = ProjectFactory.create(id=40, + short_name=short_name, + info={'task_presenter': task_presenter, + 'quiz': {'test': 123}, + 'enrichments': [{'test': 123}], + 'ext_config': {'test': 123} + }, + owner=subadminowner) + + headers = [('Authorization', subadminowner.api_key)] + + data = {'short_name': 'newname', 'name': 'newname', 'input_data_class': 'L4 - public','output_data_class': 'L4 - public'} + with patch.dict(data_access_levels, self.patch_data_access_levels): + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers, data=json.dumps(data), content_type='application/json') + data = json.loads(res.data) + assert res.status_code == 200, data From ba5dd6d501a8f0abb572297ec5b994f0b05eb08c Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Thu, 19 Dec 2024 09:35:35 -0500 Subject: [PATCH 09/15] refactor tests --- test/test_api/test_project_api.py | 42 ++++++++++++------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index b6b845a70..df18135ba 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2561,6 +2561,18 @@ def test_project_progress(self): assert res.json == dict(n_completed_tasks=3, n_tasks=3) + def _setup_project(self, short_name, owner): + task_presenter = 'test; url="project/oldname/" pybossa.run("oldname"); test;' + return ProjectFactory.create(id=40, + short_name=short_name, + info={'task_presenter': task_presenter, + 'quiz': {'test': 123}, + 'enrichments': [{'test': 123}], + 'passwd_hash': 'testpass', + 'ext_config': {'test': 123} + }, + owner=owner) + @with_context def test_clone_project_access(self): """Test API clone project access control""" @@ -2570,8 +2582,7 @@ def test_clone_project_access(self): make_subadmin(subadmin) short_name = "testproject" - - project = ProjectFactory.create(owner=subadminowner, short_name=short_name) + self._setup_project(short_name, subadminowner) headers = [('Authorization', subadminowner.api_key)] # check 404 response when no project param @@ -2610,8 +2621,7 @@ def test_clone_project_edge_cases(self): make_subadmin(subadminowner) short_name = "testproject" - - project = ProjectFactory.create(owner=subadminowner, short_name=short_name) + self._setup_project(short_name, subadminowner) headers = [('Authorization', subadminowner.api_key)] # check 400 response when user does not post a payload @@ -2650,18 +2660,7 @@ def test_clone_project_success(self): make_subadmin(subadminowner) short_name = "testproject" - - task_presenter = 'test; url="project/oldname/" pybossa.run("oldname"); test;' - project = ProjectFactory.create(id=40, - short_name=short_name, - info={'task_presenter': task_presenter, - 'quiz': {'test': 123}, - 'enrichments': [{'test': 123}], - 'passwd_hash': 'testpass', - 'ext_config': {'test': 123} - }, - owner=subadminowner) - + self._setup_project(short_name, subadminowner) headers = [('Authorization', subadminowner.api_key)] data = {'short_name': 'newname', 'name': 'newname', 'password': 'Test123', 'input_data_class': 'L4 - public','output_data_class': 'L4 - public'} @@ -2681,16 +2680,7 @@ def test_clone_project_success_no_password(self): make_subadmin(subadminowner) short_name = "testproject" - - task_presenter = 'test; url="project/oldname/" pybossa.run("oldname"); test;' - project = ProjectFactory.create(id=40, - short_name=short_name, - info={'task_presenter': task_presenter, - 'quiz': {'test': 123}, - 'enrichments': [{'test': 123}], - 'ext_config': {'test': 123} - }, - owner=subadminowner) + self._setup_project(short_name, subadminowner) headers = [('Authorization', subadminowner.api_key)] From 09fa359be763eb5bbd4615120b81c64ab881b52e Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Thu, 19 Dec 2024 10:14:56 -0500 Subject: [PATCH 10/15] add tests --- test/test_api/test_project_api.py | 35 +++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index df18135ba..7b56b6ebe 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -33,6 +33,7 @@ CategoryFactory, AuditlogFactory) from test.helper.gig_helper import make_subadmin, make_admin from test.test_api import TestAPI +from test.test_authorization import mock_current_user project_repo = ProjectRepository(db) task_repo = TaskRepository(db) @@ -2585,10 +2586,12 @@ def test_clone_project_access(self): self._setup_project(short_name, subadminowner) headers = [('Authorization', subadminowner.api_key)] - # check 404 response when no project param - res = self.app.post('/api/project//clone', headers=headers) - error_msg = "A valid project must be used" - assert res.status_code == 404, error_msg + # check 401 response for anonymous user + mock_anonymous = mock_current_user() + with patch('pybossa.api.current_user', new=mock_anonymous): + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) + error_msg = "User must have access" + assert res.status_code == 401, res.status_code # check 404 response when the project doesn't exist res = self.app.post('/api/project/9999/clone', headers=headers) @@ -2651,7 +2654,7 @@ def test_clone_project_edge_cases(self): @with_context - def test_clone_project_success(self): + def test_clone_project(self): """Test API clone project success state""" from pybossa.view.projects import data_access_levels @@ -2671,7 +2674,27 @@ def test_clone_project_success(self): @with_context - def test_clone_project_success_no_password(self): + def test_clone_project_by_id(self): + """Test API clone project by id success state""" + from pybossa.view.projects import data_access_levels + + [admin, subadminowner] = UserFactory.create_batch(2) + make_admin(admin) + make_subadmin(subadminowner) + + short_name = "testproject" + self._setup_project(short_name, subadminowner) + headers = [('Authorization', subadminowner.api_key)] + + data = {'short_name': 'newname', 'name': 'newname', 'password': 'Test123', 'input_data_class': 'L4 - public','output_data_class': 'L4 - public'} + with patch.dict(data_access_levels, self.patch_data_access_levels): + res = self.app.post(f'/api/project/40/clone', headers=headers, data=json.dumps(data), content_type='application/json') + data = json.loads(res.data) + assert res.status_code == 200, data + + + @with_context + def test_clone_project_no_password(self): """Test API clone project success state without project password""" from pybossa.view.projects import data_access_levels From 70a6e1e43dde67fc7aaa3b3ee885b62f17763118 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Thu, 19 Dec 2024 13:20:51 -0500 Subject: [PATCH 11/15] update --- pybossa/api/__init__.py | 6 ++---- test/test_api/test_project_api.py | 26 ++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index 302591257..ebca99948 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -1034,16 +1034,14 @@ def project_clone(project_id=None, short_name=None): if current_user.is_anonymous: return abort(401) - if not (project_id or short_name): - return abort(404) if short_name: project = project_repo.get_by_shortname(short_name) - elif project_id: + else: project = project_repo.get(project_id) if not project: return abort(404) if not (current_user.admin or (current_user.subadmin and current_user.id in project.owners_ids)): - return abort(401) + return abort(403) payload = json.loads(request.form['request_json']) if 'request_json' in request.form else request.json diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index 7b56b6ebe..3233b1cfc 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2607,13 +2607,13 @@ def test_clone_project_access(self): headers = [('Authorization', reguser.api_key)] res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) error_msg = "User must have permissions" - assert res.status_code == 401, error_msg + assert res.status_code == 403, error_msg # check 401 response when use is not authorized headers = [('Authorization', subadmin.api_key)] res = self.app.post(f'/api/project/{short_name}/clone', headers=headers) error_msg = "User must have permissions" - assert res.status_code == 401, error_msg + assert res.status_code == 403, error_msg @with_context @@ -2673,6 +2673,28 @@ def test_clone_project(self): assert res.status_code == 200, data + @with_context + @patch('pybossa.api.clone_project') + def test_clone_project_error(self, clone_project): + """Test API clone project success state""" + from pybossa.view.projects import data_access_levels + + clone_project.side_effect = Exception("Project clone error!") + [admin, subadminowner] = UserFactory.create_batch(2) + make_admin(admin) + make_subadmin(subadminowner) + + short_name = "testproject" + self._setup_project(short_name, subadminowner) + headers = [('Authorization', subadminowner.api_key)] + + data = {'short_name': 'newname', 'name': 'newname', 'password': 'Test123', 'input_data_class': 'L4 - public','output_data_class': 'L4 - public'} + with patch.dict(data_access_levels, self.patch_data_access_levels): + res = self.app.post(f'/api/project/{short_name}/clone', headers=headers, data=json.dumps(data), content_type='application/json') + data = json.loads(res.data) + assert res.status_code == 400, data + + @with_context def test_clone_project_by_id(self): """Test API clone project by id success state""" From d36d1119f49cf42abfa06bc33b7accdeac834672 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Thu, 19 Dec 2024 15:02:50 -0500 Subject: [PATCH 12/15] remove form support --- pybossa/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index ebca99948..025ebfa82 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -1043,7 +1043,7 @@ def project_clone(project_id=None, short_name=None): if not (current_user.admin or (current_user.subadmin and current_user.id in project.owners_ids)): return abort(403) - payload = json.loads(request.form['request_json']) if 'request_json' in request.form else request.json + payload = request.json if not (payload and payload.get('input_data_class') and payload.get('output_data_class') \ and payload.get('name') and payload.get('short_name')): From a47c6fa8ae8453ab164e01cbd7e3a3cb8ae30b39 Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Fri, 20 Dec 2024 10:03:41 -0500 Subject: [PATCH 13/15] add swagger docs --- pybossa/api/__init__.py | 2 + pybossa/api/docs/project/project_clone.yaml | 69 +++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 pybossa/api/docs/project/project_clone.yaml diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index 025ebfa82..e0dc1bb1e 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -33,6 +33,7 @@ from flask import Blueprint, request, abort, Response, make_response from flask import current_app from flask_login import current_user, login_required +from flasgger import swag_from from time import time from datetime import datetime, timedelta from werkzeug.exceptions import NotFound @@ -1030,6 +1031,7 @@ def get_project_progress(project_id=None, short_name=None): @blueprint.route('/project//clone', methods=['POST']) @blueprint.route('/project//clone', methods=['POST']) @login_required +@swag_from('docs/project/project_clone.yaml') def project_clone(project_id=None, short_name=None): if current_user.is_anonymous: diff --git a/pybossa/api/docs/project/project_clone.yaml b/pybossa/api/docs/project/project_clone.yaml new file mode 100644 index 000000000..1110f60b3 --- /dev/null +++ b/pybossa/api/docs/project/project_clone.yaml @@ -0,0 +1,69 @@ +Clone a project by project ID or short name +--- +tags: + - name: project +definitions: + CloneProjectRequestBody: + type: object + properties: + input_data_class: + type: string + description: Classification of input data + example: L4 - Public Internal Data + output_data_class: + type: string + description: Classification of output data + example: L4 - Public Internal Data + name: + type: string + description: Name of the new project + example: My Project Clone + short_name: + type: string + description: Short name of the new project + example: myprojectclone + password: + type: string + description: Password for the new project (optional) + example: mypassword123 + required: + - input_data_class + - output_data_class + - name + - short_name +parameters: + - name: project_id + in: path + type: integer + required: false + description: The project ID to clone + - name: short_name + in: path + type: string + required: false + description: The short name of the project to clone + - name: payload + in: body + required: true + description: Information required to clone the project + schema: + $ref: '#/definitions/CloneProjectRequestBody' + example: + input_data_class: L4 - Public Internal Data + output_data_class: L4 - Public Internal Data + name: My Project Clone + short_name: myprojectclone + password: mypassword +responses: + 200: + description: New cloned project information + schema: + $ref: '#definitions/Project' + 400: + description: Bad request - Missing or invalid payload + 401: + description: Unauthorized - User is not logged in + 403: + description: Forbidden - User does not have permission to clone project + 404: + description: Not found - Project does not exist From 02c8288df9866d4b1b4a00c999a9f2abc606976c Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Mon, 23 Dec 2024 11:32:56 -0500 Subject: [PATCH 14/15] cr updates --- pybossa/api/__init__.py | 5 +---- test/test_api/test_project_api.py | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index e0dc1bb1e..9b69a6b89 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -1049,14 +1049,11 @@ def project_clone(project_id=None, short_name=None): if not (payload and payload.get('input_data_class') and payload.get('output_data_class') \ and payload.get('name') and payload.get('short_name')): - return abort(400) + return abort(400, 'Request is missing one or more of the required fields: name, short_name, input_data_class, output_data_class') if not payload.get('password'): payload['password'] = "" - project.input_data_class = project.info.get('data_classification', {}).get('input_data') - project.output_data_class = project.info.get('data_classification', {}).get('output_data') - try: new_project = clone_project(project, payload) current_app.logger.info( diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index 3233b1cfc..5f6dc1c84 100644 --- a/test/test_api/test_project_api.py +++ b/test/test_api/test_project_api.py @@ -2654,7 +2654,7 @@ def test_clone_project_edge_cases(self): @with_context - def test_clone_project(self): + def test_clone_project_success(self): """Test API clone project success state""" from pybossa.view.projects import data_access_levels @@ -2676,7 +2676,7 @@ def test_clone_project(self): @with_context @patch('pybossa.api.clone_project') def test_clone_project_error(self, clone_project): - """Test API clone project success state""" + """Test API clone project error when cloning project""" from pybossa.view.projects import data_access_levels clone_project.side_effect = Exception("Project clone error!") @@ -2696,7 +2696,7 @@ def test_clone_project_error(self, clone_project): @with_context - def test_clone_project_by_id(self): + def test_clone_project_by_id_success(self): """Test API clone project by id success state""" from pybossa.view.projects import data_access_levels From 160cac30e2431a238a2b1f74372431c40172a6da Mon Sep 17 00:00:00 2001 From: nsyed22 Date: Mon, 23 Dec 2024 12:05:40 -0500 Subject: [PATCH 15/15] edit clone doc --- pybossa/api/docs/project/project_clone.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pybossa/api/docs/project/project_clone.yaml b/pybossa/api/docs/project/project_clone.yaml index 1110f60b3..399862e0d 100644 --- a/pybossa/api/docs/project/project_clone.yaml +++ b/pybossa/api/docs/project/project_clone.yaml @@ -26,6 +26,10 @@ definitions: type: string description: Password for the new project (optional) example: mypassword123 + copy_users: + type: boolean + description: Keep same list of assigned users (optional, default=false) + example: false required: - input_data_class - output_data_class