From fc06facc9d4543309b986e0d55c4fdd2999ded4c Mon Sep 17 00:00:00 2001 From: Lubomír Sedlář Date: Dec 01 2016 11:11:47 +0000 Subject: [PATCH 1/5] Add quick_replies field to project The migration to increase group name length limit is updated be linear. --- diff --git a/alembic/versions/349a3890596_increase_length_group_name.py b/alembic/versions/349a3890596_increase_length_group_name.py index 4e84bd5..7ec1154 100644 --- a/alembic/versions/349a3890596_increase_length_group_name.py +++ b/alembic/versions/349a3890596_increase_length_group_name.py @@ -8,7 +8,7 @@ Create Date: 2016-11-30 14:30:15.681269 # revision identifiers, used by Alembic. revision = '349a3890596' -down_revision = '5083efccac7' +down_revision = '114d3a68c1fd' from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/588eabcd394c_add_quick_replies_field_to_project.py b/alembic/versions/588eabcd394c_add_quick_replies_field_to_project.py new file mode 100644 index 0000000..215b328 --- /dev/null +++ b/alembic/versions/588eabcd394c_add_quick_replies_field_to_project.py @@ -0,0 +1,29 @@ +"""add quick_replies field to project + +Revision ID: 588eabcd394c +Revises: 5083efccac7 +Create Date: 2016-11-17 16:12:36.624079 + +""" + +# revision identifiers, used by Alembic. +revision = '588eabcd394c' +down_revision = '349a3890596' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ''' Add the column _quick_replies to the table projects + ''' + op.add_column( + 'projects', + sa.Column('_quick_replies', sa.Text, nullable=True) + ) + + +def downgrade(): + ''' Drop the column _quick_replies from the table projects. + ''' + op.drop_column('projects', '_quick_replies') diff --git a/pagure/lib/model.py b/pagure/lib/model.py index 95a2843..3c79a56 100644 --- a/pagure/lib/model.py +++ b/pagure/lib/model.py @@ -344,6 +344,7 @@ class Project(BASE): nullable=True) _priorities = sa.Column(sa.Text, nullable=True) _milestones = sa.Column(sa.Text, nullable=True) + _quick_replies = sa.Column(sa.Text, nullable=True) _reports = sa.Column(sa.Text, nullable=True) _notifications = sa.Column(sa.Text, nullable=True) _close_status = sa.Column(sa.Text, nullable=True) @@ -476,6 +477,23 @@ class Project(BASE): self._priorities = json.dumps(priorities) @property + def quick_replies(self): + """ Return a list of quick replies available for pull requests and + issues. + """ + quick_replies = [] + + if self._quick_replies: + quick_replies = json.loads(self._quick_replies) + + return quick_replies + + @quick_replies.setter + def quick_replies(self, quick_replies): + """ Ensures the quick replies are properly saved. """ + self._quick_replies = json.dumps(quick_replies) + + @property def notifications(self): """ Return the dict stored as string in the database as an actual dict object. From 9871f9beb0d8336a65d28454acd85918188d0af7 Mon Sep 17 00:00:00 2001 From: Lubomír Sedlář Date: Dec 01 2016 11:12:12 +0000 Subject: [PATCH 2/5] Add settings block for editing quick replies --- diff --git a/pagure/templates/settings.html b/pagure/templates/settings.html index c9bc445..071017f 100644 --- a/pagure/templates/settings.html +++ b/pagure/templates/settings.html @@ -791,6 +791,63 @@ {% endif %} + {% if (config.get('ENABLE_TICKETS', True) + and repo.settings.get('issue_tracker', True)) + or repo.settings.get('pull_requests', True) %} +
+
+
+ Quick replies +
+
+

Quick replies will be offered in a new comment form on Issue or + Pull Request page. This allows you to reply to common probles with a + click of a button.

+

The reply can use the same Markdown formatting as regular + comments. The list you will choose the reply from will only show the + first 50 characters. Please make sure the important message is at the + beginning.

+

The replies will be presented in the same order they are written + here.

