← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/private-dupe-bug-warning3 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/private-dupe-bug-warning3 into lp:launchpad with lp:~wallyworld/launchpad/private-dupe-bug-warning2-943497 as a prerequisite.

Requested reviews:
  Curtis Hovey (sinzui)
Related bugs:
  Bug #190635 in Launchpad itself: "Cannot tell that you chose the right bug when marking as a duplicate"
  https://bugs.launchpad.net/launchpad/+bug/190635
  Bug #767084 in Launchpad itself: ""Mark as duplicate" popup gives an unhelpful error when attempted to dupe against a dupe"
  https://bugs.launchpad.net/launchpad/+bug/767084
  Bug #943497 in Launchpad itself: "warn when you are going to mark a public bug as a duplicate of a private one"
  https://bugs.launchpad.net/launchpad/+bug/943497
  Bug #1023425 in Launchpad itself: "Duplicate error "not a valid bug number or nickname" when nicknames are defunct"
  https://bugs.launchpad.net/launchpad/+bug/1023425

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/private-dupe-bug-warning3/+merge/116997

== Implementation ==

Thus branch completes the first round of work to improve the bug dupe form. Main new features compared with previous branch:

- Short circuit some checks
If the same bug number as the current bug is entered, of the same bug number as the current dupe, no search is done and a suitable error message is displayed iommediately.

- Display privacy warning
If the current bug is public and the proposed dupe bug is private, display a warning
(improvements to message text welcome)

- All correct bug data retrieved from server

The implementation adds a new method getBugData() to IMaloneApplication and exposes it as a named ws op on the /bugs web service interface. The method currently takes just the one search criteria (bug_id) but can be extended if we want to allow searching on pillar and key words. The getBugData method returns a list of json bug data.

==Demo and QA ==

Here's a screenshot of the privacy warning.

http://people.canonical.com/~ianb/bug-dupe-privacy-warning.png

== Tests ==

Extend yui tests
Add tests for getBugData (for direct call and via web service)

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/systemhomes.py
  lib/lp/bugs/browser/tests/test_bugs.py
  lib/lp/bugs/interfaces/malone.py
  lib/lp/bugs/javascript/bugtask_index.js
  lib/lp/bugs/javascript/duplicates.js
  lib/lp/bugs/javascript/tests/test_duplicates.js
  lib/lp/bugs/tests/test_searchtasks_webserv

-- 
https://code.launchpad.net/~wallyworld/launchpad/private-dupe-bug-warning3/+merge/116997
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
=== modified file 'lib/lp/bugs/browser/tests/test_bugs.py'
--- lib/lp/bugs/browser/tests/test_bugs.py	2012-02-28 04:24:19 +0000
+++ lib/lp/bugs/browser/tests/test_bugs.py	2012-07-27 08:04:37 +0000
@@ -9,12 +9,15 @@
 
 from zope.component import getUtility
 
+from lp.bugs.interfaces.bugtask import BugTaskStatus
 from lp.bugs.interfaces.malone import IMaloneApplication
 from lp.bugs.publisher import BugsLayer
+from lp.registry.enums import InformationType
 from lp.services.webapp.publisher import canonical_url
 from lp.testing import (
     celebrity_logged_in,
     feature_flags,
+    person_logged_in,
     set_feature_flag,
     TestCaseWithFactory,
     )
@@ -113,3 +116,27 @@
 
         # we should get some valid content out of this
         self.assertIn('Search all bugs', content)
+
+    def test_getBugData(self):
+        # The getBugData method works as expected.
+        owner = self.factory.makePerson()
+        bug = self.factory.makeBug(
+            owner=owner,
+            status=BugTaskStatus.INPROGRESS,
+            title='title', description='description',
+            information_type=InformationType.PRIVATESECURITY)
+        with person_logged_in(owner):
+            bug_data = getUtility(IMaloneApplication).getBugData(owner, bug.id)
+            expected_bug_data = {
+                    'id': bug.id,
+                    'information_type': 'Private Security',
+                    'is_private': True,
+                    'importance': 'Undecided',
+                    'importance_class': 'importanceUNDECIDED',
+                    'status': 'In Progress',
+                    'status_class': 'statusINPROGRESS',
+                    'bug_summary': 'title',
+                    'description': 'description',
+                    'bug_url': canonical_url(bug.default_bugtask)
+        }
+        self.assertEqual([expected_bug_data], bug_data)

