diff --git a/alembic/versions/e34e799e4182_add_reactions_to_issue_comments.py b/alembic/versions/e34e799e4182_add_reactions_to_issue_comments.py
new file mode 100644
index 0000000..d4d2247
--- /dev/null
+++ b/alembic/versions/e34e799e4182_add_reactions_to_issue_comments.py
@@ -0,0 +1,29 @@
+"""add_reactions_to_comments
+
+Revision ID: e34e799e4182
+Revises: 131ad2dc5bbd
+Create Date: 2018-05-17 18:44:32.189208
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'e34e799e4182'
+down_revision = '131ad2dc5bbd'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ ''' Add the _reactions column to table issue_comments.
+ '''
+ op.add_column(
+ 'issue_comments',
+ sa.Column('_reactions', sa.Text, nullable=True)
+ )
+
+
+def downgrade():
+ ''' Remove the column _reactions from table issue_comments.
+ '''
+ op.drop_column('issue_comments', '_reactions')
diff --git a/alembic/versions/ef98dcb838e4_add_reactions_to_request_comments_py.py b/alembic/versions/ef98dcb838e4_add_reactions_to_request_comments_py.py
new file mode 100644
index 0000000..d99e8cb
--- /dev/null
+++ b/alembic/versions/ef98dcb838e4_add_reactions_to_request_comments_py.py
@@ -0,0 +1,29 @@
+"""add_reactions_to_request_comments.py
+
+Revision ID: ef98dcb838e4
+Revises: e34e799e4182
+Create Date: 2018-06-02 19:47:52.975503
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'ef98dcb838e4'
+down_revision = 'e34e799e4182'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ ''' Add the _reactions column to table pull_request_comments.
+ '''
+ op.add_column(
+ 'pull_request_comments',
+ sa.Column('_reactions', sa.Text, nullable=True)
+ )
+
+
+def downgrade():
+ ''' Remove the column _reactions from table pull_request_comments.
+ '''
+ op.drop_column('pull_request_comments', '_reactions')
diff --git a/pagure/default_config.py b/pagure/default_config.py
index 24db249..8701134 100644
--- a/pagure/default_config.py
+++ b/pagure/default_config.py
@@ -422,3 +422,14 @@ EXTERNAL_COMMITTER = {}
# Allows to require that the users are members of a certain group to be added
# to a project (not a fork).
REQUIRED_GROUPS = {}
+
+# Predefined reactions. Selecting others is possible by typing their name. The
+# order here will be preserved in the web UI picker for reactions.
+REACTIONS = [
+ ("Thumbs up", "emojione-1F44D"), # Thumbs up
+ ("Thumbs down", "emojione-1F44E"), # Thumbs down
+ ("Confused", "emojione-1F615"), # Confused
+ ("Heart", "emojione-2764"), # Heart
+]
+# This is used for faster indexing. Do not change.
+_REACTIONS_DICT = dict(REACTIONS)
diff --git a/pagure/lib/model.py b/pagure/lib/model.py
index 2d35c6c..e6640e4 100644
--- a/pagure/lib/model.py
+++ b/pagure/lib/model.py
@@ -1416,6 +1416,8 @@ class IssueComment(BASE):
foreign_keys=[editor_id],
remote_side=[User.id])
+ _reactions = sa.Column(sa.Text, nullable=True)
+
@property
def mail_id(self):
''' Return a unique reprensetation of the issue as string that
@@ -1429,6 +1431,20 @@ class IssueComment(BASE):
''' Return the parent, in this case the issue object. '''
return self.issue
+ @property
+ def reactions(self):
+ ''' Return the reactions stored as a string in the database parsed as
+ an actual dict object.
+ '''
+ if self._reactions:
+ return json.loads(self._reactions)
+ return {}
+
+ @reactions.setter
+ def reactions(self, reactions):
+ ''' Ensures that reactions are properly saved. '''
+ self._reactions = json.dumps(reactions)
+
def to_json(self, public=False):
''' Returns a dictionary representation of the issue.
@@ -1443,6 +1459,7 @@ class IssueComment(BASE):
'editor': self.editor.to_json(public=public)
if self.editor_id else None,
'notification': self.notification,
+ 'reactions': self.reactions,
}
return output
@@ -2000,6 +2017,8 @@ class PullRequestComment(BASE):
foreign_keys=[editor_id],
remote_side=[User.id])
+ _reactions = sa.Column(sa.Text, nullable=True)
+
@property
def mail_id(self):
''' Return a unique representation of the issue as string that
@@ -2013,6 +2032,20 @@ class PullRequestComment(BASE):
''' Return the parent, in this case the pull_request object. '''
return self.pull_request
+ @property
+ def reactions(self):
+ ''' Return the reactions stored as a string in the database parsed as
+ an actual dict object.
+ '''
+ if self._reactions:
+ return json.loads(self._reactions)
+ return {}
+
+ @reactions.setter
+ def reactions(self, reactions):
+ ''' Ensures that reactions are properly saved. '''
+ self._reactions = json.dumps(reactions)
+
def to_json(self, public=False):
''' Return a dict representation of the pull-request comment. '''
@@ -2030,6 +2063,7 @@ class PullRequestComment(BASE):
'editor': self.editor.to_json(public=public)
if self.editor_id else None,
'notification': self.notification,
+ 'reactions': self.reactions,
}
diff --git a/pagure/static/reactions.js b/pagure/static/reactions.js
new file mode 100644
index 0000000..5411a95
--- /dev/null
+++ b/pagure/static/reactions.js
@@ -0,0 +1,37 @@
+(function () {
+ function send_reaction(commentid, reaction, emoji) {
+ var _url = location.href + '/comment/' + commentid + '/react';
+ var csrf_token = $("#csrf_token").val();
+ $.ajax({
+ url: _url + '?js=1',
+ type: 'POST',
+ data: {
+ 'reaction': reaction,
+ 'csrf_token': csrf_token,
+ },
+ success: function () {
+ var reactions = $(".issue_reactions[data-comment-id=" + commentid + "]>.btn-group");
+ var selected = reactions.find("span[class=" + emoji + "]");
+ if (selected.length > 0) {
+ var counter = selected.next();
+ counter.text(parseInt(counter.text(), 10) + 1);
+ } else {
+ reactions.append(
+ '');
+ }
+ },
+ });
+ }
+
+ $(document).ready(function () {
+ $(".reaction-picker button").click(function () {
+ var commentid = $(this).parents('.reaction-picker').data('comment-id');
+ var reaction = $(this).attr('title');
+ var emoji = $(this).find('span').attr('class');
+ send_reaction(commentid, reaction, emoji);
+ });
+ });
+})();
diff --git a/pagure/templates/_formhelper.html b/pagure/templates/_formhelper.html
index 6b8d21d..86f08a7 100644
--- a/pagure/templates/_formhelper.html
+++ b/pagure/templates/_formhelper.html
@@ -142,9 +142,37 @@
{% if comment.edited_on %}
Edited {{ comment.edited_on | humanize }} by {{ comment.editor.username }}
{% endif %}
+
+
+ {% for r in comment.reactions | sort %}
+
+ {% endfor %}
+
+
{% if id != 0 and g.fas_user %}
+ {% if config.get('REACTIONS') %}
+
+
+
+
+ {% endif %}
diff --git a/pagure/templates/issue.html b/pagure/templates/issue.html
index d0dec58..190efc6 100644
--- a/pagure/templates/issue.html
+++ b/pagure/templates/issue.html
@@ -1046,5 +1046,6 @@ $( document ).ready(function() {
{% if repo.quick_replies %}
{% endif %}
+
{% endblock %}
diff --git a/pagure/templates/pull_request.html b/pagure/templates/pull_request.html
index 00cf0a0..69de47c 100644
--- a/pagure/templates/pull_request.html
+++ b/pagure/templates/pull_request.html
@@ -1729,5 +1729,6 @@ sse = false;
{% if repo.quick_replies %}
{% endif %}
+
{% endblock %}
diff --git a/pagure/ui/filters.py b/pagure/ui/filters.py
index e725fa1..7d25b3d 100644
--- a/pagure/ui/filters.py
+++ b/pagure/ui/filters.py
@@ -682,3 +682,15 @@ def flag_to_label(flag):
""" For a given flag return the bootstrap label to use
"""
return pagure_config['FLAG_STATUSES_LABELS'][flag.status.lower()]
+
+
+@UI_NS.app_template_filter('join_prefix')
+def join_prefix(values, num):
+ """Produce a string joining first `num` items in the list and indicate
+ total number total number of items.
+ """
+ if len(values) <= 1:
+ return "".join(values)
+ if len(values) <= num:
+ return ", ".join(values[:-1]) + " and " + values[-1]
+ return "%s and %d others" % (", ".join(values[:num]), len(values) - num)
diff --git a/pagure/ui/fork.py b/pagure/ui/fork.py
index 38ab14e..13e804a 100644
--- a/pagure/ui/fork.py
+++ b/pagure/ui/fork.py
@@ -1556,3 +1556,57 @@ def fork_edit_file(
return flask.redirect(flask.url_for(
'ui_ns.view_repo', repo=repo.name, username=username,
namespace=namespace))
+
+
+@UI_NS.route(
+ '//pull-request//comment//react', methods=['POST'])
+@UI_NS.route(
+ '///pull-request//comment//react',
+ methods=['POST'])
+@UI_NS.route(
+ '/fork///pull-request//comment//react',
+ methods=['POST'])
+@UI_NS.route(
+ '/fork////pull-request//comment//react',
+ methods=['POST'])
+@login_required
+def pull_request_comment_add_reaction(repo, requestid, commentid,
+ username=None, namespace=None):
+ repo = flask.g.repo
+
+ form = pagure.forms.ConfirmationForm()
+ if not form.validate_on_submit():
+ flask.abort(400, 'CSRF token not valid')
+
+ request = pagure.lib.search_pull_requests(
+ flask.g.session, requestid=requestid, project_id=repo.id
+ )
+
+ if not request:
+ flask.abort(404, 'Comment not found')
+
+ comment = pagure.lib.get_request_comment(
+ flask.g.session, request.uid, commentid)
+
+ if 'reaction' not in flask.request.form:
+ flask.abort(400, 'Reaction not found')
+
+ reactions = comment.reactions
+ r = flask.request.form['reaction']
+ if not r:
+ flask.abort(400, 'Empty reaction is not acceptable')
+ if flask.g.fas_user.username in reactions.get(r, []):
+ flask.abort(409, 'Already posted this one')
+
+ reactions.setdefault(r, []).append(flask.g.fas_user.username)
+ comment.reactions = reactions
+ flask.g.session.add(comment)
+
+ try:
+ flask.g.session.commit()
+ except SQLAlchemyError as err: # pragma: no cover
+ flask.g.session.rollback()
+ _log.error(err)
+ return 'error'
+
+ return 'ok'
diff --git a/pagure/ui/issues.py b/pagure/ui/issues.py
index d440768..a46b60b 100644
--- a/pagure/ui/issues.py
+++ b/pagure/ui/issues.py
@@ -365,6 +365,79 @@ def update_issue(repo, issueid, username=None, namespace=None):
)
+@UI_NS.route(
+ '//issue//comment//react/',
+ methods=['POST'])
+@UI_NS.route(
+ '//issue//comment//react',
+ methods=['POST'])
+@UI_NS.route(
+ '///issue//comment//react/',
+ methods=['POST'])
+@UI_NS.route(
+ '///issue//comment//react',
+ methods=['POST'])
+@UI_NS.route(
+ '/fork///issue//comment//react/',
+ methods=['POST'])
+@UI_NS.route(
+ '/fork///issue//comment//react',
+ methods=['POST'])
+@UI_NS.route(
+ '/fork////issue//comment//react/',
+ methods=['POST'])
+@UI_NS.route(
+ '/fork////issue//comment//react',
+ methods=['POST'])
+@login_required
+@has_issue_tracker
+def issue_comment_add_reaction(repo, issueid, commentid, username=None,
+ namespace=None):
+ '''Add a reaction to a comment of an issue'''
+ repo = flask.g.repo
+
+ issue = pagure.lib.search_issues(flask.g.session, repo, issueid=issueid)
+
+ if not issue or issue.project != repo:
+ flask.abort(404, 'Comment not found')
+
+ form = pagure.forms.ConfirmationForm()
+ if not form.validate_on_submit():
+ flask.abort(400, 'CSRF token not valid')
+
+ if issue.private and not flask.g.repo_committer \
+ and (not authenticated() or
+ not issue.user.user == flask.g.fas_user.username):
+ flask.abort(
+ 404, 'No such issue')
+
+ comment = pagure.lib.get_issue_comment(
+ flask.g.session, issue.uid, commentid)
+
+ if 'reaction' not in flask.request.form:
+ flask.abort(400, 'Reaction not found')
+
+ reactions = comment.reactions
+ r = flask.request.form['reaction']
+ if not r:
+ flask.abort(400, 'Empty reaction is not acceptable')
+ if flask.g.fas_user.username in reactions.get(r, []):
+ flask.abort(409, 'Already posted this one')
+
+ reactions.setdefault(r, []).append(flask.g.fas_user.username)
+ comment.reactions = reactions
+ flask.g.session.add(comment)
+
+ try:
+ flask.g.session.commit()
+ except SQLAlchemyError as err: # pragma: no cover
+ flask.g.session.rollback()
+ _log.error(err)
+ return 'error'
+
+ return 'ok'
+
+
@UI_NS.route('//tag//edit/', methods=('GET', 'POST'))
@UI_NS.route('//tag//edit', methods=('GET', 'POST'))
@UI_NS.route('///tag//edit/', methods=('GET', 'POST'))
diff --git a/tests/test_pagure_flask_api_issue.py b/tests/test_pagure_flask_api_issue.py
index c38b0ae..3f6f685 100644
--- a/tests/test_pagure_flask_api_issue.py
+++ b/tests/test_pagure_flask_api_issue.py
@@ -2231,6 +2231,7 @@ class PagureFlaskApiIssuetests(tests.SimplePagureTest):
"notification": False,
"id": 1,
"parent": None,
+ "reactions": {},
"user": {
"fullname": "PY C",
"name": "pingou"
@@ -2257,6 +2258,7 @@ class PagureFlaskApiIssuetests(tests.SimplePagureTest):
"notification": False,
"id": 1,
"parent": None,
+ "reactions": {},
"user": {
"fullname": "PY C",
"name": "pingou"
@@ -2351,6 +2353,7 @@ class PagureFlaskApiIssuetests(tests.SimplePagureTest):
"notification": False,
"id": 1,
"parent": None,
+ "reactions": {},
"user": {
"fullname": "foo bar",
"name": "foo"
diff --git a/tests/test_pagure_flask_api_ui_private_repo.py b/tests/test_pagure_flask_api_ui_private_repo.py
index 4d7ee7c..d95d351 100644
--- a/tests/test_pagure_flask_api_ui_private_repo.py
+++ b/tests/test_pagure_flask_api_ui_private_repo.py
@@ -3093,6 +3093,7 @@ class PagurePrivateRepotest(tests.Modeltests):
"editor": None,
"id": 1,
"parent": None,
+ "reactions": {},
"user": {
"fullname": "PY C",
"name": "pingou"
@@ -3119,6 +3120,7 @@ class PagurePrivateRepotest(tests.Modeltests):
"editor": None,
"id": 1,
"parent": None,
+ "reactions": {},
"user": {
"fullname": "PY C",
"name": "pingou"
diff --git a/tests/test_pagure_flask_ui_fork.py b/tests/test_pagure_flask_ui_fork.py
index bce6766..468496e 100644
--- a/tests/test_pagure_flask_ui_fork.py
+++ b/tests/test_pagure_flask_ui_fork.py
@@ -3172,5 +3172,92 @@ index 0000000..2a552bb
shutil.rmtree(newpath)
+ def _set_up_for_reaction_test(self):
+ self.session.add(pagure.lib.model.User(
+ user='jdoe',
+ fullname='John Doe',
+ password=b'password',
+ default_email='jdoe@example.com',
+ ))
+ self.session.commit()
+ tests.create_projects(self.session)
+ tests.create_projects_git(
+ os.path.join(self.path, 'requests'), bare=True)
+ self.set_up_git_repo(new_project=None, branch_from='feature')
+ pagure.lib.get_authorized_project(self.session, 'test')
+ request = pagure.lib.search_pull_requests(
+ self.session, requestid=1, project_id=1,
+ )
+ pagure.lib.add_pull_request_comment(
+ self.session,
+ request=request,
+ commit=None,
+ tree_id=None,
+ filename=None,
+ row=None,
+ comment='Hello',
+ user='jdoe',
+ requestfolder=None,
+ )
+ self.session.commit()
+
+ @patch('pagure.lib.notify.send_email')
+ def test_add_reaction(self, send_email):
+ """ Test the request_pull endpoint. """
+ send_email.return_value = True
+
+ self._set_up_for_reaction_test()
+
+ user = tests.FakeUser()
+ user.username = 'pingou'
+ with tests.user_set(self.app.application, user):
+ output = self.app.get('/test/pull-request/1')
+ self.assertEqual(output.status_code, 200)
+
+ data = {
+ 'csrf_token': self.get_csrf(output=output),
+ 'reaction': 'Thumbs up',
+ }
+
+ output = self.app.post(
+ '/test/pull-request/1/comment/1/react',
+ data=data,
+ follow_redirects=True,
+ )
+ self.assertEqual(output.status_code, 200)
+
+ # Load the page and check reaction is added.
+ output = self.app.get('/test/pull-request/1')
+ self.assertEqual(output.status_code, 200)
+ self.assertIn(
+ 'Thumbs up sent by pingou',
+ output.get_data(as_text=True)
+ )
+
+ @patch('pagure.lib.notify.send_email')
+ def test_add_reaction_unauthenticated(self, send_email):
+ """ Test the request_pull endpoint. """
+ send_email.return_value = True
+
+ self._set_up_for_reaction_test()
+
+ output = self.app.get('/test/pull-request/1')
+ self.assertEqual(output.status_code, 200)
+
+ data = {
+ 'csrf_token': self.get_csrf(output=output),
+ 'reaction': 'Thumbs down',
+ }
+
+ output = self.app.post(
+ '/test/pull-request/1/comment/1/react',
+ data=data,
+ follow_redirects=False,
+ )
+ # Redirect to login page
+ self.assertEqual(output.status_code, 302)
+ self.assertIn('/login/', output.headers['Location'])
+
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/tests/test_pagure_flask_ui_issues.py b/tests/test_pagure_flask_ui_issues.py
index 6622c5e..c07590c 100644
--- a/tests/test_pagure_flask_ui_issues.py
+++ b/tests/test_pagure_flask_ui_issues.py
@@ -3807,6 +3807,126 @@ class PagureFlaskIssuestests(tests.Modeltests):
'title="Reply to this comment - lose formatting">',
), 1)
+ def _set_up_for_reaction_test(self, private=False):
+ tests.create_projects(self.session)
+ tests.create_projects_git(os.path.join(self.path, 'repos'), bare=True)
+
+ self.session.add(pagure.lib.model.User(
+ user='naysayer',
+ fullname='John Doe',
+ password=b'password',
+ default_email='jdoe@example.com',
+ ))
+ self.session.commit()
+ repo = pagure.lib.get_authorized_project(self.session, 'test')
+ msg = pagure.lib.new_issue(
+ session=self.session,
+ repo=repo,
+ title='Test issue',
+ content='Fix me',
+ user='pingou',
+ ticketfolder=None,
+ private=private,
+ )
+ pagure.lib.add_issue_comment(
+ session=self.session,
+ issue=msg,
+ comment='How about no',
+ user='naysayer',
+ ticketfolder=None,
+ )
+ self.session.commit()
+
+ @patch('pagure.lib.git.update_git')
+ @patch('pagure.lib.notify.send_email')
+ def test_add_reaction(self, p_send_email, p_ugt):
+ ''' Test adding a reaction to an issue comment.'''
+ p_send_email.return_value = True
+ p_ugt.return_value = True
+
+ self._set_up_for_reaction_test()
+
+ user = tests.FakeUser()
+ user.username = 'pingou'
+ with tests.user_set(self.app.application, user):
+ output = self.app.get('/test/issue/1')
+ self.assertEqual(output.status_code, 200)
+
+ data = {
+ 'csrf_token': self.get_csrf(output=output),
+ 'reaction': 'Thumbs down',
+ }
+
+ output = self.app.post(
+ '/test/issue/1/comment/1/react',
+ data=data,
+ follow_redirects=True,
+ )
+ self.assertEqual(output.status_code, 200)
+
+ # Load the page and check reaction is added.
+ output = self.app.get('/test/issue/1')
+ self.assertEqual(output.status_code, 200)
+ self.assertIn(
+ 'Thumbs down sent by pingou',
+ output.get_data(as_text=True)
+ )
+
+ @patch('pagure.lib.git.update_git')
+ @patch('pagure.lib.notify.send_email')
+ def test_add_reaction_unauthenticated(self, p_send_email, p_ugt):
+ '''
+ Test adding a reaction to an issue comment without authentication.
+ '''
+ p_send_email.return_value = True
+ p_ugt.return_value = True
+
+ self._set_up_for_reaction_test()
+
+ output = self.app.get('/test/issue/1')
+ self.assertEqual(output.status_code, 200)
+
+ data = {
+ 'csrf_token': self.get_csrf(output=output),
+ 'reaction': 'Thumbs down',
+ }
+
+ output = self.app.post(
+ '/test/issue/1/comment/1/react',
+ data=data,
+ follow_redirects=False,
+ )
+ # Redirect to login page
+ self.assertEqual(output.status_code, 302)
+ self.assertIn('/login/', output.headers['Location'])
+
+ @patch('pagure.lib.git.update_git')
+ @patch('pagure.lib.notify.send_email')
+ def test_add_reaction_private_issue(self, p_send_email, p_ugt):
+ '''Test adding a reaction to a private issue comment.'''
+ p_send_email.return_value = True
+ p_ugt.return_value = True
+
+ self._set_up_for_reaction_test(private=True)
+
+ user = tests.FakeUser()
+ user.username = 'naysayer'
+ with tests.user_set(self.app.application, user):
+ # Steal CSRF token from new issue page
+ output = self.app.get('/test/new_issue')
+
+ data = {
+ 'csrf_token': self.get_csrf(output=output),
+ 'reaction': 'Thumbs down',
+ }
+
+ output = self.app.post(
+ '/test/issue/1/comment/1/react',
+ data=data,
+ follow_redirects=True,
+ )
+ self.assertEqual(output.status_code, 404)
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/tests/test_pagure_lib_git.py b/tests/test_pagure_lib_git.py
index 82dffd0..09efc57 100644
--- a/tests/test_pagure_lib_git.py
+++ b/tests/test_pagure_lib_git.py
@@ -1687,7 +1687,7 @@ diff --git a/123 b/456
index 458821a..77674a8
--- a/123
+++ b/456
-@@ -3,13 +3,31 @@
+@@ -3,13 +3,32 @@
"blocks": [],
"close_status": null,
"closed_at": null,
@@ -1701,6 +1701,7 @@ index 458821a..77674a8
+ "id": 1,
+ "notification": false,
+ "parent": null,
++ "reactions": {},
+ "user": {
+ "default_email": "foo@bar.com",
+ "emails": [