diff --git a/createdb.py b/createdb.py index c7a00ce..eb78c78 100644 --- a/createdb.py +++ b/createdb.py @@ -10,4 +10,5 @@ from pagure.lib import model model.create_tables( APP.config['DB_URL'], APP.config.get('PATH_ALEMBIC_INI', None), + acls=APP.config.get('ACLS', {}), debug=True) diff --git a/pagure/__init__.py b/pagure/__init__.py index 1477b7b..929f935 100644 --- a/pagure/__init__.py +++ b/pagure/__init__.py @@ -149,10 +149,10 @@ def generate_gitolite_acls(): ) elif gitolite_version == 3: cmd = 'HOME=%s gitolite compile && HOME=%s gitolite trigger '\ - 'POST_COMPILE' % ( - APP.config.get('GITOLITE_HOME'), - APP.config.get('GITOLITE_HOME') - ) + 'POST_COMPILE' % ( + APP.config.get('GITOLITE_HOME'), + APP.config.get('GITOLITE_HOME') + ) else: raise pagure.exceptions.PagureException( 'Non-supported gitolite version "%s"' % gitolite_version @@ -204,8 +204,8 @@ def generate_authorized_key_file(): # pragma: no cover gitolite_home, user.user, user.public_ssh_key) else: raise pagure.exceptions.PagureException( - 'Non-supported gitolite version "%s"' % gitolite_version - ) + 'Non-supported gitolite version "%s"' % + gitolite_version) stream.write(row + '\n') stream.write('# gitolite end\n') @@ -389,8 +389,12 @@ import pagure.ui.issues import pagure.ui.plugins import pagure.ui.repo -import pagure.api -APP.register_blueprint(pagure.api.API) +from pagure.api import API +from pagure.api import issue +from pagure.api import fork +from pagure.api import user +APP.register_blueprint(API) + import pagure.internal APP.register_blueprint(pagure.internal.PV) diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index 2fb250c..0cff249 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -10,14 +10,161 @@ API namespace version 0. """ +import functools + import flask +import enum API = flask.Blueprint('api_ns', __name__, url_prefix='/api/0') -from pagure import __api_version__, APP, SESSION import pagure import pagure.lib +from pagure import __api_version__, APP, SESSION +from pagure.exceptions import APIError + + +class APIERROR(enum.Enum): + """ Clast listing as Enum all the possible error thrown by the API. + """ + ENOCODE = 'Variable message describing the issue' + ENOPROJECT = 'Project not found' + ETRACKERDISABLED = 'Issue tracker disabled for this project' + EDBERROR = 'An error occured at the database level and prevent the ' \ + 'action from reaching completion' + EINVALIDREQ = 'Invalid or incomplete input submited' + EINVALIDTOK = 'Invalid or expired token. Please visit %s to get or '\ + 'renew your API token.' % APP.config['APP_URL'] + ENOISSUE = 'Issue not found' + EISSUENOTALLOWED = 'You are not allowed to view this issue' + EPULLREQUESTSDISABLED = 'Pull-Request have been deactivated for this '\ + 'project' + ENOREQ = 'Pull-Request not found' + ENOPRCLOSE = 'You are not allowed to merge/close pull-request for '\ + 'this project' + EPRSCORE = 'This request does not have the minimum review score '\ + 'necessary to be merged' + ENOTASSIGNEE = 'Only the assignee can merge this review' + ENOTASSIGNED = 'This request must be assigned to be merged' + ENOUSER = 'No such user found' + + +def check_api_acls(acls, optional=False): + ''' Checks if the user provided an API token with its request and if + this token allows the user to access the endpoint desired. + ''' + + flask.g.token = None + flask.g.user = None + token = None + token_str = None + apt_login = None + if 'Authorization' in flask.request.headers: + authorization = flask.request.headers['Authorization'] + if 'token' in authorization: + token_str = authorization.split('token', 1)[1].strip() + + token_auth = False + if token_str: + token = pagure.lib.get_api_token(SESSION, token_str) + if token and not token.expired: + if acls and set(token.acls_list).intersection(set(acls)): + token_auth = True + flask.g.fas_user = token.user + flask.g.token = token + elif not acls and optional: + token_auth = True + flask.g.fas_user = token.user + flask.g.token = token + + if not token_auth: + output = { + 'error_code': APIERROR.EINVALIDTOK.name, + 'error': APIERROR.EINVALIDTOK.value, + } + jsonout = flask.jsonify(output) + jsonout.status_code = 401 + return jsonout + + +def api_login_required(acls=None): + ''' Decorator used to indicate that authentication is required for some + API endpoint. + ''' + + def decorator(fn): + ''' The decorator of the function ''' + + @functools.wraps(fn) + def decorated_function(*args, **kwargs): + ''' Actually does the job with the arguments provided. ''' + + response = check_api_acls(acls) + if response: + return response + return fn(*args, **kwargs) + + return decorated_function + + return decorator + + +def api_login_optional(acls=None): + ''' Decorator used to indicate that authentication is optional for some + API endpoint. + ''' + + def decorator(fn): + ''' The decorator of the function ''' + + @functools.wraps(fn) + def decorated_function(*args, **kwargs): + ''' Actually does the job with the arguments provided. ''' + + check_api_acls(acls, optional=True) + return fn(*args, **kwargs) + + return decorated_function + + return decorator + + +def api_method(function): + ''' Runs an API endpoint and catch all the APIException thrown. ''' + + @functools.wraps(function) + def wrapper(*args, **kwargs): + try: + result = function(*args, **kwargs) + except APIError as e: + if e.error_code in [APIERROR.EDBERROR]: + APP.logger.exception(e) + + if e.error_code in [APIERROR.ENOCODE]: + response = flask.jsonify( + { + 'error': e.error, + 'error_code': e.error_code + } + ) + else: + response = flask.jsonify( + { + 'error': e.error_code.value, + 'error_code': e.error_code.name, + } + ) + response.status_code = e.status_code + else: + response = result + + return response + + return wrapper + + +from pagure.api import issue +from pagure.api import fork @API.route('/version/') @@ -30,7 +177,7 @@ def api_version(): :: - /api/version + /api/0/version Accepts GET queries only. @@ -57,7 +204,7 @@ def api_users(): :: - /api/users + /api/0/users Accepts GET queries only. @@ -93,13 +240,14 @@ def api_project_tags(repo, username=None): ''' List all the tags of a project ------------------------------ - Returns the list of all tags of the specified project. + Returns the list of all tags assigned to the tickets of the specified + project. :: - /api//tags + /api/0//tags - /api/fork///tags + /api/0/fork///tags Accepts GET queries only. @@ -134,7 +282,6 @@ def api_project_tags(repo, username=None): ) - @API.route('/groups/') @API.route('/groups') def api_groups(): @@ -146,7 +293,7 @@ def api_groups(): :: - /api/groups + /api/0/groups Accepts GET queries only. @@ -172,3 +319,32 @@ def api_groups(): ] } ) + + +@API.route('/error_codes/') +@API.route('/error_codes') +def api_error_codes(): + ''' + Error codes + ------------ + Returns the dictionary (hash) of all the error codes present in the API + + :: + + /api/0/error_codes + + Accepts GET queries only. + + Sample response: + + :: + + { + ENOCODE: 'Variable message describing the issue', + ENOPROJECT: 'Project not found', + } + + ''' + errors = {val.name: val.value for val in APIERROR.__members__.values()} + + return flask.jsonify(errors) diff --git a/pagure/api/fork.py b/pagure/api/fork.py new file mode 100644 index 0000000..d87b032 --- /dev/null +++ b/pagure/api/fork.py @@ -0,0 +1,489 @@ +# -*- coding: utf-8 -*- + +""" + (c) 2015 - Copyright Red Hat Inc + + Authors: + Pierre-Yves Chibon + +""" + +import flask + +from sqlalchemy.exc import SQLAlchemyError + +import pagure +import pagure.exceptions +import pagure.lib +from pagure import APP, SESSION, is_repo_admin, authenticated +from pagure.api import ( + API, api_method, api_login_required, api_login_optional, APIERROR +) + + +@API.route('//pull-requests') +@API.route('/fork///pull-requests') +@api_method +def api_pull_request_views(repo, username=None): + """ + List project's Pull-Requests + ---------------------------- + This endpoint can be used to retrieve the pull-requests of the specified + project + + :: + + /api/0//pull-requests + + /api/0/fork///pull-requests + + Accepts GET queries only. + + :kwarg status: The status of the pull-requests to return, default to + 'True' (ie: opened pull-requests) + :kwarg assignee: Filters the pull-requests returned by the user they + are assigned to + :kwarg author: Filters the pull-requests returned by the user that + opened the pull-request + + Sample response: + + :: + + { + "args": { + "assignee": null, + "author": null, + "status": true + }, + "requests": [ + { + "assignee": null, + "branch": "master", + "branch_from": "master", + "comments": [], + "commit_start": null, + "commit_stop": null, + "date_created": "1431414800", + "id": 1, + "project": { + "date_created": "1431414800", + "description": "test project #1", + "id": 1, + "name": "test", + "parent": null, + "settings": { + "Minimum_score_to_merge_pull-request": -1, + "Only_assignee_can_merge_pull-request": false, + "Web-hooks": None, + "issue_tracker": true, + "project_documentation": true, + "pull_requests": true + }, + "user": { + "fullname": "PY C", + "name": "pingou" + } + }, + "repo_from": { + "date_created": "1431414800", + "description": "test project #1", + "id": 1, + "name": "test", + "parent": null, + "settings": { + "Minimum_score_to_merge_pull-request": -1, + "Only_assignee_can_merge_pull-request": false, + "Web-hooks": null, + "issue_tracker": true, + "project_documentation": true, + "pull_requests": true + }, + "user": { + "fullname": "PY C", + "name": "pingou" + } + }, + "status": true, + "title": "test pull-request", + "uid": "1431414800", + "user": { + "fullname": "PY C", + "name": "pingou" + } + } + ] + } + + """ + + repo = pagure.lib.get_project(SESSION, repo, user=username) + output = {} + + if repo is None: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOPROJECT) + + if not repo.settings.get('pull_requests', True): + raise pagure.exceptions.APIError( + 404, error_code=APIERROR.EPULLREQUESTSDISABLED) + + status = flask.request.args.get('status', True) + assignee = flask.request.args.get('assignee', None) + author = flask.request.args.get('author', None) + + requests = [] + if status is False or str(status).lower() == 'closed': + requests = pagure.lib.search_pull_requests( + SESSION, + project_id=repo.id, + status=False, + assignee=assignee, + author=author) + else: + requests = pagure.lib.search_pull_requests( + SESSION, + project_id=repo.id, + assignee=assignee, + author=author, + status=status) + + jsonout = flask.jsonify({ + 'requests': [request.to_json(public=True) for request in requests], + 'args': { + 'status': status, + 'assignee': assignee, + 'author': author, + } + }) + return jsonout + + +@API.route('//pull-request/') +@API.route('/fork///pull-request/') +@api_method +def api_pull_request_view(repo, requestid, username=None): + """ + Pull-request information + ------------------------ + This endpoint can be used to retrieve information about a specific + pull-request + + :: + + /api/0//pull-request/ + + /api/0/fork///pull-request/ + + Accepts GET queries only. + + Sample response: + + :: + + { + "assignee": null, + "branch": "master", + "branch_from": "master", + "comments": [], + "commit_start": null, + "commit_stop": null, + "date_created": "1431414800", + "id": 1, + "project": { + "date_created": "1431414800", + "description": "test project #1", + "id": 1, + "name": "test", + "parent": null, + "settings": { + "Minimum_score_to_merge_pull-request": -1, + "Only_assignee_can_merge_pull-request": false, + "Web-hooks": null, + "issue_tracker": true, + "project_documentation": true, + "pull_requests": true + }, + "user": { + "fullname": "PY C", + "name": "pingou" + } + }, + "repo_from": { + "date_created": "1431414800", + "description": "test project #1", + "id": 1, + "name": "test", + "parent": null, + "settings": { + "Minimum_score_to_merge_pull-request": -1, + "Only_assignee_can_merge_pull-request": false, + "Web-hooks": null, + "issue_tracker": true, + "project_documentation": true, + "pull_requests": true + }, + "user": { + "fullname": "PY C", + "name": "pingou" + } + }, + "status": true, + "title": "test pull-request", + "uid": "1431414800", + "user": { + "fullname": "PY C", + "name": "pingou" + } + } + + """ + + repo = pagure.lib.get_project(SESSION, repo, user=username) + output = {} + + if repo is None: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOPROJECT) + + if not repo.settings.get('pull_requests', True): + raise pagure.exceptions.APIError( + 404, error_code=APIERROR.EPULLREQUESTSDISABLED) + + request = pagure.lib.search_pull_requests( + SESSION, project_id=repo.id, requestid=requestid) + + if not request: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOREQ) + + jsonout = flask.jsonify(request.to_json(public=True)) + return jsonout + + +@API.route('//pull-request//merge', methods=['POST']) +@API.route('/fork///pull-request//merge', + methods=['POST']) +@api_login_required(acls=['pull_request_merge']) +@api_method +def api_pull_request_merge(repo, requestid, username=None): + """ + Merge a pull-request + -------------------- + This endpoint can be used to instruct pagure to merge a pull-request + + :: + + /api/0//pull-request//merge + + /api/0/fork///pull-request//merge + + Accepts POST queries only. + + Sample response: + + :: + + { + "message": "Changes merged!" + } + + """ + output = {} + + repo = pagure.lib.get_project(SESSION, repo, user=username) + + if repo is None: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOPROJECT) + + if not repo.settings.get('pull_requests', True): + raise pagure.exceptions.APIError( + 404, error_code=APIERROR.EPULLREQUESTSDISABLED) + + if repo != flask.g.token.project: + raise pagure.exceptions.APIError(401, error_code=APIERROR.EINVALIDTOK) + + request = pagure.lib.search_pull_requests( + SESSION, project_id=repo.id, requestid=requestid) + + if not request: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOREQ) + + if not is_repo_admin(repo): + raise pagure.exceptions.APIError(403, error_code=APIERROR.ENOPRCLOSE) + + if repo.settings.get('Only_assignee_can_merge_pull-request', False): + if not request.assignee: + raise pagure.exceptions.APIError( + 403, error_code=APIERROR.ENOTASSIGNED) + + if request.assignee.username != flask.g.fas_user.username: + raise pagure.exceptions.APIError( + 403, error_code=APIERROR.ENOTASSIGNEE) + + threshold = repo.settings.get('Minimum_score_to_merge_pull-request', -1) + if threshold > 0 and int(request.score) < int(threshold): + raise pagure.exceptions.APIError(403, error_code=APIERROR.EPRSCORE) + + try: + message = pagure.lib.git.merge_pull_request( + SESSION, repo, request, flask.g.fas_user.username, + APP.config['REQUESTS_FOLDER']) + output['message'] = message + except pagure.exceptions.PagureException as err: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.ENOCODE, error=str(err)) + + jsonout = flask.jsonify(output) + return jsonout + + +@API.route('//pull-request//close', methods=['POST']) +@API.route('/fork///pull-request//close', + methods=['POST']) +@api_login_required(acls=['pull_request_close']) +@api_method +def api_pull_request_close(repo, requestid, username=None): + """ + Close a pull-request + -------------------- + This endpoint can be used to instruct pagure to close a pull-request + without merging it + + :: + + /api/0//pull-request//close + + /api/0/fork///pull-request//close + + Accepts POST queries only. + + Sample response: + + :: + + { + "message": "Pull-request closed!" + } + + """ + output = {} + + repo = pagure.lib.get_project(SESSION, repo, user=username) + + if repo is None: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOPROJECT) + + if not repo.settings.get('pull_requests', True): + raise pagure.exceptions.APIError( + 404, error_code=APIERROR.EPULLREQUESTSDISABLED) + + if repo != flask.g.token.project: + raise pagure.exceptions.APIError(401, error_code=APIERROR.EINVALIDTOK) + + request = pagure.lib.search_pull_requests( + SESSION, project_id=repo.id, requestid=requestid) + + if not request: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOREQ) + + if not is_repo_admin(repo): + raise pagure.exceptions.APIError(403, error_code=APIERROR.ENOPRCLOSE) + + try: + pagure.lib.close_pull_request( + SESSION, request, flask.g.fas_user.username, + requestfolder=APP.config['REQUESTS_FOLDER'], + merged=False) + SESSION.commit() + output['message'] = 'Pull-request closed!' + except SQLAlchemyError as err: # pragma: no cover + SESSION.rollback() + APP.logger.exception(err) + raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR) + + jsonout = flask.jsonify(output) + return jsonout + + +@API.route('//pull-request//comment', + methods=['POST']) +@API.route('/fork///pull-request//comment', + methods=['POST']) +@api_login_required(acls=['pull_request_comment']) +@api_method +def api_pull_request_add_comment(repo, requestid, username=None): + """ + Comment on a pull-request + -------------------- + This endpoint can be used to comment on a pull-request + + :: + + /api/0//pull-request//comment + + /api/0/fork///pull-request//comment + + Accepts POST queries only. + + :arg comment: The comment to add to the pull-request + :kwarg commit: The hash of the commit you wish to comment on + :kwarg filename: The name of the file you wish to comment on + :kwarg row: Used in combination with filename to comment on a specific + row of a file of the pull-request + + Sample response: + + :: + + { + "message": "Comment added" + } + + """ + repo = pagure.lib.get_project(SESSION, repo, user=username) + output = {} + + if repo is None: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOPROJECT) + + if not repo.settings.get('pull_requests', True): + raise pagure.exceptions.APIError( + 404, error_code=APIERROR.EPULLREQUESTSDISABLED) + + if repo.fullname != flask.g.token.project.fullname: + raise pagure.exceptions.APIError(401, error_code=APIERROR.EINVALIDTOK) + + request = pagure.lib.search_pull_requests( + SESSION, project_id=repo.id, requestid=requestid) + + if not request: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOREQ) + + form = pagure.forms.AddPullRequestCommentForm(csrf_enabled=False) + if form.validate_on_submit(): + comment = form.comment.data + commit = form.commit.data or None + filename = form.filename.data or None + row = form.row.data or None + try: + # New comment + message = pagure.lib.add_pull_request_comment( + SESSION, + request=request, + commit=commit, + filename=filename, + row=row, + comment=comment, + user=flask.g.fas_user.username, + requestfolder=APP.config['REQUESTS_FOLDER'], + ) + SESSION.commit() + output['message'] = message + except SQLAlchemyError, err: # pragma: no cover + APP.logger.exception(err) + SESSION.rollback() + raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR) + + else: + raise pagure.exceptions.APIError(400, error_code=APIERROR.EINVALIDREQ) + + jsonout = flask.jsonify(output) + return jsonout diff --git a/pagure/api/issue.py b/pagure/api/issue.py new file mode 100644 index 0000000..1e3e5ee --- /dev/null +++ b/pagure/api/issue.py @@ -0,0 +1,543 @@ +# -*- coding: utf-8 -*- + +""" + (c) 2015 - Copyright Red Hat Inc + + Authors: + Pierre-Yves Chibon + +""" + +import flask + +from sqlalchemy.exc import SQLAlchemyError + +import pagure +import pagure.exceptions +import pagure.lib +from pagure import APP, SESSION, is_repo_admin, authenticated +from pagure.api import ( + API, api_method, api_login_required, api_login_optional, APIERROR +) + + +@API.route('//new_issue', methods=['POST']) +@API.route('/fork///new_issue', methods=['POST']) +@api_login_required(acls=['issue_create']) +@api_method +def api_new_issue(repo, username=None): + """ + Create a new issue + ------------------ + This endpoint can be used to open an issue on a project + + :: + + /api/0//new_issue + + /api/0/fork///new_issue + + Accepts POST queries only. + + :arg title: The title of the issue/ticket to create + :arg content: The content of the issue to create (ie the description of + the problem) + :arg private: A boolean specifying whether this issue is private or not + + Sample response: + + :: + + { + "message": "Issue created" + } + + """ + repo = pagure.lib.get_project(SESSION, repo, user=username) + 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 repo != flask.g.token.project: + raise pagure.exceptions.APIError(401, error_code=APIERROR.EINVALIDTOK) + + status = pagure.lib.get_issue_statuses(SESSION) + form = pagure.forms.IssueForm(status=status, csrf_enabled=False) + if form.validate_on_submit(): + title = form.title.data + content = form.issue_content.data + private = form.private.data + + try: + issue = pagure.lib.new_issue( + SESSION, + repo=repo, + title=title, + content=content, + private=private or False, + user=flask.g.fas_user.username, + ticketfolder=APP.config['TICKETS_FOLDER'], + ) + SESSION.flush() + # If there is a file attached, attach it. + filestream = flask.request.files.get('filestream') + if filestream and '' in issue.content: + new_filename = pagure.lib.git.add_file_to_git( + repo=repo, + issue=issue, + ticketfolder=APP.config['TICKETS_FOLDER'], + user=flask.g.fas_user, + filename=filestream.filename, + filestream=filestream.stream, + ) + # Replace the tag in the comment with the link + # to the actual image + filelocation = flask.url_for( + 'view_issue_raw_file', + repo=repo.name, + username=username, + filename=new_filename, + ) + new_filename = new_filename.split('-', 1)[1] + url = '[![%s](%s)](%s)' % ( + new_filename, filelocation, filelocation) + issue.content = issue.content.replace('', url) + SESSION.add(issue) + SESSION.flush() + + SESSION.commit() + output['message'] = 'Issue created' + except SQLAlchemyError, 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) + + jsonout = flask.jsonify(output) + return jsonout + + +@API.route('//issues') +@API.route('/fork///issues') +@api_login_optional() +@api_method +def api_view_issues(repo, username=None): + """ + List project's issues + --------------------- + This endpoint can be used to retrieve the list of all issues of the + specified project + + :: + + /api/0//issues + + /api/0/fork///issues + + Accepts GET queries only. + + :kwarg status: The status of the issues to return, default to 'Open' + :kwarg tags: One or more tags to filter the issues returned. + If you want to wish to filter for issues not having a specific tag + you can mark the tag with an exclamation mark in front of it, for + example to get all the issues not tagged as ``easyfix`` you can + filter using the tag ``!easyfix`` + :kwarg assignee: Filters the issues returned by the user they are + assigned to + :kwarg author: Filters the issues returned by the user that opened the + issue + + Sample response: + + :: + + { + "args": { + "assignee": null, + "author": null, + "status": null, + "tags": [] + }, + "issues": [ + { + "assignee": null, + "blocks": [], + "comments": [ + { + "comment": "bing", + "date_created": "1427441560", + "id": 379, + "parent": null, + "user": { + "fullname": "PY.C", + "name": "pingou" + } + } + ], + "content": "bar", + "date_created": "1427441555", + "depends": [], + "id": 1, + "private": false, + "status": "Open", + "tags": [], + "title": "foo", + "user": { + "fullname": "PY.C", + "name": "pingou" + } + }, + { + "assignee": null, + "blocks": [], + "comments": [], + "content": "report", + "date_created": "1427442076", + "depends": [], + "id": 2, + "private": false, + "status": "Open", + "tags": [], + "title": "bug", + "user": { + "fullname": "PY.C", + "name": "pingou" + } + } + ] + } + + Second example: + + { + "args": { + "assignee": null, + "author": null, + "status": "Closed", + "tags": [ + "0.1" + ] + }, + "issues": [ + { + "assignee": null, + "blocks": [], + "comments": [], + "content": "asd", + "date_created": "1427442217", + "depends": [], + "id": 4, + "private": false, + "status": "Fixed", + "tags": [ + "0.1" + ], + "title": "bug", + "user": { + "fullname": "PY.C", + "name": "pingou" + } + } + ] + } + + """ + + repo = pagure.lib.get_project(SESSION, repo, user=username) + 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) + + status = flask.request.args.get('status', None) + tags = flask.request.args.getlist('tags') + tags = [tag.strip() for tag in tags if tag.strip()] + assignee = flask.request.args.get('assignee', None) + author = flask.request.args.get('author', None) + + # Hide private tickets + private = False + # If user is authenticated, show him/her his/her private tickets + if authenticated(): + private = flask.g.fas_user.username + # If user is repo admin, show all tickets included the private ones + if is_repo_admin(repo): + private = None + + if status is not None: + if status.lower() == 'closed': + issues = pagure.lib.search_issues( + SESSION, + repo, + closed=True, + tags=tags, + assignee=assignee, + author=author, + private=private, + ) + else: + issues = pagure.lib.search_issues( + SESSION, + repo, + status=status, + tags=tags, + assignee=assignee, + author=author, + private=private, + ) + else: + issues = pagure.lib.search_issues( + SESSION, repo, status='Open', tags=tags, assignee=assignee, + author=author, private=private) + + jsonout = flask.jsonify({ + 'issues': [issue.to_json(public=True) for issue in issues], + 'args': { + 'status': status, + 'tags': tags, + 'assignee': assignee, + 'author': author, + } + }) + return jsonout + + +@API.route('//issue/') +@API.route('/fork///issue/') +@api_login_optional() +@api_method +def api_view_issue(repo, issueid, username=None): + """ + Issue information + ----------------- + This endpoint can be used to retrieve information about a specific + issue/ticket + + :: + + /api/0//issue/ + + /api/0/fork///issue/ + + Accepts GET queries only. + + Sample response: + + :: + + { + "assignee": null, + "blocks": [], + "comments": [], + "content": "This issue needs attention", + "date_created": "1431414800", + "depends": [], + "id": 1, + "private": false, + "status": "Open", + "tags": [], + "title": "test issue", + "user": { + "fullname": "PY C", + "name": "pingou" + } + } + + """ + + repo = pagure.lib.get_project(SESSION, repo, user=username) + 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) + + 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_admin(repo) \ + and (not authenticated() or + not issue.user.user == flask.g.fas_user.username): + raise pagure.exceptions.APIError( + 403, error_code=APIERROR.EISSUENOTALLOWED) + + jsonout = flask.jsonify(issue.to_json(public=True)) + return jsonout + + +@API.route('//issue//status', methods=['POST']) +@API.route('/fork////status', methods=['POST']) +@api_login_required(acls=['issue_change_status']) +@api_method +def api_change_status_issue(repo, issueid, username=None): + """ + Change issue status + ------------------- + This endpoint can be used to change the status of an issue + + :: + + /api/0//issue//status + + /api/0/fork///issue//status + + Accepts POST queries only. + + :arg status: The new status of the specified issue + + Sample response: + + :: + + { + "message": "Edited successfully issue #1" + } + + """ + repo = pagure.lib.get_project(SESSION, repo, user=username) + 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 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_admin(repo) \ + and (not authenticated() or + not issue.user.user == flask.g.fas_user.username): + raise pagure.exceptions.APIError( + 403, error_code=APIERROR.EISSUENOTALLOWED) + + status = pagure.lib.get_issue_statuses(SESSION) + form = pagure.forms.StatusForm(status=status, csrf_enabled=False) + if form.validate_on_submit(): + new_status = form.status.data + try: + # Update status + message = pagure.lib.edit_issue( + SESSION, + issue=issue, + status=new_status, + user=flask.g.fas_user.username, + ticketfolder=APP.config['TICKETS_FOLDER'], + ) + SESSION.commit() + if message: + output['message'] = message + else: + output['message'] = 'No changes' + except pagure.exceptions.PagureException, err: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.ENOCODE, error=str(err)) + except SQLAlchemyError, 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) + + jsonout = flask.jsonify(output) + return jsonout + + +@API.route('//issue//comment', methods=['POST']) +@API.route('/fork////comment', methods=['POST']) +@api_login_required(acls=['issue_comment']) +@api_method +def api_comment_issue(repo, issueid, username=None): + """ + Comment to an issue + ------------------- + This endpoint can be used to add a comment to an issue + + :: + + /api/0//issue//comment + + /api/0/fork///issue//comment + + Accepts POST queries only. + + :arg comment: The comment to add to the specified issue + + Sample response: + + :: + + { + "message": "Comment added" + } + + """ + repo = pagure.lib.get_project(SESSION, repo, user=username) + 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 repo.fullname != flask.g.token.project.fullname: + 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_admin(repo) \ + and (not authenticated() or + not issue.user.user == flask.g.fas_user.username): + raise pagure.exceptions.APIError( + 403, error_code=APIERROR.EISSUENOTALLOWED) + + form = pagure.forms.CommentForm(csrf_enabled=False) + if form.validate_on_submit(): + comment = form.comment.data + try: + # New comment + message = pagure.lib.add_issue_comment( + SESSION, + issue=issue, + comment=comment, + user=flask.g.fas_user.username, + ticketfolder=APP.config['TICKETS_FOLDER'], + ) + SESSION.commit() + output['message'] = message + except SQLAlchemyError, 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) + + jsonout = flask.jsonify(output) + return jsonout diff --git a/pagure/api/user.py b/pagure/api/user.py new file mode 100644 index 0000000..d7d1eba --- /dev/null +++ b/pagure/api/user.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- + +""" + (c) 2015 - Copyright Red Hat Inc + + Authors: + Pierre-Yves Chibon + +""" + +import flask + +import pagure +import pagure.exceptions +import pagure.lib +from pagure import APP, SESSION, is_repo_admin, authenticated +from pagure.api import ( + API, api_method, api_login_required, api_login_optional, APIERROR +) + + +@API.route('/user/') +@api_method +def api_view_user(username): + """ + User information + ---------------- + Use this endpoint to retrieve information about a specific user. + + :: + + /api/0/user/ + /api/0/user/ralph + + Accepts GET queries only. + + Sample response: + + :: + + { + "forks": [], + "repos": [ + { + "date_created": "1426595173", + "description": "", + "id": 5, + "name": "pagure", + "parent": null, + "settings": { + "Minimum_score_to_merge_pull-request": -1, + "Only_assignee_can_merge_pull-request": false, + "Web-hooks": null, + "issue_tracker": true, + "project_documentation": true, + "pull_requests": true + }, + "user": { + "fullname": "ralph", + "name": "ralph" + } + } + ], + "user": { + "fullname": "ralph", + "name": "ralph" + } + } + + """ + httpcode = 200 + output = {} + + user = pagure.lib.search_user(SESSION, username=username) + if not user: + raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOUSER) + + repopage = flask.request.args.get('repopage', 1) + try: + repopage = int(repopage) + except ValueError: + repopage = 1 + + forkpage = flask.request.args.get('forkpage', 1) + try: + forkpage = int(forkpage) + except ValueError: + forkpage = 1 + + limit = APP.config['ITEM_PER_PAGE'] + repo_start = limit * (repopage - 1) + fork_start = limit * (forkpage - 1) + + repos = pagure.lib.search_projects( + SESSION, + username=username, + fork=False) + + forks = pagure.lib.search_projects( + SESSION, + username=username, + fork=True) + + output['user'] = user.to_json(public=True) + output['repos'] = [repo.to_json(public=True) for repo in repos] + output['forks'] = [repo.to_json(public=True) for repo in forks] + + jsonout = flask.jsonify(output) + jsonout.status_code = httpcode + return jsonout diff --git a/pagure/default_config.py b/pagure/default_config.py index 2ffc41f..e1ca12e 100644 --- a/pagure/default_config.py +++ b/pagure/default_config.py @@ -140,3 +140,12 @@ APPLICATION_ROOT = '/' # List of blacklisted project names BLACKLISTED_PROJECTS = ['static', 'pv'] + +ACLS = { + 'issue_create': 'Create a new ticket against this project', + 'issue_change_status': 'Change the status of a ticket of this project', + 'issue_comment': 'Comment on a ticket of this project', + 'pull_request_merge': 'Merge a pull-request of this project', + 'pull_request_close': 'Close a pull-request of this project', + 'pull_request_comment': 'Comment on a pull-request of this project', +} diff --git a/pagure/exceptions.py b/pagure/exceptions.py index 4114069..3b95fdc 100644 --- a/pagure/exceptions.py +++ b/pagure/exceptions.py @@ -28,3 +28,12 @@ class FileNotFoundException(PagureException): exists. ''' pass + + +class APIError(PagureException): + ''' Exception raised by the API when something goes wrong. ''' + + def __init__(self, status_code, error_code, error=None): + self.status_code = status_code + self.error_code = error_code + self.error = error diff --git a/pagure/forms.py b/pagure/forms.py index ae43bc5..587668d 100644 --- a/pagure/forms.py +++ b/pagure/forms.py @@ -72,6 +72,46 @@ class AddIssueTagForm(wtf.Form): ) +class StatusForm(wtf.Form): + ''' Form to add/change the status of an issue. ''' + status = wtforms.SelectField( + 'Status', + [wtforms.validators.Required()], + choices=[(item, item) for item in []] + ) + + 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(StatusForm, self).__init__(*args, **kwargs) + if 'status' in kwargs: + self.status.choices = [ + (status, status) for status in kwargs['status'] + ] + + +class NewTokenForm(wtf.Form): + ''' Form to add/change the status of an issue. ''' + acls = wtforms.SelectMultipleField( + 'ACLs', + [wtforms.validators.Required()], + choices=[(item, item) for item in []] + ) + + 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(NewTokenForm, self).__init__(*args, **kwargs) + if 'acls' in kwargs: + self.acls.choices = [ + (acl.name, acl.name) for acl in kwargs['acls'] + ] + + class UpdateIssueForm(wtf.Form): ''' Form to add a comment to an issue. ''' tag = wtforms.TextField( diff --git a/pagure/internal/__init__.py b/pagure/internal/__init__.py index cb7cb23..003d58a 100644 --- a/pagure/internal/__init__.py +++ b/pagure/internal/__init__.py @@ -152,7 +152,6 @@ def ticket_add_comment(): return flask.jsonify({'message': message}) - @PV.route('/pull-request/merge', methods=['POST']) def mergeable_request_pull(): """ Returns if the specified pull-request can be merged or not. @@ -243,7 +242,8 @@ def mergeable_request_pull(): return flask.jsonify({ 'code': 'CONFLICTS', 'short_code': 'Conflicts', - 'message': 'The pull-request cannot be merged due to conflicts'}) + 'message': 'The pull-request cannot be merged due to ' + 'conflicts'}) shutil.rmtree(newpath) return flask.jsonify({ diff --git a/pagure/lib/__init__.py b/pagure/lib/__init__.py index b63a71f..b9f17c9 100644 --- a/pagure/lib/__init__.py +++ b/pagure/lib/__init__.py @@ -1897,7 +1897,7 @@ def add_user_to_group(session, username, group, user, is_admin): raise pagure.exceptions.PagureException( 'No user `%s` found' % action_user) - if not group.group_name in user.groups and not is_admin\ + if group.group_name not in user.groups and not is_admin\ and user.username != group.creator.username: raise pagure.exceptions.PagureException( 'You are not allowed to add user to this group') @@ -1937,7 +1937,7 @@ def delete_user_of_group(session, username, groupname, user, is_admin): raise pagure.exceptions.PagureException( 'Could not find user %s' % action_user) - if not group_obj.group_name in user.groups and not is_admin: + if group_obj.group_name not in user.groups and not is_admin: raise pagure.exceptions.PagureException( 'You are not allowed to remove user from this group') @@ -1949,7 +1949,7 @@ def delete_user_of_group(session, username, groupname, user, is_admin): if not user_grp: raise pagure.exceptions.PagureException( 'User `%s` could not be found in the group `%s`' % ( - username, groupname)) + username, groupname)) session.delete(user_grp) session.flush() @@ -2024,3 +2024,62 @@ def is_group_member(session, user, groupname): return False return groupname in user.groups + + +def get_api_token(session, token_str): + """ Return the Token object corresponding to the provided token string + if there is any, returns None otherwise. + """ + query = session.query( + model.Token + ).filter( + model.Token.id == token_str + ) + + return query.first() + + +def get_acls(session): + """ Returns all the possible ACLs a token can have according to the + database. + """ + query = session.query( + model.ACL + ).order_by( + model.ACL.name + ) + + return query.all() + + +def add_token_to_user(session, project, acls, username): + """ Create a new token for the specified user on the specified project + with the given ACLs. + """ + acls_obj = session.query( + model.ACL + ).filter( + model.ACL.name.in_(acls) + ).all() + + user = search_user(session, username=username) + + token = pagure.lib.model.Token( + id=pagure.lib.login.id_generator(64), + user_id=user.id, + project_id=project.id, + expiration=datetime.datetime.utcnow() + datetime.timedelta(days=60) + ) + session.add(token) + session.flush() + + for acl in acls_obj: + item = pagure.lib.model.TokenAcl( + token_id=token.id, + acl_id=acl.id, + ) + session.add(item) + + session.commit() + + return 'Token created' diff --git a/pagure/lib/git.py b/pagure/lib/git.py index 313a1c2..ad50596 100644 --- a/pagure/lib/git.py +++ b/pagure/lib/git.py @@ -107,7 +107,6 @@ def write_gitolite_acls(session, configfile): config.append(' RW+ = %s' % user.user) config.append('') - with open(configfile, 'w') as stream: for key, users in groups.iteritems(): stream.write('@%s = %s\n' % (key, ' '.join(users))) @@ -732,3 +731,108 @@ def get_username(abspath): if username.startswith('/'): username = username[1:] return username + + +def merge_pull_request(session, repo, request, username, request_folder): + ''' Merge the specified pull-request. + ''' + # Get the fork + repopath = pagure.get_repo_path(request.project_from) + fork_obj = pygit2.Repository(repopath) + + # Get the original repo + parentpath = pagure.get_repo_path(request.project) + + # Clone the original repo into a temp folder + newpath = tempfile.mkdtemp(prefix='pagure-pr-merge') + new_repo = pygit2.clone_repository(parentpath, newpath) + + repo_commit = fork_obj[ + fork_obj.lookup_branch(request.branch_from).get_object().hex] + + ori_remote = new_repo.remotes[0] + # Add the fork as remote repo + reponame = '%s_%s' % (request.user.user, repo.name) + remote = new_repo.create_remote(reponame, repopath) + + # Fetch the commits + remote.fetch() + + merge = new_repo.merge(repo_commit.oid) + if merge is None: + mergecode = new_repo.merge_analysis(repo_commit.oid)[0] + + try: + branch_ref = new_repo.lookup_reference( + request.branch).resolve() + except ValueError: + branch_ref = new_repo.lookup_reference( + 'refs/heads/%s' % request.branch).resolve() + + refname = '%s:%s' % (branch_ref.name, branch_ref.name) + if ( + (merge is not None and merge.is_uptodate) + or + (merge is None and + mergecode & pygit2.GIT_MERGE_ANALYSIS_UP_TO_DATE)): + pagure.lib.close_pull_request( + session, request, username, + requestfolder=request_folder) + try: + session.commit() + except SQLAlchemyError as err: # pragma: no cover + session.rollback() + APP.logger.exception(err) + shutil.rmtree(newpath) + raise pagure.exceptions.PagureException( + 'Could not close this pull-request') + raise pagure.exceptions.PagureException( + 'Nothing to do, changes were already merged') + + elif ( + (merge is not None and merge.is_fastforward) + or + (merge is None and + mergecode & pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD)): + if merge is not None: + # This is depending on the pygit2 version + branch_ref.target = merge.fastforward_oid + elif merge is None and mergecode is not None: + branch_ref.set_target(repo_commit.oid.hex) + + ori_remote.push(refname) + + else: + tree = None + try: + tree = new_repo.index.write_tree() + except pygit2.GitError: + shutil.rmtree(newpath) + raise pagure.exceptions.PagureException('Merge conflicts!') + + head = new_repo.lookup_reference('HEAD').get_object() + new_repo.create_commit( + 'refs/heads/master', + repo_commit.author, + repo_commit.committer, + 'Merge #%s `%s`' % (request.id, request.title), + tree, + [head.hex, repo_commit.oid.hex]) + ori_remote.push(refname) + + # Update status + pagure.lib.close_pull_request( + session, request, username, + requestfolder=request_folder, + ) + try: + session.commit() + except SQLAlchemyError as err: # pragma: no cover + session.rollback() + APP.logger.exception(err) + shutil.rmtree(newpath) + raise pagure.exceptions.PagureException( + 'Could not update this pull-request in the database') + shutil.rmtree(newpath) + + return 'Changes merged!' diff --git a/pagure/lib/model.py b/pagure/lib/model.py index 0ad6d51..db434fc 100644 --- a/pagure/lib/model.py +++ b/pagure/lib/model.py @@ -31,7 +31,7 @@ ERROR_LOG = logging.getLogger('pagure.model') # pylint: disable=C0103,R0903,W0232,E1101 -def create_tables(db_url, alembic_ini=None, debug=False): +def create_tables(db_url, alembic_ini=None, acls=None, debug=False): """ Create the tables in the database using the information from the url obtained. @@ -72,11 +72,11 @@ def create_tables(db_url, alembic_ini=None, debug=False): scopedsession = scoped_session(sessionmaker(bind=engine)) # Insert the default data into the db - create_default_status(scopedsession) + create_default_status(scopedsession, acls=acls) return scopedsession -def create_default_status(session): +def create_default_status(session, acls=None): """ Insert the defaults status in the status tables. """ @@ -98,6 +98,18 @@ def create_default_status(session): session.rollback() ERROR_LOG.debug('Type %s could not be added', grptype) + for acl in acls or {}: + item = ACL( + name=acl, + description=acls[acl] + ) + session.add(item) + try: + session.commit() + except SQLAlchemyError: # pragma: no cover + session.rollback() + ERROR_LOG.debug('ACL %s could not be added', acl) + class StatusIssue(BASE): """ Stores the status a ticket can have. @@ -160,14 +172,16 @@ class User(BASE): return 'User: %s - name %s' % (self.id, self.user) - def to_json(self): + def to_json(self, public=False): ''' Return a representation of the User in a dictionnary. ''' output = { 'name': self.user, 'fullname': self.fullname, - 'default_email': self.default_email, - 'emails': [email.email for email in self.emails], } + if not public: + output['default_email'] = self.default_email + output['emails'] = [email.email for email in self.emails] + return output @@ -323,7 +337,7 @@ class Project(BASE): ''' Ensures the settings are properly saved. ''' self._settings = json.dumps(settings) - def to_json(self): + def to_json(self, public=False): ''' Return a representation of the project as JSON. ''' @@ -334,11 +348,7 @@ class Project(BASE): 'parent': self.parent.to_json() if self.parent else None, 'settings': self.settings, 'date_created': self.date_created.strftime('%s'), - 'user': { - 'name': self.user.user, - 'fullname': self.user.fullname, - 'emails': [email.email for email in self.user.emails], - }, + 'user': self.user.to_json(public=public), } return output @@ -454,7 +464,7 @@ class Issue(BASE): ''' Return the list of issue this issue blocks on in simple text. ''' return [issue.id for issue in self.parents] - def to_json(self): + def to_json(self, public=False): ''' Returns a dictionary representation of the issue. ''' @@ -464,12 +474,13 @@ class Issue(BASE): 'content': self.content, 'status': self.status, 'date_created': self.date_created.strftime('%s'), - 'user': self.user.to_json(), + 'user': self.user.to_json(public=public), 'private': self.private, 'tags': self.tags_text, 'depends': [str(item) for item in self.depends_text], 'blocks': [str(item) for item in self.blocks_text], - 'assignee': self.assignee.to_json() if self.assignee else None, + 'assignee': self.assignee.to_json(public=public) + if self.assignee else None, } comments = [] @@ -479,7 +490,7 @@ class Issue(BASE): 'comment': comment.comment, 'parent': comment.parent_id, 'date_created': comment.date_created.strftime('%s'), - 'user': comment.user.to_json(), + 'user': comment.user.to_json(public=public), } comments.append(cmt) @@ -712,7 +723,7 @@ class PullRequest(BASE): return len(positive) - len(negative) - def to_json(self): + def to_json(self, public=False): ''' Returns a dictionnary representation of the pull-request. ''' @@ -721,11 +732,11 @@ class PullRequest(BASE): 'uid': self.uid, 'title': self.title, 'branch': self.branch, - 'project': self.project.to_json(), + 'project': self.project.to_json(public=public), 'branch_from': self.branch_from, - 'repo_from': self.project_from.to_json(), + 'repo_from': self.project_from.to_json(public=public), 'date_created': self.date_created.strftime('%s'), - 'user': self.user.to_json(), + 'user': self.user.to_json(public=public), 'assignee': self.assignee.to_json() if self.assignee else None, 'status': self.status, 'commit_start': self.commit_start, @@ -742,7 +753,7 @@ class PullRequest(BASE): 'comment': comment.comment, 'parent': comment.parent_id, 'date_created': comment.date_created.strftime('%s'), - 'user': comment.user.to_json(), + 'user': comment.user.to_json(public=public), } comments.append(cmt) @@ -893,6 +904,116 @@ class ProjectGroup(BASE): 'project_id', 'group_id'), ) +# +# Class and tables specific for the API/token access +# + + +class ACL(BASE): + """ + Table listing all the rights a token can be given + """ + + __tablename__ = 'acls' + + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.String(32), unique=True, nullable=False) + description = sa.Column(sa.Text(), nullable=False) + created = sa.Column( + sa.DateTime, nullable=False, default=datetime.datetime.utcnow) + + def __repr__(self): + ''' Return a string representation of this object. ''' + + return 'ACL: %s - name %s' % (self.id, self.name) + + +class Token(BASE): + """ + Table listing all the tokens per user and per project + """ + + __tablename__ = 'tokens' + + id = sa.Column(sa.String(64), primary_key=True) + user_id = sa.Column( + sa.Integer, + sa.ForeignKey('users.id', onupdate='CASCADE'), + nullable=False, + index=True) + project_id = sa.Column( + sa.Integer, + sa.ForeignKey('projects.id', onupdate='CASCADE'), + nullable=False, + index=True) + expiration = sa.Column( + sa.DateTime, nullable=False, default=datetime.datetime.utcnow) + created = sa.Column( + sa.DateTime, nullable=False, default=datetime.datetime.utcnow) + + acls = relation( + "ACL", + secondary="tokens_acls", + primaryjoin="tokens.c.id==tokens_acls.c.token_id", + secondaryjoin="acls.c.id==tokens_acls.c.acl_id", + ) + + user = relation( + 'User', + backref=backref( + 'tokens', cascade="delete, delete-orphan", + order_by="Token.created" + ), + foreign_keys=[user_id], + remote_side=[User.id]) + + project = relation( + 'Project', + backref=backref( + 'tokens', cascade="delete, delete-orphan", + ), + foreign_keys=[project_id], + remote_side=[Project.id]) + + def __repr__(self): + ''' Return a string representation of this object. ''' + + return 'Token: %s - name %s' % (self.id, self.expiration) + + @property + def expired(self): + ''' Returns wether a token has expired or not. ''' + if datetime.datetime.utcnow().date() >= self.expiration.date(): + return True + else: + return False + + @property + def acls_list(self): + ''' Return a list containing the name of each ACLs this token has. + ''' + return sorted([str(acl.name) for acl in self.acls]) + + +class TokenAcl(BASE): + """ + Association table linking the tokens table to the acls table. + This allow linking token to acl. + """ + + __tablename__ = 'tokens_acls' + + token_id = sa.Column( + sa.String(64), sa.ForeignKey('tokens.id'), primary_key=True) + acl_id = sa.Column( + sa.Integer, sa.ForeignKey('acls.id'), primary_key=True) + + # Constraints + __table_args__ = ( + sa.UniqueConstraint( + 'token_id', 'acl_id'), + ) + # ########################################################## # These classes are only used if you're using the `local` diff --git a/pagure/lib/notify.py b/pagure/lib/notify.py index beb7485..4afea3c 100644 --- a/pagure/lib/notify.py +++ b/pagure/lib/notify.py @@ -77,7 +77,11 @@ def log(project, topic, msg): for url in project.settings.get('Web-hooks').split('\n'): url = url.strip() try: - req = requests.post(url, headers=headers, data={'payload': msg}) + req = requests.post( + url, + headers=headers, + data={'payload': msg} + ) if not req: raise pagure.exceptions.PagureException( 'An error occured while querying: %s - ' diff --git a/pagure/static/pagure.css b/pagure/static/pagure.css index ac554e2..0470a44 100644 --- a/pagure/static/pagure.css +++ b/pagure/static/pagure.css @@ -730,7 +730,7 @@ header.repo.forked > p { display: inline-block; } -#fork_project { +#fork_project, .clickable { cursor: pointer; } diff --git a/pagure/templates/add_token.html b/pagure/templates/add_token.html new file mode 100644 index 0000000..6c397a0 --- /dev/null +++ b/pagure/templates/add_token.html @@ -0,0 +1,36 @@ +{% extends "master.html" %} +{% from "_formhelper.html" import render_field_in_row %} + +{% block title %}Create token{% endblock %} +{%block tag %}home{% endblock %} + + +{% block content %} + +

Create a new token

+ +
+
+ + + {% for acl in acls %} + + + + + {% endfor %} +
+ + {{ acl.description }}
+

+ + + {{ form.csrf_token }} +

+
+
+ +{% endblock %} diff --git a/pagure/templates/settings.html b/pagure/templates/settings.html index 74413c0..7dad6a7 100644 --- a/pagure/templates/settings.html +++ b/pagure/templates/settings.html @@ -54,6 +54,71 @@ +
+

API key

+

+ API keys are tokens used to authenticate you on pagure. They can also + be used to grant access to 3rd party application to behave on this + project on your name. +

+

+ These keys are valid for 60 days. +

+

+ These keys are private to your project, make sure to store in a safe + place and do not share it. +

+ + {% if repo.tokens %} + + {% for token in repo.tokens %} + {% if token.user.username == g.fas_user.username %} + + + + + + + {% endif %} + {% endfor %} +
+ {{ token.id }} + + {% if token.expired %} + Expired since {{ token.expiration.date() }} + {% else %} + Valid until: {{ token.expiration.date() }} + {% endif %} + + ACLs + +
+ + {{ form.csrf_token }} +
+
+ {% endif %} + + + + +
+

Project's options

{{ plugin }}
{% endfor %} - {% endif %} @@ -230,6 +294,25 @@ {% block jscripts %} {{ super() }}