diff --git a/pagure/api/issue.py b/pagure/api/issue.py index 10fc006..076d219 100644 --- a/pagure/api/issue.py +++ b/pagure/api/issue.py @@ -720,6 +720,128 @@ def api_change_status_issue(repo, issueid, username=None, namespace=None): return jsonout +@API.route('//issue//milestone', methods=['POST']) +@API.route('///issue//milestone', methods=['POST']) +@API.route( + '/fork///issue//milestone', methods=['POST']) +@API.route( + '/fork////issue//milestone', + methods=['POST']) +@api_login_required(acls=['issue_update_milestone', 'issue_update']) +@api_method +def api_change_milestone_issue(repo, issueid, username=None, namespace=None): + """ + Change issue milestone + ------------------- + Change the milestone of an issue. + + :: + + POST /api/0//issue//milestone + POST /api/0///issue//milestone + + :: + + POST /api/0/fork///issue//milestone + POST /api/0/fork////issue//milestone + + Input + ^^^^^ + + +----------------- +---------+--------------+------------------------+ + | Key | Type | Optionality | Description | + +==================+=========+==============+========================+ + | ``milestone`` | string | Optional | The new milestone of | + | | | | the issue, can be any | + | | | | of defined milestones | + | | | | or empty to unset the | + | | | | milestone | + +----------------- +---------+--------------+------------------------+ + + Sample response + ^^^^^^^^^^^^^^^ + + :: + + { + "message": "Successfully edited issue #1" + } + + """ + repo = pagure.lib.get_project( + SESSION, repo, user=username, namespace=namespace) + + output = {} + + if repo is None: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOPROJECT) + + if not repo.settings.get('issue_tracker', True): + raise pagure.exceptions.APIError( + 404, error_code=APIERROR.ETRACKERDISABLED) + + if api_authenticated(): + if repo != flask.g.token.project: + raise pagure.exceptions.APIError( + 401, error_code=APIERROR.EINVALIDTOK) + + issue = pagure.lib.search_issues(SESSION, repo, issueid=issueid) + + if issue is None or issue.project != repo: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOISSUE) + + if issue.private and not is_repo_committer(repo) \ + and (not api_authenticated() or + not issue.user.user == flask.g.fas_user.username): + raise pagure.exceptions.APIError( + 403, error_code=APIERROR.EISSUENOTALLOWED) + + form = pagure.forms.MilestoneForm( + milestones=repo.milestones.keys(), + csrf_enabled=False) + + if form.validate_on_submit(): + new_milestone = form.milestone.data + if new_milestone == '': + new_milestone = None # unset milestone + try: + # Update status + message = pagure.lib.edit_issue( + SESSION, + issue=issue, + milestone=new_milestone, + user=flask.g.fas_user.username, + ticketfolder=APP.config['TICKETS_FOLDER'], + ) + SESSION.commit() + if message: + output['message'] = message + else: + output['message'] = 'No changes' + + if message: + pagure.lib.add_metadata_update_notif( + session=SESSION, + issue=issue, + messages=message, + user=flask.g.fas_user.username, + ticketfolder=APP.config['TICKETS_FOLDER'] + ) + except pagure.exceptions.PagureException as err: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.ENOCODE, error=str(err)) + except SQLAlchemyError as err: # pragma: no cover + SESSION.rollback() + raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR) + + else: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EINVALIDREQ, errors=form.errors) + + jsonout = flask.jsonify(output) + return jsonout + + @API.route('//issue//comment', methods=['POST']) @API.route('///issue//comment', methods=['POST']) @API.route( diff --git a/pagure/default_config.py b/pagure/default_config.py index 78930b9..d3c3e48 100644 --- a/pagure/default_config.py +++ b/pagure/default_config.py @@ -219,6 +219,7 @@ ACLS = { 'issue_subscribe': 'Subscribe the user with this token to an issue', 'issue_update': 'Update an issue, status, comments, custom fields...', 'issue_update_custom_fields': 'Update the custom fields of an issue', + 'issue_update_milestone': 'Update the milestone of an issue', } # From the ACLs above lists which ones are tolerated to be associated with diff --git a/pagure/forms.py b/pagure/forms.py index 9e91727..c40b210 100644 --- a/pagure/forms.py +++ b/pagure/forms.py @@ -286,6 +286,28 @@ class StatusForm(PagureForm): self.close_status.choices.insert(0, ('', '')) +class MilestoneForm(PagureForm): + ''' Form to change the milestone of an issue. ''' + milestone = wtforms.SelectField( + 'Milestone', + [wtforms.validators.Optional()], + choices=[], + coerce=convert_value + ) + + def __init__(self, *args, **kwargs): + """ Calls the default constructor with the normal argument but + uses the list of collection provided to fill the choices of the + drop-down list. + """ + super(MilestoneForm, self).__init__(*args, **kwargs) + self.milestone.choices = [] + if 'milestones' in kwargs and kwargs['milestones']: + for key in sorted(kwargs['milestones']): + self.milestone.choices.append((key, key)) + self.milestone.choices.insert(0, ('', '')) + + class NewTokenForm(PagureForm): ''' Form to add/change the status of an issue. ''' description = wtforms.TextField( diff --git a/tests/test_pagure_flask_api_issue.py b/tests/test_pagure_flask_api_issue.py index 8ba4531..77b9964 100644 --- a/tests/test_pagure_flask_api_issue.py +++ b/tests/test_pagure_flask_api_issue.py @@ -2017,6 +2017,207 @@ class PagureFlaskApiIssuetests(tests.Modeltests): data['error_code']) self.assertEqual(pagure.api.APIERROR.EINVALIDTOK.value, data['error']) + def test_api_change_milestone_issue(self): + """ Test the api_change_milestone_issue method of the flask api. """ + tests.create_projects(self.session) + tests.create_projects_git(os.path.join(self.path, 'tickets')) + tests.create_tokens(self.session) + tests.create_tokens_acl(self.session) + + # Set some milestones to the project + repo = pagure.lib.get_project(self.session, 'test') + repo.milestones = {'v1.0': None, 'v2.0': 'Soon'} + self.session.add(repo) + self.session.commit() + + headers = {'Authorization': 'token aaabbbcccddd'} + + # Invalid project + output = self.app.post('/api/0/foo/issue/1/milestone', headers=headers) + self.assertEqual(output.status_code, 404) + data = json.loads(output.data) + self.assertDictEqual( + data, + { + "error": "Project not found", + "error_code": "ENOPROJECT", + } + ) + + # Valid token, wrong project + output = self.app.post('/api/0/test2/issue/1/milestone', headers=headers) + self.assertEqual(output.status_code, 401) + data = json.loads(output.data) + self.assertEqual(pagure.api.APIERROR.EINVALIDTOK.name, + data['error_code']) + self.assertEqual(pagure.api.APIERROR.EINVALIDTOK.value, data['error']) + + # No issue + output = self.app.post('/api/0/test/issue/1/milestone', headers=headers) + self.assertEqual(output.status_code, 404) + data = json.loads(output.data) + self.assertDictEqual( + data, + { + "error": "Issue not found", + "error_code": "ENOISSUE", + } + ) + + # Create normal issue + repo = pagure.lib.get_project(self.session, 'test') + msg = pagure.lib.new_issue( + session=self.session, + repo=repo, + title='Test issue #1', + content='We should work on this', + user='pingou', + ticketfolder=None, + private=False, + ) + self.session.commit() + self.assertEqual(msg.title, 'Test issue #1') + + # Check milestone before + repo = pagure.lib.get_project(self.session, 'test') + issue = pagure.lib.search_issues(self.session, repo, issueid=1) + self.assertEqual(issue.milestone, None) + + data = { + 'milestone': '', + } + + # Valid request but no milestone specified + output = self.app.post( + '/api/0/test/issue/1/milestone', data=data, headers=headers) + self.assertEqual(output.status_code, 200) + data = json.loads(output.data) + self.assertDictEqual( + data, + {'message': 'No changes'} + ) + + # No change + repo = pagure.lib.get_project(self.session, 'test') + issue = pagure.lib.search_issues(self.session, repo, issueid=1) + self.assertEqual(issue.milestone, None) + + data = { + 'milestone': 'milestone-1-0', + } + + # Invalid milestone specified + output = self.app.post( + '/api/0/test/issue/1/milestone', data=data, headers=headers) + self.assertEqual(output.status_code, 400) + data = json.loads(output.data) + self.assertDictEqual( + data, + { + "error": "Invalid or incomplete input submited", + "error_code": "EINVALIDREQ", + "errors": { + "milestone": [ + "Not a valid choice" + ] + } + } + ) + + data = { + 'milestone': 'v1.0', + } + + # Valid requests + output = self.app.post( + '/api/0/test/issue/1/milestone', data=data, headers=headers) + self.assertEqual(output.status_code, 200) + data = json.loads(output.data) + self.assertDictEqual( + data, + { + "message": [ + "Issue set to the milestone: v1.0" + ] + } + ) + + # remove milestone + data = { + 'milestone': '', + } + + # Valid requests + output = self.app.post( + '/api/0/test/issue/1/milestone', data=data, headers=headers) + self.assertEqual(output.status_code, 200) + data = json.loads(output.data) + self.assertDictEqual( + data, + { + "message": [ + "Issue set to the milestone: None (was: v1.0)" + ] + } + ) + + # Change recorded + repo = pagure.lib.get_project(self.session, 'test') + issue = pagure.lib.search_issues(self.session, repo, issueid=1) + self.assertEqual(issue.milestone, None) + + data = { + 'milestone': 'v1.0', + } + + # Valid requests + output = self.app.post( + '/api/0/test/issue/1/milestone', data=data, headers=headers) + self.assertEqual(output.status_code, 200) + data = json.loads(output.data) + self.assertDictEqual( + data, + { + "message": [ + "Issue set to the milestone: v1.0" + ] + } + ) + + # remove milestone by using no milestone in JSON + data = {} + + # Valid requests + output = self.app.post( + '/api/0/test/issue/1/milestone', data=data, headers=headers) + self.assertEqual(output.status_code, 200) + data = json.loads(output.data) + self.assertDictEqual( + data, + { + "message": [ + "Issue set to the milestone: None (was: v1.0)" + ] + } + ) + + # Change recorded + repo = pagure.lib.get_project(self.session, 'test') + issue = pagure.lib.search_issues(self.session, repo, issueid=1) + self.assertEqual(issue.milestone, None) + + headers = {'Authorization': 'token pingou_foo'} + + # Un-authorized issue + output = self.app.post( + '/api/0/foo/issue/1/milestone', data=data, headers=headers) + self.assertEqual(output.status_code, 401) + data = json.loads(output.data) + self.assertEqual(pagure.api.APIERROR.EINVALIDTOK.name, + data['error_code']) + self.assertEqual(pagure.api.APIERROR.EINVALIDTOK.value, data['error']) + + @patch('pagure.lib.git.update_git') @patch('pagure.lib.notify.send_email') def test_api_comment_issue(self, p_send_email, p_ugt):