=== modified file 'lib/lp/bugs/interfaces/malone.py'
--- lib/lp/bugs/interfaces/malone.py	2012-05-11 06:29:42 +0000
+++ lib/lp/bugs/interfaces/malone.py	2012-07-27 08:04:37 +0000
@@ -12,10 +12,13 @@
     collection_default_content,
     export_as_webservice_collection,
     export_factory_operation,
+    export_read_operation,
+    operation_for_version,
     operation_parameters,
     REQUEST_USER,
     )
 from lazr.restful.fields import Reference
+from lazr.restful.interface import copy_field
 from zope.interface import Attribute
 
 from lp.bugs.interfaces.bug import IBug
@@ -36,6 +39,21 @@
     def searchTasks(search_params):
         """Search IBugTasks with the given search parameters."""
 
+    @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        bug_id=copy_field(IBug['id'])
+    )
+    @export_read_operation()
+    @operation_for_version('devel')
+    def getBugData(user, bug_id):
+        """Search bugtasks matching the specified criteria.
+
+        The only criteria currently supported is to search for a bugtask with
+        the specified bug id.
+
+        :return: a list of matching bugs represented as json data
+        """
+
     bug_count = Attribute("The number of bugs recorded in Launchpad")
     bugwatch_count = Attribute("The number of links to external bug trackers")
     bugtask_count = Attribute("The number of bug tasks in Launchpad")

=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
--- lib/lp/bugs/javascript/bugtask_index.js	2012-07-25 00:31:04 +0000
+++ lib/lp/bugs/javascript/bugtask_index.js	2012-07-27 08:04:37 +0000
@@ -41,7 +41,11 @@
         setup_client_and_bug();
         var dup_widget = new Y.lp.bugs.duplicates.MarkBugDuplicate({
             srcNode: '#duplicate-actions',
-            lp_bug_entry: lp_bug_entry
+            lp_bug_entry: lp_bug_entry,
+            private_warning_message:
+                'This is a private bug. If you select this bug as ' +
+                'the duplicate, it may not be visible to some users.'
+
         });
         dup_widget.render();
 

=== modified file 'lib/lp/bugs/javascript/duplicates.js'
--- lib/lp/bugs/javascript/duplicates.js	2012-07-27 08:04:36 +0000
+++ lib/lp/bugs/javascript/duplicates.js	2012-07-27 08:04:37 +0000
@@ -138,16 +138,35 @@
      * @private
      */
     _find_duplicate_bug: function(data) {
-        var new_dup_id = Y.Lang.trim(data['field.duplicateof'][0]);
+        var new_dup_id = Y.Lang.trim(data['field.duplicateof'][0]).trim();
         // If there's no bug data entered then we are deleting the duplicate
         // link.
         if (new_dup_id === '') {
             this._update_bug_duplicate(new_dup_id);
             return;
         }
+
+        // Do some quick checks before we submit.
+        if (new_dup_id === LP.cache.bug.id.toString()) {
+            this.duplicate_form.showError(
+                'A bug cannot be marked as a duplicate of itself.');
+            return;
+        }
+        var duplicate_of_link = LP.cache.bug.duplicate_of_link;
+        var new_dupe_link
+            = Y.lp.client.get_absolute_uri("/api/devel/bugs/" + new_dup_id);
+        if (new_dupe_link === duplicate_of_link) {
+            this.duplicate_form.showError(
+                'This bug is already marked as a duplicate of bug ' +
+                new_dup_id + '.');
+            return;
+        }
+
         var that = this;
         var qs_data
             = Y.lp.client.append_qs("", "ws.accept", "application.json");
+        qs_data = Y.lp.client.append_qs(qs_data, "ws.op", "getBugData");
+        qs_data = Y.lp.client.append_qs(qs_data, "bug_id", new_dup_id);
 
         var bug_field = this.duplicate_form.form_node
             .one('[id="field.duplicateof"]');
@@ -165,7 +184,12 @@
                         return;
                     }
                     var bug_data = Y.JSON.parse(response.responseText);
