diff --git a/pybossa/api/__init__.py b/pybossa/api/__init__.py index 468c89d83..e51554fcd 100644 --- a/pybossa/api/__init__.py +++ b/pybossa/api/__init__.py @@ -33,9 +33,11 @@ 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 +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 @@ -90,7 +92,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 @@ -1023,3 +1025,53 @@ 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 +@csrf.exempt +@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: + return abort(401) + if short_name: + project = project_repo.get_by_shortname(short_name) + 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(403) + + 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')): + 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'] = "" + + try: + new_project = clone_project(project, payload) + current_app.logger.info( + 'project.clone: user: %s, old project id: %s, new project id: %s'.format( + current_user.id, + project.id, + new_project.id + ) + ) + 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( + 'project.clone: user: %s, msg: %s'.format( + current_user.id, + msg + ) + ) + raise abort(400, f"Error cloning project - {e}") diff --git a/pybossa/api/docs/project/project_clone.yaml b/pybossa/api/docs/project/project_clone.yaml new file mode 100644 index 000000000..399862e0d --- /dev/null +++ b/pybossa/api/docs/project/project_clone.yaml @@ -0,0 +1,73 @@ +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 + copy_users: + type: boolean + description: Keep same list of assigned users (optional, default=false) + example: false + 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 diff --git a/test/test_api/test_project_api.py b/test/test_api/test_project_api.py index e67797ebc..5f6dc1c84 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) @@ -2559,3 +2560,177 @@ 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) + + + 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""" + [admin, subadminowner, subadmin, reguser] = UserFactory.create_batch(4) + make_admin(admin) + make_subadmin(subadminowner) + make_subadmin(subadmin) + + short_name = "testproject" + self._setup_project(short_name, subadminowner) + headers = [('Authorization', subadminowner.api_key)] + + # 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) + 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', headers=headers) + 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 == 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 == 403, 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" + self._setup_project(short_name, subadminowner) + 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" } + 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 + + # 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" + 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 == 200, data + + + @with_context + @patch('pybossa.api.clone_project') + def test_clone_project_error(self, clone_project): + """Test API clone project error when cloning project""" + 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_success(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 + + [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', '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