← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~lgp171188/launchpad:add-ui-bug-lock-unlock into launchpad:master

 

Guruprasad has proposed merging ~lgp171188/launchpad:add-ui-bug-lock-unlock into launchpad:master.

Commit message:
Implement the UI for changing a bug's lock status and reason

And allow only the users with the 'launchpad.Moderate' permission to
be able to access the page and change the lock status and reason.

Also display a read-only icon for the locked bugs.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lgp171188/launchpad/+git/launchpad/+merge/415453
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/launchpad:add-ui-bug-lock-unlock into launchpad:master.
diff --git a/lib/canonical/launchpad/icing/css/typography.scss b/lib/canonical/launchpad/icing/css/typography.scss
index 1bdd3a2..1e58f29 100644
--- a/lib/canonical/launchpad/icing/css/typography.scss
+++ b/lib/canonical/launchpad/icing/css/typography.scss
@@ -58,6 +58,15 @@ h1, h2, h3, h4, h5, h6 {
     max-width: 100%;
   }
 
+  .inline-block {
+    display: inline-block;
+  }
+
+  .badge.read-only {
+    width: 20px;
+    height: 16px;
+  }
+
   table.wide {
     width: $wider-page;
   }
diff --git a/lib/lp/bugs/browser/bug.py b/lib/lp/bugs/browser/bug.py
index b67c65f..ef8b6c1 100644
--- a/lib/lp/bugs/browser/bug.py
+++ b/lib/lp/bugs/browser/bug.py
@@ -8,6 +8,7 @@ __all__ = [
     'BugContextMenu',
     'BugEditView',
     'BugInformationTypePortletView',
+    'BugLockStatusEditView',
     'BugMarkAsAffectingUserView',
     'BugMarkAsDuplicateView',
     'BugNavigation',
@@ -76,7 +77,10 @@ from lp.app.widgets.product import GhostCheckBoxWidget
 from lp.app.widgets.project import ProjectScopeWidget
 from lp.bugs.browser.bugsubscription import BugPortletSubscribersWithDetails
 from lp.bugs.browser.widgets.bug import BugTagsWidget
-from lp.bugs.enums import BugNotificationLevel
+from lp.bugs.enums import (
+    BugLockStatus,
+    BugNotificationLevel,
+    )
 from lp.bugs.interfaces.bug import (
     IBug,
     IBugSet,
@@ -218,7 +222,7 @@ class BugContextMenu(ContextMenu):
         'adddistro', 'subscription', 'addsubscriber', 'editsubscriptions',
         'addcomment', 'nominate', 'addbranch', 'linktocve', 'unlinkcve',
         'createquestion', 'mute_subscription', 'removequestion',
-        'activitylog', 'affectsmetoo']
+        'activitylog', 'affectsmetoo', 'change_lock_status']
 
     def __init__(self, context):
         # Always force the context to be the current bugtask, so that we don't
@@ -238,6 +242,12 @@ class BugContextMenu(ContextMenu):
         text = 'Change privacy/security'
         return Link('+secrecy', text)
 
+    @enabled_with_permission('launchpad.Moderate')
+    def change_lock_status(self):
+        """Return the 'Change lock status' Link."""
+        text = 'Change lock status'
+        return Link('+lock-status', text, icon='edit')
+
     @enabled_with_permission('launchpad.Edit')
     def markduplicate(self):
         """Return the 'Mark as duplicate' Link."""
@@ -766,6 +776,73 @@ class BugEditView(BugEditViewBase):
         self.updateBugFromData(data)
 
 
