Skip to content

Commit

Permalink
Merge pull request #1017 from bloomberg/RDISCROWD-7842
Browse files Browse the repository at this point in the history
RDISCROWD-7842 add project clone API endpoint
  • Loading branch information
n00rsy authored Dec 23, 2024
2 parents df2293a + 160cac3 commit dac3580
Show file tree
Hide file tree
Showing 3 changed files with 301 additions and 1 deletion.
54 changes: 53 additions & 1 deletion pybossa/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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/<int:project_id>/clone', methods=['POST'])
@blueprint.route('/project/<short_name>/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}")
73 changes: 73 additions & 0 deletions pybossa/api/docs/project/project_clone.yaml
Original file line number Diff line number Diff line change
@@ -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
175 changes: 175 additions & 0 deletions test/test_api/test_project_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

0 comments on commit dac3580

Please sign in to comment.