-                    that._confirm_selected_bug(bug_data);
+                    if (!Y.Lang.isArray(bug_data) || bug_data.length === 0) {
+                        return;
+                    }
+                    // The server may return multiple bugs but for now we only
+                    // support displaying one of them.
+                    that._confirm_selected_bug(bug_data[0]);
                 },
                 failure: function(id, response) {
                     var error_msg;
@@ -181,13 +205,14 @@
             data: qs_data
         };
         var uri
-            = Y.lp.client.get_absolute_uri("/api/devel/bugs/" + new_dup_id);
+            = Y.lp.client.get_absolute_uri("/api/devel/bugs");
         this.io_provider.io(uri, config);
     },
 
     // Template for rendering the bug details.
     _bug_details_template: function() {
         return [
+        '<table><tbody><tr><td>',
         '<div id="client-listing">',
         '  <div class="buglisting-col1">',
         '      <div class="importance {{importance_class}}">',
@@ -210,8 +235,22 @@
         '          {{description}}</p>',
         '      </div>',
         '  </div>',
-        '</div>'
-        ].join(' ');
+        '</div></td></tr>',
+        '{{> private_warning}}',
+        '</tbody></table>'
+        ].join(' ');
+    },
+
+    _private_warning_template: function(message) {
+        var template = [
+        '{{#private_warning}}',
+        '<tr><td><p id="privacy-warning" ',
+        'class="block-sprite large-warning">',
+        '{message}',
+        '</p></td></tr>',
+        '{{/private_warning}}'
+        ].join(' ');
+        return Y.Lang.substitute(template, {message: message});
     },
 
     // Template for the bug confirmation form.
@@ -236,18 +275,18 @@
      * @private
      */
     _confirm_selected_bug: function(bug_data) {
-        // TODO - get real data from the server
-        bug_data.importance = 'High';
-        bug_data.importance_class = 'importanceHIGH';
-        bug_data.status = 'Triaged';
-        bug_data.status_class = 'statusTRIAGED';
-        bug_data.bug_summary = bug_data.title;
-        bug_data.bug_url = bug_data.web_link;
-
         var bug_id = bug_data.id;
+        bug_data.private_warning
+            = this.get('public_context') && bug_data.is_private;
+        var private_warning_message
+            = this.get('private_warning_message');
         var html = Y.lp.mustache.to_html(
             this._bug_confirmation_form_template(), bug_data,
-            {bug_details: this._bug_details_template()});
+            {
+                bug_details: this._bug_details_template(),
+                private_warning:
+                    this._private_warning_template(private_warning_message)
+            });
         var confirm_node = Y.Node.create(html);
         this._show_confirm_node(confirm_node);
         var that = this;
@@ -521,6 +560,16 @@
         dupe_span: '#mark-duplicate-text'
     },
     ATTRS: {
+        // Is the context in which this form being used public.
+        public_context: {
+            getter: function() {
+                return !Y.one(document.body).hasClass('private');
+            }
+        },
+        // Warning to display if we select a private bug from a public context.
+        private_warning_message: {
+            value: 'You are selecting a private bug.'
+        },
         // The launchpad client entry for the current bug.
         lp_bug_entry: {
             value: null

=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.js'
--- lib/lp/bugs/javascript/tests/test_duplicates.js	2012-07-27 08:04:36 +0000
+++ lib/lp/bugs/javascript/tests/test_duplicates.js	2012-07-27 08:04:37 +0000
@@ -13,8 +13,9 @@
                 links: {},
                 cache: {
                     bug: {
+                        id: 1,
                         self_link: 'api/devel/bugs/1',
-                        duplicate_of_link: null
+                        duplicate_of_link: ''
                     }
                 }
             };
@@ -52,7 +53,8 @@
                 srcNode: '#duplicate-actions',
                 lp_bug_entry: this.lp_bug_entry,
                 use_animation: false,
-                io_provider: this.mockio
+                io_provider: this.mockio,
+                private_warning_message: 'Privacy warning'
             });
             widget.render();
             Y.Assert.areEqual(
@@ -109,11 +111,18 @@
                     .hasClass('yui3-lazr-formoverlay-hidden'));
             Y.DOM.byId('field.duplicateof').value = bug_id;
             form.one('[name="field.actions.change"]').simulate('click');
