From f778bdca1a086b21c99890d75e97a420ce2036d2 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: Jun 17 2018 05:30:46 +0000 Subject: Add a new API endpoint allowing to update watch status on a project With this people with the appropriate API token will be able to adjust their own watch status and instance-wide admins will be able to do it for any user. Fixes https://pagure.io/pagure/issue/3205 Fixes https://pagure.io/pagure/issue/3174 Signed-off-by: Pierre-Yves Chibon --- diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index a3137c5..0f006fe 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -464,6 +464,8 @@ def api(): api_new_branch_doc = load_doc(project.api_new_branch) api_commit_flags_doc = load_doc(project.api_commit_flags) api_commit_add_flag_doc = load_doc(project.api_commit_add_flag) + api_update_project_watchers_doc = load_doc( + project.api_update_project_watchers) issues = [] if pagure_config.get('ENABLE_TICKETS', True): @@ -538,6 +540,7 @@ def api(): api_new_branch_doc, api_commit_flags_doc, api_commit_add_flag_doc, + api_update_project_watchers_doc, ], issues=issues, requests=[ diff --git a/pagure/api/project.py b/pagure/api/project.py index 0105013..474a3b7 100644 --- a/pagure/api/project.py +++ b/pagure/api/project.py @@ -1580,3 +1580,137 @@ def api_commit_add_flag(repo, commit_hash, username=None, namespace=None): jsonout = flask.jsonify(output) return jsonout + + +@API.route('//watchers/update', methods=['POST']) +@API.route('///watchers/update', methods=['POST']) +@API.route('/fork///watchers/update', methods=['POST']) +@API.route( + '/fork////watchers/update', methods=['POST']) +@api_login_required(acls=['update_watch_status']) +@api_method +def api_update_project_watchers(repo, username=None, namespace=None): + ''' + Update project watchers + ----------------------- + Allows anyone to update their own watch status on the project. + + :: + + POST /api/0//watchers/update + POST /api/0///watchers/update + + :: + + POST /api/0/fork///watchers/update + POST /api/0/fork////watchers/update + + Input + ^^^^^ + + +------------------+---------+--------------+---------------------------+ + | Key | Type | Optionality | Description | + +==================+=========+==============+===========================+ + | ``repo`` | string | Mandatory | | The name of the project | + | | | | to fork. | + +------------------+---------+--------------+---------------------------+ + | ``status`` | string | Mandatory | | The new watch status to | + | | | | set on that project. | + | | | | (See options below) | + +------------------+---------+--------------+---------------------------+ + | ``watcher`` | string | Mandatory | | The name of the user | + | | | | changing their watch | + | | | | status. | + +------------------+---------+--------------+---------------------------+ + | ``namespace`` | string | Optional | | The namespace of the | + | | | | project to fork. | + +------------------+---------+--------------+---------------------------+ + | ``username`` | string | Optional | | The username of the user| + | | | | of the fork. | + +------------------+---------+--------------+---------------------------+ + + Watch Status + ^^^^^^^^^^^^ + + +------------+----------------------------------------------+ + | Key | Description | + +============+==============================================+ + | -1 | Reset the watch status to default | + +------------+----------------------------------------------+ + | 0 | Unwatch, don't notify the user of anything | + +------------+----------------------------------------------+ + | 1 | Watch issues and pull-requests | + +------------+----------------------------------------------+ + | 2 | Watch commits | + +------------+----------------------------------------------+ + | 3 | Watch commits, issues and pull-requests | + +------------+----------------------------------------------+ + + Sample response + ^^^^^^^^^^^^^^^ + + :: + + { + "message": "You are now watching issues and PRs on this project", + "status": "ok" + } + ''' + + project = get_authorized_api_project( + flask.g.session, repo, namespace=namespace) + if not project: + raise pagure.exceptions.APIError( + 404, error_code=APIERROR.ENOPROJECT) + + if flask.g.token.project and project != flask.g.token.project: + raise pagure.exceptions.APIError( + 401, error_code=APIERROR.EINVALIDTOK) + + # Get the input submitted + data = get_request_data() + + watcher = data.get('watcher') + + if not watcher: + _log.debug( + 'api_update_project_watchers: Invalid watcher: %s', + watcher) + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EINVALIDREQ) + + is_site_admin = pagure.utils.is_admin() + # Only allow the main admin, and the user themselves to update their + # status + if not is_site_admin and flask.g.fas_user.username != watcher: + raise pagure.exceptions.APIError( + 401, error_code=APIERROR.EMODIFYPROJECTNOTALLOWED) + + try: + pagure.lib.get_user(flask.g.session, watcher) + except pagure.exceptions.PagureException as err: + _log.debug( + 'api_update_project_watchers: Invalid user watching: %s', + watcher) + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EINVALIDREQ) + + watch_status = data.get('status') + + try: + msg = pagure.lib.update_watch_status( + session=flask.g.session, + project=project, + user=watcher, + watch=watch_status) + flask.g.session.commit() + 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 + flask.g.session.rollback() + _log.exception(err) + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EDBERROR) + + return flask.jsonify({'message': msg, 'status': 'ok'}) diff --git a/pagure/default_config.py b/pagure/default_config.py index fa88fa6..ba22e33 100644 --- a/pagure/default_config.py +++ b/pagure/default_config.py @@ -289,6 +289,7 @@ ACLS = { 'pull_request_merge': 'Merge a pull-request', 'pull_request_subscribe': 'Subscribe the user with this token to a pull-request', + 'update_watch_status': 'Update the watch status on a project', } # List of ACLs which a regular user is allowed to associate to an API token @@ -300,7 +301,8 @@ USER_ACLS = [key for key in ACLS.keys() if key != 'generate_acls_project'] CROSS_PROJECT_ACLS = [ 'create_project', 'fork_project', - 'modify_project' + 'modify_project', + 'update_watch_status', ] # ACLs with which admins are allowed to create project-less API tokens diff --git a/tests/test_pagure_flask_api_project_update_watch.py b/tests/test_pagure_flask_api_project_update_watch.py new file mode 100644 index 0000000..3cb95d4 --- /dev/null +++ b/tests/test_pagure_flask_api_project_update_watch.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- + +""" + (c) 2018 - Copyright Red Hat Inc + + Authors: + Pierre-Yves Chibon + +""" + +from __future__ import unicode_literals + +__requires__ = ['SQLAlchemy >= 0.8'] +import pkg_resources + +import copy +import datetime +import unittest +import shutil +import sys +import time +import os + +import json +from mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname( + os.path.abspath(__file__)), '..')) + +import pagure +import pagure.lib +import tests + + +class PagureFlaskApiProjectUpdateWatchTests(tests.Modeltests): + """ Tests for the flask API of pagure for changing the watch status on + a project via the API + """ + + @patch('pagure.lib.notify.send_email', MagicMock(return_value=True)) + def setUp(self): + """ Set up the environnment, ran before every tests. """ + super(PagureFlaskApiProjectUpdateWatchTests, self).setUp() + + 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) + + # Create normal issue + repo = pagure.lib.get_authorized_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') + + # Create project-less token for user foo + item = pagure.lib.model.Token( + id='project-less-foo', + user_id=1, + project_id=None, + expiration=datetime.datetime.utcnow() + + datetime.timedelta(days=30) + ) + self.session.add(item) + self.session.commit() + tests.create_tokens_acl(self.session, token_id='project-less-foo') + + def test_api_update_project_watchers_invalid_project(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + + # Invalid project + output = self.app.post( + '/api/0/foobar/watchers/update', headers=headers) + self.assertEqual(output.status_code, 404) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + "error": "Project not found", + "error_code": "ENOPROJECT", + } + ) + + def test_api_change_status_issue_token_not_for_project(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + + # Valid token, wrong project + output = self.app.post( + '/api/0/test2/watchers/update', headers=headers) + self.assertEqual(output.status_code, 401) + data = json.loads(output.get_data(as_text=True)) + self.assertEqual(pagure.api.APIERROR.EINVALIDTOK.name, + data['error_code']) + self.assertEqual(pagure.api.APIERROR.EINVALIDTOK.value, data['error']) + + def test_api_update_project_watchers_no_user_watching(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'status': '42', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'error': u'Invalid or incomplete input submitted', + u'error_code': u'EINVALIDREQ' + } + ) + + def test_api_update_project_watchers_no_watch_status(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'watcher': 'pingou', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'error': u'The watch value of "None" is invalid', + u'error_code': u'ENOCODE' + } + ) + + def test_api_update_project_watchers_invalid_status(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'watcher': 'pingou', + 'status': '42', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'error': u'The watch value of "42" is invalid', + u'error_code': u'ENOCODE' + } + ) + + def test_api_update_project_watchers_invalid_user(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'watcher': 'example', + 'status': '2', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 401) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'error': u'You are not allowed to modify this project', + u'error_code': u'EMODIFYPROJECTNOTALLOWED' + } + ) + + def test_api_update_project_watchers_other_user(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'watcher': 'foo', + 'status': '2', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 401) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'error': u'You are not allowed to modify this project', + u'error_code': u'EMODIFYPROJECTNOTALLOWED' + } + ) + + def test_api_update_project_watchers_all_good(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'watcher': 'pingou', + 'status': 1, + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 200) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'message': u'You are now watching issues and PRs on this project', + u'status': u'ok' + } + ) + + @patch('pagure.utils.is_admin', MagicMock(return_value=True)) + def test_api_update_project_watchers_other_user_admin(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'watcher': 'foo', + 'status': '2', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 200) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'message': u'You are now watching commits on this project', + u'status': u'ok' + } + ) + + @patch('pagure.utils.is_admin', MagicMock(return_value=True)) + def test_api_update_project_watchers_invalid_user_admin(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'watcher': 'example', + 'status': '2', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'error': u'Invalid or incomplete input submitted', + u'error_code': u'EINVALIDREQ' + } + ) + + @patch('pagure.utils.is_admin', MagicMock(return_value=True)) + def test_api_update_project_watchers_missing_user_admin(self): + """ Test the api_update_project_watchers method of the flask api. """ + + headers = {'Authorization': 'token aaabbbcccddd'} + data = { + 'status': '2', + } + + output = self.app.post( + '/api/0/test/watchers/update', headers=headers, data=data) + self.assertEqual(output.status_code, 400) + data = json.loads(output.get_data(as_text=True)) + self.assertDictEqual( + data, + { + u'error': u'Invalid or incomplete input submitted', + u'error_code': u'EINVALIDREQ' + } + ) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_pagure_lib.py b/tests/test_pagure_lib.py index 8b2b780..14f1ed5 100644 --- a/tests/test_pagure_lib.py +++ b/tests/test_pagure_lib.py @@ -5614,6 +5614,7 @@ foo bar 'pull_request_flag', 'pull_request_merge', 'pull_request_subscribe', + 'update_watch_status', ] )