+class BugLockStatusEditView(LaunchpadEditFormView):
+    """The view for editing the bug lock status and lock reason."""
+
+    class schema(Interface):
+        # Duplicating the fields is necessary because these fields are
+        # read-only in `IBug`.
+        lock_status = copy_field(IBug['lock_status'], readonly=False)
+        lock_reason = copy_field(IBug['lock_reason'], readonly=False)
+
+    @property
+    def adapters(self):
+        """See `LaunchpadFormView`."""
+        return {self.schema: self.context.bug}
+
+    field_names = ['lock_status', 'lock_reason']
+
+    custom_widget_lock_status = LaunchpadRadioWidgetWithDescription
+    custom_widget_lock_reason = CustomWidgetFactory(
+        TextWidget,
+        displayWidth=30
+    )
+
+    def initialize(self):
+        super().initialize()
+        lock_status_widget = self.widgets['lock_status']
+        lock_status_widget._displayItemForMissingValue = False
+
+    @property
+    def label(self):
+        """The form label."""
+        return (
+            "Edit the lock status and reason for bug #%d" % self.context.bug.id
+        )
+
+    page_title = label
+
+    @action('Change', name='change')
+    def change_action(self, action, data):
+        """Update the bug lock status and reason with submitted changes."""
+        bug = self.context.bug
+        if bug.lock_status != data['lock_status']:
+            locked_states = (
+                BugLockStatus.COMMENT_ONLY,
+            )
+            if data['lock_status'] in locked_states:
+                bug.lock(
+                    status=data['lock_status'],
+                    reason=data['lock_reason'] or '',
+                    who=self.user
+                )
+            else:
+                bug.unlock(who=self.user)
+
+        elif (bug.lock_status != BugLockStatus.UNLOCKED and
+              bug.lock_reason != data['lock_reason']):
+            bug.setLockReason(data['lock_reason'], who=self.user)
+
+    @property
+    def next_url(self):
+        """Return the next URL to call when this call completes."""
+        if not self.request.is_ajax:
+            return canonical_url(self.context)
+        return None
+
+    cancel_url = next_url
+
+
 class BugMarkAsDuplicateView(BugEditViewBase):
     """Page for marking a bug as a duplicate."""
 
diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
index 85e1340..d6a365e 100644
--- a/lib/lp/bugs/browser/configure.zcml
+++ b/lib/lp/bugs/browser/configure.zcml
@@ -507,6 +507,12 @@
             template="../../app/templates/generic-edit.pt"
             permission="launchpad.Edit"/>
         <browser:page
+            name="+lock-status"
+            for="lp.bugs.interfaces.bug.IBugTask"
+            class="lp.bugs.browser.bug.BugLockStatusEditView"
+            template="../../app/templates/generic-edit.pt"
+            permission="launchpad.Moderate"/>
+        <browser:page
             name="+delete"
             for="lp.bugs.interfaces.bugtask.IBugTask"
             class="lp.bugs.browser.bugtask.BugTaskDeletionView"