+
+
+ {{ tag_form.csrf_token }} +
+
+ {% for quick_reply in repo.quick_replies %} +
+
+ +
+
+ {% endfor %} +
+ +
+
+ +
+
+
+
+
+
+ {% endif %} + {% if config.get('ENABLE_DEL_PROJECTS', True) %}
@@ -939,6 +996,17 @@ $('#new_close_status').click(function(e) { ); }); +$('#new_quick_reply').click(function(e) { + console.log('new quick reply'); + $('#quick_reply_list').append( + '
\ +
\ + \ +
\ +
' + ); +}); + $('#new_custom_field').click(function(e) { console.log('new custom fields'); $('#custom_fields').append( diff --git a/pagure/ui/repo.py b/pagure/ui/repo.py index 8d964a9..2e2c676 100644 --- a/pagure/ui/repo.py +++ b/pagure/ui/repo.py @@ -2157,6 +2157,58 @@ def update_close_status(repo, username=None, namespace=None): namespace=namespace)) +@APP.route('//update/quick_replies', methods=['POST']) +@APP.route('///update/quick_replies', methods=['POST']) +@APP.route('/fork///update/quick_replies', methods=['POST']) +@APP.route( + '/fork////update/quick_replies', + methods=['POST']) +@login_required +def update_quick_replies(repo, username=None, namespace=None): + """ Update the quick_replies of a project. + """ + if admin_session_timedout(): + flask.flash('Action canceled, try it again', 'error') + url = flask.url_for( + 'view_settings', username=username, repo=repo, + namespace=namespace) + return flask.redirect( + flask.url_for('auth_login', next=url)) + + repo = flask.g.repo + + if (not repo.settings.get('issue_tracker', True) and + not repo.settings.get('pull_requests', True)): + flask.abort( + 404, + 'Issue tracker and pull requests are disabled for this project') + + if not flask.g.repo_admin: + flask.abort( + 403, + 'You are not allowed to change the settings for this project') + + form = pagure.forms.ConfirmationForm() + + if form.validate_on_submit(): + quick_replies = [ + w.strip() for w in flask.request.form.getlist('quick_reply') + if w.strip() + ] + try: + repo.quick_replies = quick_replies + SESSION.add(repo) + SESSION.commit() + flask.flash('List of quick replies updated') + except SQLAlchemyError as err: # pragma: no cover + SESSION.rollback() + flask.flash(str(err), 'error') + + return flask.redirect(flask.url_for( + 'view_settings', username=username, repo=repo.name, + namespace=namespace)) + + @APP.route('//update/custom_keys', methods=['POST']) @APP.route('///update/custom_keys', methods=['POST']) @APP.route('/fork///update/custom_keys', methods=['POST']) From 011b1b863e9be71c658ddcfeee204c3007cae5d4 Mon Sep 17 00:00:00 2001 From: Lubomír Sedlář Date: Dec 01 2016 11:12:12 +0000 Subject: [PATCH 3/5] Add button to use quick replies --- diff --git a/pagure/static/quick_reply.js b/pagure/static/quick_reply.js new file mode 100644 index 0000000..6546c96 --- /dev/null +++ b/pagure/static/quick_reply.js @@ -0,0 +1,14 @@ +$(document).ready(function() { + $('.qr-reply').on('click', function (e) { + let tgt = $('#comment'); + if (!tgt.val()) { + tgt.val($(this).attr('data-qr')); + } + $('.qr .dropdown-toggle').dropdown('toggle'); + return false; + }); + // Disable selecting replies when in preview mode. + $('#previewinmarkdown').on('click', function () { + $('.qr-btn').toggleClass('disabled'); + }); +}); diff --git a/pagure/templates/issue.html b/pagure/templates/issue.html index 46bf5fb..4b25403 100644 --- a/pagure/templates/issue.html +++ b/pagure/templates/issue.html @@ -81,6 +81,9 @@ aria-pressed="false" id="previewinmarkdown">Preview + {% if repo.quick_replies %} + {% include "quick_reply.html" %} + {% endif %} @@ -904,4 +907,9 @@ $( document ).ready(function() { }); + +{% if repo.quick_replies %} + +{% endif %} + {% endblock %} diff --git a/pagure/templates/pull_request.html b/pagure/templates/pull_request.html index 7f9b6e4..601ebf2 100644 --- a/pagure/templates/pull_request.html +++ b/pagure/templates/pull_request.html @@ -588,6 +588,9 @@ aria-pressed="false" id="previewinmarkdown">Preview + {% if repo.quick_replies %} + {% include "quick_reply.html" %} + {% endif %}
@@ -1385,4 +1388,8 @@ source.addEventListener('message', function(e) { {% endif %} +{% if repo.quick_replies %} + +{% endif %} + {% endblock %} diff --git a/pagure/templates/quick_reply.html b/pagure/templates/quick_reply.html new file mode 100644 index 0000000..b207de7 --- /dev/null +++ b/pagure/templates/quick_reply.html @@ -0,0 +1,12 @@ +
+ + +
From 9ca35275edc3cd25be73def6b37e9eeed7543ad7 Mon Sep 17 00:00:00 2001 From: Lubomír Sedlář Date: Dec 01 2016 11:12:12 +0000 Subject: [PATCH 4/5] Add tests for storing and retrieving quick replies Only interactions with server are tested. The work happing on client side is currently untested. --- diff --git a/tests/test_pagure_flask_ui_quick_reply.py b/tests/test_pagure_flask_ui_quick_reply.py new file mode 100644 index 0000000..ca40e0d --- /dev/null +++ b/tests/test_pagure_flask_ui_quick_reply.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- + +""" + (c) 2015-2016 - Copyright Red Hat Inc + + Authors: + Lubomír Sedlář + +""" + +import mock +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname( + os.path.abspath(__file__)), '..')) + +import pagure.lib +import pagure.lib.plugins +import pagure.lib.model +import pagure.hooks +import tests + + +class PagureFlaskQuickReplytest(tests.Modeltests): + """ Tests for configuring and displaying quick replies. """ + + def setUp(self): + """ Set up the environnment, ran before every tests. """ + super(PagureFlaskQuickReplytest, self).setUp() + + pagure.APP.config['TESTING'] = True + pagure.SESSION = self.session + pagure.ui.app.SESSION = self.session + pagure.ui.filters.SESSION = self.session + pagure.ui.fork.SESSION = self.session + pagure.ui.issues.SESSION = self.session + pagure.ui.plugins.SESSION = self.session + pagure.ui.repo.SESSION = self.session + pagure.ui.SESSION = self.session + + pagure.APP.config['GIT_FOLDER'] = self.path + pagure.APP.config['FORK_FOLDER'] = os.path.join( + self.path, 'forks') + pagure.APP.config['TICKETS_FOLDER'] = os.path.join( + self.path, 'tickets') + pagure.APP.config['DOCS_FOLDER'] = os.path.join( + self.path, 'docs') + self.app = pagure.APP.test_client() + tests.create_projects(self.session) + tests.create_projects_git(os.path.join(self.path), bare=True) + + self.admin = tests.FakeUser(username='pingou') + self.user = tests.FakeUser(username='ralph') + self.repo = pagure.lib.get_project(self.session, 'test') + + def disable_issues_and_pull_requests(self): + """Disable both issues and pull requests.""" + # This can not use direct access as repo.settings is a property that + # serializes data into JSON. Direct modification is not preserved. + settings = self.repo.settings + settings['issue_tracker'] = False + settings['pull_requests'] = False + self.repo.settings = settings + self.session.add(self.repo) + self.session.commit() + + def setup_quick_replies(self): + """Create some quick replies. + + The full replies are stored as r1 and r2 attributes, with shortened + versions in sr1 and sr2. + """ + self.r1 = 'Ship it!' + self.r2 = ('Nah. I would prefer if you did not submit this, as there ' + 'are problems.') + self.sr1 = self.r1 + self.sr2 = 'Nah. I would prefer if you did not submit this, as...' + + # Set some quick replies + self.repo.quick_replies = [self.r1, self.r2] + self.session.add(self.repo) + + def get_csrf(self, url='/test/settings'): + """Retrieve a CSRF token from given URL.""" + output = self.app.get(url) + self.assertEqual(output.status_code, 200) + + return output.data.split( + 'name="csrf_token" type="hidden" value="')[1].split('">')[0] + + def assertRedirectToSettings(self, output, project='test', notice=None): + """ + Check that user was redirected to settings page of a given project + and that a given notice was printed. + """ + self.assertEqual(output.status_code, 200) + self.assertIn( + u'Settings - %s - Pagure' % project, output.data) + self.assertIn(u'

Settings for %s

' % project, output.data) + if notice: + self.assertIn(notice, output.data) + + def assertQuickReplies(self, quick_replies, project='test'): + repo = pagure.lib.get_project(self.session, project) + self.assertEqual(repo.quick_replies, quick_replies) + + def assertQuickReplyLinks(self, output): + """Assert reply links created by setup_quick_replies are present.""" + link = 'data-qr="%s">\s*%s\s*' + self.assertRegexpMatches( + output.data, link % (self.r1, self.sr1)) + self.assertRegexpMatches( + output.data, link % (self.r2, self.sr2)) + + def test_new_project_has_none(self): + self.assertQuickReplies([]) + + def test_update_quick_reply_without_csrf(self): + with tests.user_set(pagure.APP, self.admin): + output = self.app.get('/test/settings') + self.assertEqual(output.status_code, 200) + + data = { + 'quick_reply': 'Ship it!', + } + output = self.app.post( + '/test/update/quick_replies', data=data, follow_redirects=True) + self.assertRedirectToSettings(output) + self.assertQuickReplies([]) + + def test_update_quick_replies_single(self): + with tests.user_set(pagure.APP, self.admin): + data = { + 'quick_reply': 'Ship it!', + 'csrf_token': self.get_csrf(), + } + output = self.app.post( + '/test/update/quick_replies', data=data, follow_redirects=True) + self.assertRedirectToSettings( + output, notice=u'quick replies updated') + self.assertQuickReplies(['Ship it!']) + self.assertIn(u'>Ship it!', output.data) + + def test_update_quick_replies_multiple(self): + with tests.user_set(pagure.APP, self.admin): + data = { + 'quick_reply': [u'Ship it!', u'Nah.'], + 'csrf_token': self.get_csrf(), + } + output = self.app.post( + '/test/update/quick_replies', data=data, follow_redirects=True) + self.assertRedirectToSettings( + output, notice=u'quick replies updated') + self.assertQuickReplies([u'Ship it!', u'Nah.']) + # Check page has filled in textarea. + self.assertIn(u'>Ship it!', output.data) + self.assertIn(u'>Nah.', output.data) + + def test_update_quick_replies_empty_to_reset(self): + # Set some quick replies + repo = pagure.lib.get_project(self.session, 'test') + repo.quick_replies = ['Ship it!', 'Nah.'] + self.session.add(repo) + self.session.commit() + + with tests.user_set(pagure.APP, self.admin): + data = { + 'quick_reply': [], + 'csrf_token': self.get_csrf(), + } + output = self.app.post( + '/test/update/quick_replies', data=data, follow_redirects=True) + self.assertRedirectToSettings( + output, notice=u'quick replies updated') + self.assertQuickReplies([]) + + def test_update_quick_replies_unprivileged(self): + with tests.user_set(pagure.APP, self.user): + data = { + 'quick_reply': 'Ship it!', + 'csrf_token': 'a guess', + } + output = self.app.post( + '/test/update/quick_replies', data=data, follow_redirects=True) + self.assertEqual(output.status_code, 403) + self.assertQuickReplies([]) + + def test_no_form_with_disabled_issues_and_pull_requests(self): + self.disable_issues_and_pull_requests() + + with tests.user_set(pagure.APP, self.admin): + output = self.app.get('/test/settings') + self.assertNotIn('Quick replies', output.data) + + def test_no_submit_with_disabled_issues_and_pull_requests(self): + self.disable_issues_and_pull_requests() + + with tests.user_set(pagure.APP, self.admin): + data = { + 'quick_reply': 'Ship it!', + 'csrf_token': 'a guess', + } + output = self.app.post( + '/test/update/quick_replies', data=data, follow_redirects=True) + self.assertEqual(output.status_code, 404) + self.assertQuickReplies([]) + + def test_submit_for_bad_project(self): + with tests.user_set(pagure.APP, self.admin): + data = { + 'quick_reply': 'Ship it!', + 'csrf_token': 'a guess', + } + output = self.app.post( + '/boom/update/quick_replies', data=data, follow_redirects=True) + self.assertEqual(output.status_code, 404) + + @mock.patch('pagure.lib.git.update_git') + def test_issue_page_has_quick_replies(self, p_ugt): + self.setup_quick_replies() + + issue = pagure.lib.new_issue( + self.session, + self.repo, + 'Dummy issue', + 'Just a lonely issue.', + 'pingou', + None, + notify=False + ) + + with tests.user_set(pagure.APP, self.user): + output = self.app.get('/test/issue/%s' % issue.id) + self.assertEqual(output.status_code, 200) + self.assertQuickReplyLinks(output) + + @mock.patch('pagure.lib.git.update_git') + @mock.patch('pagure.lib.git.diff_pull_request') + def test_pull_request_page_has_quick_replies(self, diff, p_ugt): + diff.return_value = ([], []) + + self.setup_quick_replies() + + pr = pagure.lib.new_pull_request( + self.session, + 'pr', + self.repo, + 'master', + 'Dummy PR', 'pingou', + None, + repo_from=self.repo, + notify=False, + ) + + with tests.user_set(pagure.APP, self.user): + output = self.app.get('/test/pull-request/%s' % pr.id) + self.assertEqual(output.status_code, 200) + self.assertQuickReplyLinks(output) + + +if __name__ == '__main__': + SUITE = unittest.TestLoader().loadTestsFromTestCase( + PagureFlaskQuickReplytest) + unittest.TextTestRunner(verbosity=2).run(SUITE) From b234a85ecbc201457bd172eff4eb1fc191c42deb Mon Sep 17 00:00:00 2001 From: Lubomír Sedlář Date: Dec 01 2016 11:12:12 +0000 Subject: [PATCH 5/5] Add tooltip to disabled quick reply button When in preview mode or there is some text in the box, instead of overwriting the value we display a tooltip to tell user what to do to choose another reply. --- diff --git a/pagure/static/quick_reply.js b/pagure/static/quick_reply.js index 6546c96..b310ee5 100644 --- a/pagure/static/quick_reply.js +++ b/pagure/static/quick_reply.js @@ -1,14 +1,32 @@ $(document).ready(function() { + const MSG = 'Turn off preview and clear the input field to use a different quick reply.'; + + let in_preview = false; + function update_button() { + const has_text = $("#comment").val() !== ""; + $('.qr-btn').toggleClass('disabled', in_preview || has_text); + if (in_preview || has_text) { + $('.qr').attr('data-original-title', MSG); + } else { + $('.qr').attr('data-original-title', ''); + } + } + $('.qr-reply').on('click', function (e) { let tgt = $('#comment'); if (!tgt.val()) { - tgt.val($(this).attr('data-qr')); + tgt.val($(this).attr('data-qr')).focus(); } $('.qr .dropdown-toggle').dropdown('toggle'); + update_button(); return false; }); // Disable selecting replies when in preview mode. $('#previewinmarkdown').on('click', function () { - $('.qr-btn').toggleClass('disabled'); + in_preview = !in_preview; + update_button(); }); + $('#comment').on('input propertychange', update_button); + $('[data-toggle="tooltip"]').tooltip(); + update_button(); }); diff --git a/pagure/templates/quick_reply.html b/pagure/templates/quick_reply.html index b207de7..6c16ce7 100644 --- a/pagure/templates/quick_reply.html +++ b/pagure/templates/quick_reply.html @@ -1,4 +1,4 @@ -
+