-            var expected_url = '/api/devel/bugs/1';
             if (bug_id !== '') {
-                expected_url = 'file:///api/devel/bugs/' + bug_id;
+                Y.Assert.areEqual(
+                    'file:///api/devel/bugs',
+                    this.mockio.last_request.url);
+                Y.Assert.areEqual(
+                    this.mockio.last_request.config.data,
+                    'ws.accept=application.json&ws.op=getBugData&' +
+                    'bug_id=' + bug_id);
+            } else {
+                Y.Assert.areEqual(
+                    '/api/devel/bugs/1', this.mockio.last_request.url);
             }
-            Y.Assert.areEqual(expected_url, this.mockio.last_request.url);
         },
 
         // The bug entry form is visible and the confirmation form is invisible
@@ -134,15 +143,49 @@
 
         // Invoke a successful search operation and check the form state.
         _assert_search_form_success: function(bug_id) {
-            var expected_updated_entry = {
+            var is_private = bug_id === 4;
+            var expected_updated_entry = [{
                 id: bug_id,
                 uri: 'api/devel/bugs/' + bug_id,
+                is_private: is_private,
                 duplicate_of_link: 'api/devel/bugs/' + bug_id,
-                self_link: 'api/devel/bugs/' + bug_id};
+                self_link: 'api/devel/bugs/' + bug_id}];
             this.mockio.last_request.successJSON(expected_updated_entry);
             this._assert_form_state(true);
         },
 
+        // Attempt to make a bug as a duplicate of itself is detected and an
+        // error is displayed immediately.
+        test_mark_bug_as_dupe_of_self: function() {
+            this.widget = this._createWidget(false);
+            this.widget.get('update_dupe_link').simulate('click');
+            this.mockio.last_request = null;
+            Y.DOM.byId('field.duplicateof').value = 1;
+            var form = Y.one('#duplicate-form-container');
+            form.one('[name="field.actions.change"]').simulate('click');
+            Y.Assert.isNull(this.mockio.last_request);
+            this._assert_error_display(
+                'A bug cannot be marked as a duplicate of itself.');
+            this._assert_form_state(false);
+        },
+
+        // Attempt to make a bug as a duplicate of it's existing dupe is
+        // detected and an error is displayed immediately.
+        test_mark_bug_as_dupe_of_existing_dupe: function() {
+            this.widget = this._createWidget(false);
+            this.widget.get('update_dupe_link').simulate('click');
+            this.mockio.last_request = null;
+            window.LP.cache.bug.duplicate_of_link
+                = 'file:///api/devel/bugs/4';
+            Y.DOM.byId('field.duplicateof').value = 4;
+            var form = Y.one('#duplicate-form-container');
+            form.one('[name="field.actions.change"]').simulate('click');
+            Y.Assert.isNull(this.mockio.last_request);
+            this._assert_error_display(
+                'This bug is already marked as a duplicate of bug 4.');
+            this._assert_form_state(false);
+        },
+
         // A successful search for a bug displays the confirmation form.
         test_initial_bug_search_success: function() {
             this.widget = this._createWidget(false);
@@ -150,6 +193,34 @@
             this._assert_search_form_success(3);
         },
 