diff --git a/lib/lp/bugs/browser/tests/test_edit_bug_lock_status.py b/lib/lp/bugs/browser/tests/test_edit_bug_lock_status.py
new file mode 100644
index 0000000..2df1f80
--- /dev/null
+++ b/lib/lp/bugs/browser/tests/test_edit_bug_lock_status.py
@@ -0,0 +1,414 @@
+# Copyright 2011-2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests related to the view for editing the bug lock status."""
+
+from soupmatchers import (
+    HTMLContains,
+    Tag,
+    )
+from testtools.matchers import (
+    MatchesStructure,
+    Not,
+    )
+from zope.security.interfaces import Unauthorized
+
+from lp.bugs.enums import BugLockStatus
+from lp.services.webapp import canonical_url
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.testing import (
+    anonymous_logged_in,
+    BrowserTestCase,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.views import create_initialized_view
+
+
+class TestBugLockStatusEditView(TestCaseWithFactory):
+    """
+    Tests for the view to edit the bug lock status.
+    """
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.person = self.factory.makePerson()
+        self.target = self.factory.makeProduct()
+
+    def test_form_submission_missing_required_fields(self):
+        bug = self.factory.makeBug(target=self.target)
+        form = {
+            'a': 1,
+            'b': 2,
+        }
+        with person_logged_in(self.target.owner):
+            request = LaunchpadTestRequest(
+                method='POST',
+                form=form,
+            )
+            view = create_initialized_view(
+                bug.default_bugtask,
+                name='+lock-status',
+                request=request
+            )
+            self.assertEqual([], view.errors)
+
+        self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+
+    def test_users_without_moderate_permission_cannot_edit_lock_status(self):
+        bug = self.factory.makeBug(target=self.target)
+        with person_logged_in(self.target.owner):
+            bug.lock(who=self.target.owner, status=BugLockStatus.COMMENT_ONLY)
+
+        form = {
+            "field.actions.change": "Change",
+            "field.lock_status": "Unlocked",
+            "field.lock_reason": "",
+            "field.lock_status-empty-marker": "1",
+        }
+
+        with anonymous_logged_in():
+            self.assertRaises(
+                Unauthorized, create_initialized_view,
+                bug.default_bugtask, name='+lock-status',
+                form=form
+            )
+
+        with person_logged_in(self.person):
+            self.assertRaises(
+                Unauthorized, create_initialized_view,
+                bug.default_bugtask, name='+lock-status',
+                form=form
+            )
+
+    def test_locking_a_locked_bug(self):
+        bug = self.factory.makeBug(target=self.target)
+        with person_logged_in(self.target.owner):
+            bug.lock(who=self.target.owner, status=BugLockStatus.COMMENT_ONLY)
+
+        self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+
+        form = {
+            "field.actions.change": "Change",
+            "field.lock_status": "Comment-only",
+            "field.lock_reason": "",
+            "field.lock_status-empty-marker": "1",
+        }
+        with person_logged_in(self.target.owner):
+            request = LaunchpadTestRequest(
+                method='POST',
+                form=form,
+            )
+            view = create_initialized_view(
+                bug.default_bugtask,
+                name='+lock-status',
+                request=request
+            )
+            self.assertEqual([], view.errors)
+
+        self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+
+    def test_unlocking_an_unlocked_bug(self):
+        bug = self.factory.makeBug(target=self.target)
+
+        self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+
+        form = {
+            "field.actions.change": "Change",
+            "field.lock_status": "Unlocked",
+            "field.lock_reason": "too hot",
+            "field.lock_status-empty-marker": "1",
+        }
+        with person_logged_in(self.target.owner):
+            request = LaunchpadTestRequest(
+                method='POST',
+                form=form,
+            )
+            view = create_initialized_view(
+                bug.default_bugtask,
+                name='+lock-status',
+                request=request
+            )
+            self.assertEqual([], view.errors)
+
+        self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+
+    def test_unlocking_a_bug_locked_with_reason_clears_the_reason(self):
+        bug = self.factory.makeBug(target=self.target)
+
+        with person_logged_in(self.target.owner):
+            bug.lock(
+                who=self.target.owner,
+                status=BugLockStatus.COMMENT_ONLY,
+                reason='too hot'
+            )
+        self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
+        self.assertEqual('too hot', bug.lock_reason)
+
+        form = {
+            "field.actions.change": "Change",
+            "field.lock_status": "Unlocked",
+            "field.lock_reason": "too hot!",
+            "field.lock_status-empty-marker": "1",
+        }
+        with person_logged_in(self.target.owner):
+            request = LaunchpadTestRequest(
+               method='POST',
+                form=form,
+            )
+            view = create_initialized_view(
+                bug.default_bugtask,
+                name='+lock-status',
+                request=request
+            )
+            self.assertEqual([], view.errors)
+
+        self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+
+    def test_locking_an_unlocked_bug(self):
+        bug = self.factory.makeBug(target=self.target)
+
+        self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+        self.assertEqual(1, bug.activity.count())
+
+        form = {
+            "field.actions.change": "Change",
+            "field.lock_status": "Comment-only",
+            "field.lock_reason": "too hot",
+            "field.lock_status-empty-marker": "1",
+        }
+        with person_logged_in(self.target.owner):
+            request = LaunchpadTestRequest(
+                method='POST',
+                form=form,
+            )
+            view = create_initialized_view(
+                bug.default_bugtask,
+                name='+lock-status',
+                request=request
+            )
+            self.assertEqual([], view.errors)
+
+        self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
+        self.assertEqual('too hot', bug.lock_reason)
+        self.assertEqual(2, bug.activity.count())
+        self.assertThat(
+            bug.activity[1],
+            MatchesStructure.byEquality(
+                person=self.target.owner,
+                whatchanged='lock status',
+                oldvalue=str(BugLockStatus.UNLOCKED),
+                newvalue=str(BugLockStatus.COMMENT_ONLY),
+            )
+        )
+
+    def test_unlocking_a_locked_bug(self):
+        bug = self.factory.makeBug(target=self.target)
+        self.assertEqual(1, bug.activity.count())
+
+        with person_logged_in(self.target.owner):
+            bug.lock(
+                who=self.target.owner,
+                status=BugLockStatus.COMMENT_ONLY,
+                reason='too hot'
+            )
+        self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
+        self.assertEqual('too hot', bug.lock_reason)
+        self.assertEqual(2, bug.activity.count())
+
+        form = {
+            "field.actions.change": "Change",
+            "field.lock_status": "Unlocked",
+            "field.lock_reason": "too hot!!",
+            "field.lock_status-empty-marker": "1",
+        }
+        with person_logged_in(self.target.owner):
+            request = LaunchpadTestRequest(
+                method='POST',
+                form=form,
+            )
+            view = create_initialized_view(
+                bug.default_bugtask,
+                name='+lock-status',
+                request=request
+            )
+            self.assertEqual([], view.errors)
+
+        self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
+        self.assertIsNone(bug.lock_reason)
+        self.assertEqual(3, bug.activity.count())
+        self.assertThat(
+            bug.activity[2],
+            MatchesStructure.byEquality(
+                person=self.target.owner,
+                whatchanged='lock status',
+                oldvalue=str(BugLockStatus.COMMENT_ONLY),
+                newvalue=str(BugLockStatus.UNLOCKED),
+            )
+        )
+
+    def test_changing_lock_reason_of_a_locked_bug(self):
+        bug = self.factory.makeBug(target=self.target)
+        self.assertEqual(1, bug.activity.count())
+
+        with person_logged_in(self.target.owner):
+            bug.lock(
+                who=self.target.owner,
+                status=BugLockStatus.COMMENT_ONLY,
+                reason='too hot'
+            )
+        self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
+        self.assertEqual('too hot', bug.lock_reason)
+        self.assertEqual(2, bug.activity.count())
+
+        form = {
+            "field.actions.change": "Change",
+            "field.lock_status": "Comment-only",
+            "field.lock_reason": "too hot!",
+            "field.lock_status-empty-marker": "1",
+        }
+        with person_logged_in(self.target.owner):
+            request = LaunchpadTestRequest(
+                method='POST',
+                form=form,
+            )
+            view = create_initialized_view(
+                bug.default_bugtask,
+                name='+lock-status',
+                request=request
+            )
+            self.assertEqual([], view.errors)
+
+        self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
+        self.assertEqual('too hot!', bug.lock_reason)
+        self.assertEqual(3, bug.activity.count())
+        self.assertThat(
+            bug.activity[2],
+            MatchesStructure.byEquality(
+                person=self.target.owner,
+                whatchanged='lock reason',
+                oldvalue='too hot',
+                newvalue='too hot!',
+            )
+        )
+
+class TestBugLockFeatures(BrowserTestCase):
+    """Test for the features related to the locking, unlocking a bug."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.person = self.factory.makePerson()
+        self.target = self.factory.makeProduct()
+
+    def test_bug_lock_status_page_not_linked_for_non_moderators(self):
+        bug = self.factory.makeBug(target=self.target)
+        bugtask_url = canonical_url(bug.default_bugtask)
+        browser = self.getUserBrowser(
+            bugtask_url,
+            user=self.person,
+        )
+        self.assertThat(
+            browser.contents,
+            Not(
+                HTMLContains(
+                    Tag(
+                        'change lock status link tag',
+                        'a',
+                        text='Change lock status',
+                        attrs={
+                            'class': "edit",
+                            'href': '{}/+lock-status'.format(bugtask_url),
+                        }
+                    )
+                )
+            )
+        )
+
+    def test_bug_lock_status_page_linked_for_moderators(self):
+        bug = self.factory.makeBug(target=self.target)
+        bugtask_url = canonical_url(bug.default_bugtask)
+
+        browser = self.getUserBrowser(
+            bugtask_url,
+            user=self.target.owner,
+        )
+        self.assertThat(
+            browser.contents,
+            HTMLContains(
+                Tag(
+                    'change lock status link tag',
+                    'a',
+                    text='Change lock status',
+                    attrs={
+                        'class': "edit",
+                        'href': '{}/+lock-status'.format(bugtask_url),
+                    }
+                )
+            )
+        )
+
+    def test_bug_readonly_icon_displayed_when_bug_is_locked(self):
+        bug = self.factory.makeBug(target=self.target)
+        with person_logged_in(self.target.owner):
+            bug.lock(
+                who=self.target.owner,
+                status=BugLockStatus.COMMENT_ONLY,
+                reason='too hot'
+            )
+
+        bugtask_url = canonical_url(bug.default_bugtask)
+
+        browser = self.getUserBrowser(
+            bugtask_url,
+            user=self.target.owner,
+        )
+        self.assertThat(
+            browser.contents,
+            HTMLContains(
+                Tag(
+                    'read-only icon tag',
+                    'span',
+                    attrs={
+                        'class': 'read-only',
+                        'title': 'Locked'
+                    }
+                )
+            )
+        )
+
+    def test_bug_readonly_icon_not_displayed_when_bug_is_unlocked(self):
+        bug = self.factory.makeBug(target=self.target)
+
+        bugtask_url = canonical_url(bug.default_bugtask)
+
+        browser = self.getUserBrowser(
+            bugtask_url,
+            user=self.target.owner,
+        )
+        self.assertThat(
+            browser.contents,
+            Not(
+                HTMLContains(
+                    Tag(
+                        'read-only icon tag',
+                        'span',
+                        attrs={
+                            'class': 'read-only',
+                            'title': 'Locked'
+                        }
+                    )
+                )
+            )
+        )
diff --git a/lib/lp/bugs/interfaces/bug.py b/lib/lp/bugs/interfaces/bug.py
index 7459bd5..100396e 100644
--- a/lib/lp/bugs/interfaces/bug.py
+++ b/lib/lp/bugs/interfaces/bug.py
@@ -387,6 +387,14 @@ class IBugView(Interface):
             readonly=True,
             value_type=Reference(schema=IMessage)),
         exported_as='messages'))
+    locked = exported(
+        Bool(
+            title=_('Locked?'),
+            description=_('Is this bug locked?'),
+            readonly=True
+        ),
+        as_of="devel"
+    )
     lock_status = exported(
         Choice(
             title=_('Lock Status'), vocabulary=BugLockStatus,
diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py
index f663baa..7299cd6 100644
--- a/lib/lp/bugs/model/bug.py
+++ b/lib/lp/bugs/model/bug.py
@@ -107,6 +107,7 @@ from lp.bugs.adapters.bugchange import (
     UnsubscribedFromBug,
     )
 from lp.bugs.enums import (
+    BugLockedStatus,
     BugLockStatus,
     BugNotificationLevel,
     )
@@ -414,6 +415,28 @@ class Bug(SQLBase, InformationTypeMixin):
     lock_reason = StringCol(notNull=False, default=None)
 
     @property
+<<<<<<< lib/lp/bugs/model/bug.py
+=======
+    def locked(self):
+        try:
+            BugLockedStatus.items[self._lock_status.value]
+            return True
+        except KeyError:
+            return False
+
+    @property
+    def lock_status(self):
+        return (
+            BugLockStatus.UNLOCKED if self._lock_status is None
+            else self._lock_status
+        )
+
+    @lock_status.setter
+    def lock_status(self, value):
+        self._lock_status = value
+
+    @property
+>>>>>>> lib/lp/bugs/model/bug.py
     def linked_branches(self):
         return [link.branch for link in self.linked_bugbranches]
 
@@ -2252,8 +2275,8 @@ class Bug(SQLBase, InformationTypeMixin):
         """See `IBug`."""
         if self.lock_status != BugLockStatus.UNLOCKED:
             old_lock_status = self.lock_status
-            self.lock_status = BugLockStatus.UNLOCKED
             self.lock_reason = None
+            self.lock_status = BugLockStatus.UNLOCKED
 
             self.addChange(
                 BugUnlocked(
diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py
index 57ad9fa..5e40c90 100644
--- a/lib/lp/bugs/security.py
+++ b/lib/lp/bugs/security.py
@@ -229,6 +229,21 @@ class ModerateBug(AuthorizationBase):
         )
 
 
+class ModerateBugTask(DelegatedAuthorization):
+    """
+    Security adapter for moderating bug tasks.
+
+    This has the same semantics as `ModerateBug`, but can be used where
+    the context is a bug task rather than a bug.
+    """
+
+    permission = 'launchpad.Moderate'
+    usedfor = IHasBug
+
+    def __init__(self, obj):
+        super().__init__(obj, obj.bug)
+
+
 class PublicToAllOrPrivateToExplicitSubscribersForBug(AuthorizationBase):
     permission = 'launchpad.View'
     usedfor = IBug
diff --git a/lib/lp/bugs/templates/bug-portlet-actions.pt b/lib/lp/bugs/templates/bug-portlet-actions.pt
index c2762fd..f6b9b24 100644
--- a/lib/lp/bugs/templates/bug-portlet-actions.pt
+++ b/lib/lp/bugs/templates/bug-portlet-actions.pt
@@ -81,4 +81,11 @@
       tal:condition="link/enabled"
       tal:content="structure link/render" />
   </ul>
+  <ul id="lock-status-actions">
+    <li
+        tal:define="link context_menu/change_lock_status"
+        tal:condition="python: link.enabled"
+        tal:content="structure context_menu/change_lock_status/render"
+    />
+  </ul>
 </div>
diff --git a/lib/lp/bugs/templates/bugtask-index.pt b/lib/lp/bugs/templates/bugtask-index.pt
index 287bb85..ac2d1ba 100644
--- a/lib/lp/bugs/templates/bugtask-index.pt
+++ b/lib/lp/bugs/templates/bugtask-index.pt
@@ -76,6 +76,7 @@
       <tal:reporter replace="structure context/bug/owner/fmt:link" />
       <tal:created
         replace="structure context/bug/datecreated/fmt:displaydatetitle" />
+      <span class="badge read-only inline-block" title="Locked" tal:condition="context/bug/locked"></span>
     </tal:registering>
 
     <metal:heading fill-slot="heading" tal:define="context_menu context/menu:context">

Follow ups