+        // No privacy warning when marking a bug as a dupe a public one.
+        test_public_dupe: function() {
+            this.widget = this._createWidget(false);
+            this._assert_search_form_submission(3);
+            this._assert_search_form_success(3);
+            Y.Assert.isNull(Y.one('#privacy-warning'));
+        },
+
+        // Privacy warning when marking a public bug as a dupe of private one.
+        test_public_bug_private_dupe: function() {
+            this.widget = this._createWidget(false);
+            this._assert_search_form_submission(4);
+            this._assert_search_form_success(4);
+            var privacy_message = Y.one('#privacy-warning');
+            Y.Assert.areEqual(
+                'Privacy warning', privacy_message.get('text').trim());
+        },
+
+        // No privacy warning when marking a private bug as a dupe of another
+        // private bug.
+        test_private_bug_private_dupe: function() {
+            Y.one(document.body).addClass('private');
+            this.widget = this._createWidget(false);
+            this._assert_search_form_submission(4);
+            this._assert_search_form_success(4);
+            Y.Assert.isNull(Y.one('#privacy-warning'));
+        },
+
         // After a successful search, hitting the Search Again button takes us
         // back to the bug details entry form.
         test_initial_bug_search_try_again: function() {
@@ -260,7 +331,7 @@
                 function(response, old_dup_url, new_dup_id) {
                     Y.Assert.areEqual(
                         'There was an error', response.responseText);
-                    Y.Assert.areEqual(null, old_dup_url);
+                    Y.Assert.areEqual('', old_dup_url);
                     Y.Assert.areEqual(3, new_dup_id);
                     failure_called = true;
                 };

=== modified file 'lib/lp/bugs/tests/test_searchtasks_webservice.py'
--- lib/lp/bugs/tests/test_searchtasks_webservice.py	2012-07-17 14:13:55 +0000
+++ lib/lp/bugs/tests/test_searchtasks_webservice.py	2012-07-27 08:04:37 +0000
@@ -115,3 +115,36 @@
         # A non-matching search returns no results.
         response = self.search("devel", information_type="Private")
         self.assertEqual(response['total_size'], 0)
+
+
+class TestGetBugData(TestCaseWithFactory):
+    """Tests for the /bugs getBugData operation."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGetBugData, self).setUp()
+        self.owner = self.factory.makePerson()
+        with person_logged_in(self.owner):
+            self.product = self.factory.makeProduct()
+        self.bug = self.factory.makeBug(
+            product=self.product,
+            information_type=InformationType.PRIVATESECURITY)
+        self.webservice = LaunchpadWebServiceCaller(
+            'launchpad-library', 'salgado-change-anything')
+
+    def search(self, api_version, **kwargs):
+        return self.webservice.named_get(
+            '/bugs', 'getBugData',
+            api_version=api_version, **kwargs).jsonBody()
+
+    def test_search_returns_results(self):
+        # A matching search returns results.
+        response = self.search(
+            "devel", bug_id=self.bug.id)
+        self.assertEqual(self.bug.id, response[0]['id'])
+
+    def test_search_returns_no_results(self):
+        # A non-matching search returns no results.
+        response = self.search("devel", bug_id=0)
+        self.assertEqual(len(response), 0)

=== modified file 'lib/lp/systemhomes.py'
--- lib/lp/systemhomes.py	2012-05-11 06:36:01 +0000
+++ lib/lp/systemhomes.py	2012-07-27 08:04:37 +0000
@@ -58,6 +58,7 @@
     IHWVendorIDSet,
     ParameterError,
     )
+from lp.registry.enums import PRIVATE_INFORMATION_TYPES
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
@@ -77,6 +78,7 @@
     ICanonicalUrlData,
     ILaunchBag,
     )
+from lp.services.webapp.publisher import canonical_url
 from lp.services.webservice.interfaces import IWebServiceApplication
 from lp.services.worlddata.interfaces.language import ILanguageSet
 from lp.testopenid.interfaces.server import ITestOpenIDApplication
@@ -126,6 +128,30 @@
         """See `IMaloneApplication`."""
         return getUtility(IBugTaskSet).search(search_params)
 
+    def getBugData(self, user, bug_id):
+        """See `IMaloneApplication`."""
+        search_params = BugTaskSearchParams(user, bug=bug_id)
+        bugtasks = getUtility(IBugTaskSet).search(search_params)
+        if not bugtasks:
+            return []
+        bugs = [task.bug for task in bugtasks]
+        data = []
+        for bug in bugs:
+            bugtask = bug.default_bugtask
+            data.append({
+                'id': bug_id,
+                'information_type': bug.information_type.title,
+                'is_private':
+                    bug.information_type in PRIVATE_INFORMATION_TYPES,
+                'importance': bugtask.importance.title,
+                'importance_class': 'importance' + bugtask.importance.name,
+                'status': bugtask.status.title,
+                'status_class': 'status' + bugtask.status.name,
+                'bug_summary': bug.title,
+                'description': bug.description,
+                'bug_url': canonical_url(bugtask)})
+        return data
+
     def createBug(self, owner, title, description, target,
                   security_related=False, private=False, tags=None):
         """See `IMaloneApplication`."""


Follow ups