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

Proposed by Guruprasad
Status: Merged
Approved by: Guruprasad
Approved revision: 13a5d1f52e7feaadc0997d0bf9c78bac6b924a75
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~lgp171188/launchpad:add-ui-bug-lock-unlock
Merge into: launchpad:master
Diff against target: 696 lines (+548/-5)
9 files modified
lib/canonical/launchpad/icing/css/typography.scss (+9/-0)
lib/lp/bugs/browser/bug.py (+80/-3)
lib/lp/bugs/browser/configure.zcml (+7/-1)
lib/lp/bugs/browser/tests/test_edit_bug_lock_status.py (+414/-0)
lib/lp/bugs/interfaces/bug.py (+5/-0)
lib/lp/bugs/model/bug.py (+10/-1)
lib/lp/bugs/security.py (+15/-0)
lib/lp/bugs/templates/bug-portlet-actions.pt (+7/-0)
lib/lp/bugs/templates/bugtask-index.pt (+1/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+415453@code.launchpad.net

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.

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

For UI changes it would be awesome to have a screenshot. Usually we put the screenshot in our Mattermost channel and then add a link here at the MP.

Revision history for this message
Guruprasad (lgp171188) wrote :
Revision history for this message
Colin Watson (cjwatson) wrote :

This looks like really good work, thanks! Just a couple of small nits.

review: Approve
Revision history for this message
Jürgen Gmach (jugmac00) :
Revision history for this message
Guruprasad (lgp171188) :
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Guruprasad (lgp171188) wrote :

I have addressed all the open review comments and so I am updating the status of this merge proposal to 'Approved' now.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/canonical/launchpad/icing/css/typography.scss b/lib/canonical/launchpad/icing/css/typography.scss
2index 1bdd3a2..1e58f29 100644
3--- a/lib/canonical/launchpad/icing/css/typography.scss
4+++ b/lib/canonical/launchpad/icing/css/typography.scss
5@@ -58,6 +58,15 @@ h1, h2, h3, h4, h5, h6 {
6 max-width: 100%;
7 }
8
9+ .inline-block {
10+ display: inline-block;
11+ }
12+
13+ .badge.read-only {
14+ width: 20px;
15+ height: 16px;
16+ }
17+
18 table.wide {
19 width: $wider-page;
20 }
21diff --git a/lib/lp/bugs/browser/bug.py b/lib/lp/bugs/browser/bug.py
22index b67c65f..b1b798f 100644
23--- a/lib/lp/bugs/browser/bug.py
24+++ b/lib/lp/bugs/browser/bug.py
25@@ -1,4 +1,4 @@
26-# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
27+# Copyright 2009-2022 Canonical Ltd. This software is licensed under the
28 # GNU Affero General Public License version 3 (see the file LICENSE).
29
30 """IBug related view classes."""
31@@ -8,6 +8,7 @@ __all__ = [
32 'BugContextMenu',
33 'BugEditView',
34 'BugInformationTypePortletView',
35+ 'BugLockStatusEditView',
36 'BugMarkAsAffectingUserView',
37 'BugMarkAsDuplicateView',
38 'BugNavigation',
39@@ -76,7 +77,10 @@ from lp.app.widgets.product import GhostCheckBoxWidget
40 from lp.app.widgets.project import ProjectScopeWidget
41 from lp.bugs.browser.bugsubscription import BugPortletSubscribersWithDetails
42 from lp.bugs.browser.widgets.bug import BugTagsWidget
43-from lp.bugs.enums import BugNotificationLevel
44+from lp.bugs.enums import (
45+ BugLockStatus,
46+ BugNotificationLevel,
47+ )
48 from lp.bugs.interfaces.bug import (
49 IBug,
50 IBugSet,
51@@ -218,7 +222,7 @@ class BugContextMenu(ContextMenu):
52 'adddistro', 'subscription', 'addsubscriber', 'editsubscriptions',
53 'addcomment', 'nominate', 'addbranch', 'linktocve', 'unlinkcve',
54 'createquestion', 'mute_subscription', 'removequestion',
55- 'activitylog', 'affectsmetoo']
56+ 'activitylog', 'affectsmetoo', 'change_lock_status']
57
58 def __init__(self, context):
59 # Always force the context to be the current bugtask, so that we don't
60@@ -238,6 +242,12 @@ class BugContextMenu(ContextMenu):
61 text = 'Change privacy/security'
62 return Link('+secrecy', text)
63
64+ @enabled_with_permission('launchpad.Moderate')
65+ def change_lock_status(self):
66+ """Return the 'Change lock status' Link."""
67+ text = 'Change lock status'
68+ return Link('+lock-status', text, icon='edit')
69+
70 @enabled_with_permission('launchpad.Edit')
71 def markduplicate(self):
72 """Return the 'Mark as duplicate' Link."""
73@@ -766,6 +776,73 @@ class BugEditView(BugEditViewBase):
74 self.updateBugFromData(data)
75
76
77+class BugLockStatusEditView(LaunchpadEditFormView):
78+ """The view for editing the bug lock status and lock reason."""
79+
80+ class schema(Interface):
81+ # Duplicating the fields is necessary because these fields are
82+ # read-only in `IBug`.
83+ lock_status = copy_field(IBug['lock_status'], readonly=False)
84+ lock_reason = copy_field(IBug['lock_reason'], readonly=False)
85+
86+ @property
87+ def adapters(self):
88+ """See `LaunchpadFormView`."""
89+ return {self.schema: self.context.bug}
90+
91+ field_names = ['lock_status', 'lock_reason']
92+
93+ custom_widget_lock_status = LaunchpadRadioWidgetWithDescription
94+ custom_widget_lock_reason = CustomWidgetFactory(
95+ TextWidget,
96+ displayWidth=30
97+ )
98+
99+ def initialize(self):
100+ super().initialize()
101+ lock_status_widget = self.widgets['lock_status']
102+ lock_status_widget._displayItemForMissingValue = False
103+
104+ @property
105+ def label(self):
106+ """The form label."""
107+ return (
108+ "Edit the lock status and reason for bug #%d" % self.context.bug.id
109+ )
110+
111+ page_title = label
112+
113+ @action('Change', name='change')
114+ def change_action(self, action, data):
115+ """Update the bug lock status and reason with submitted changes."""
116+ bug = self.context.bug
117+ if bug.lock_status != data['lock_status']:
118+ locked_states = (
119+ BugLockStatus.COMMENT_ONLY,
120+ )
121+ if data['lock_status'] in locked_states:
122+ bug.lock(
123+ status=data['lock_status'],
124+ reason=data['lock_reason'],
125+ who=self.user
126+ )
127+ else:
128+ bug.unlock(who=self.user)
129+
130+ elif (bug.lock_status != BugLockStatus.UNLOCKED and
131+ bug.lock_reason != data['lock_reason']):
132+ bug.setLockReason(data['lock_reason'], who=self.user)
133+
134+ @property
135+ def next_url(self):
136+ """Return the next URL to call when this call completes."""
137+ if not self.request.is_ajax:
138+ return canonical_url(self.context)
139+ return None
140+
141+ cancel_url = next_url
142+
143+
144 class BugMarkAsDuplicateView(BugEditViewBase):
145 """Page for marking a bug as a duplicate."""
146
147diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
148index 85e1340..142d99a 100644
149--- a/lib/lp/bugs/browser/configure.zcml
150+++ b/lib/lp/bugs/browser/configure.zcml
151@@ -1,4 +1,4 @@
152-<!-- Copyright 2010-2021 Canonical Ltd. This software is licensed under the
153+<!-- Copyright 2010-2022 Canonical Ltd. This software is licensed under the
154 GNU Affero General Public License version 3 (see the file LICENSE).
155 -->
156
157@@ -507,6 +507,12 @@
158 template="../../app/templates/generic-edit.pt"
159 permission="launchpad.Edit"/>
160 <browser:page
161+ name="+lock-status"
162+ for="lp.bugs.interfaces.bug.IBugTask"
163+ class="lp.bugs.browser.bug.BugLockStatusEditView"
164+ template="../../app/templates/generic-edit.pt"
165+ permission="launchpad.Moderate"/>
166+ <browser:page
167 name="+delete"
168 for="lp.bugs.interfaces.bugtask.IBugTask"
169 class="lp.bugs.browser.bugtask.BugTaskDeletionView"
170diff --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
171new file mode 100644
172index 0000000..f387bb3
173--- /dev/null
174+++ b/lib/lp/bugs/browser/tests/test_edit_bug_lock_status.py
175@@ -0,0 +1,414 @@
176+# Copyright 2011-2022 Canonical Ltd. This software is licensed under the
177+# GNU Affero General Public License version 3 (see the file LICENSE).
178+
179+"""Tests related to the view for editing the bug lock status."""
180+
181+from soupmatchers import (
182+ HTMLContains,
183+ Tag,
184+ )
185+from testtools.matchers import (
186+ MatchesStructure,
187+ Not,
188+ )
189+from zope.security.interfaces import Unauthorized
190+
191+from lp.bugs.enums import BugLockStatus
192+from lp.services.webapp import canonical_url
193+from lp.services.webapp.servers import LaunchpadTestRequest
194+from lp.testing import (
195+ anonymous_logged_in,
196+ BrowserTestCase,
197+ person_logged_in,
198+ TestCaseWithFactory,
199+ )
200+from lp.testing.layers import DatabaseFunctionalLayer
201+from lp.testing.views import create_initialized_view
202+
203+
204+class TestBugLockStatusEditView(TestCaseWithFactory):
205+ """
206+ Tests for the view to edit the bug lock status.
207+ """
208+
209+ layer = DatabaseFunctionalLayer
210+
211+ def setUp(self):
212+ super().setUp()
213+ self.person = self.factory.makePerson()
214+ self.target = self.factory.makeProduct()
215+
216+ def test_form_submission_missing_required_fields(self):
217+ bug = self.factory.makeBug(target=self.target)
218+ form = {
219+ 'a': 1,
220+ 'b': 2,
221+ }
222+ with person_logged_in(self.target.owner):
223+ request = LaunchpadTestRequest(
224+ method='POST',
225+ form=form,
226+ )
227+ view = create_initialized_view(
228+ bug.default_bugtask,
229+ name='+lock-status',
230+ request=request
231+ )
232+ self.assertEqual([], view.errors)
233+
234+ self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
235+ self.assertIsNone(bug.lock_reason)
236+
237+ def test_users_without_moderate_permission_cannot_edit_lock_status(self):
238+ bug = self.factory.makeBug(target=self.target)
239+ with person_logged_in(self.target.owner):
240+ bug.lock(who=self.target.owner, status=BugLockStatus.COMMENT_ONLY)
241+
242+ form = {
243+ "field.actions.change": "Change",
244+ "field.lock_status": "Unlocked",
245+ "field.lock_reason": "",
246+ "field.lock_status-empty-marker": "1",
247+ }
248+
249+ with anonymous_logged_in():
250+ self.assertRaises(
251+ Unauthorized, create_initialized_view,
252+ bug.default_bugtask, name='+lock-status',
253+ form=form
254+ )
255+
256+ with person_logged_in(self.person):
257+ self.assertRaises(
258+ Unauthorized, create_initialized_view,
259+ bug.default_bugtask, name='+lock-status',
260+ form=form
261+ )
262+
263+ def test_locking_a_locked_bug(self):
264+ bug = self.factory.makeBug(target=self.target)
265+ with person_logged_in(self.target.owner):
266+ bug.lock(who=self.target.owner, status=BugLockStatus.COMMENT_ONLY)
267+
268+ self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
269+ self.assertIsNone(bug.lock_reason)
270+
271+ form = {
272+ "field.actions.change": "Change",
273+ "field.lock_status": "Comment-only",
274+ "field.lock_reason": "",
275+ "field.lock_status-empty-marker": "1",
276+ }
277+ with person_logged_in(self.target.owner):
278+ request = LaunchpadTestRequest(
279+ method='POST',
280+ form=form,
281+ )
282+ view = create_initialized_view(
283+ bug.default_bugtask,
284+ name='+lock-status',
285+ request=request
286+ )
287+ self.assertEqual([], view.errors)
288+
289+ self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
290+ self.assertIsNone(bug.lock_reason)
291+
292+ def test_unlocking_an_unlocked_bug(self):
293+ bug = self.factory.makeBug(target=self.target)
294+
295+ self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
296+ self.assertIsNone(bug.lock_reason)
297+
298+ form = {
299+ "field.actions.change": "Change",
300+ "field.lock_status": "Unlocked",
301+ "field.lock_reason": "too hot",
302+ "field.lock_status-empty-marker": "1",
303+ }
304+ with person_logged_in(self.target.owner):
305+ request = LaunchpadTestRequest(
306+ method='POST',
307+ form=form,
308+ )
309+ view = create_initialized_view(
310+ bug.default_bugtask,
311+ name='+lock-status',
312+ request=request
313+ )
314+ self.assertEqual([], view.errors)
315+
316+ self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
317+ self.assertIsNone(bug.lock_reason)
318+
319+ def test_unlocking_a_bug_locked_with_reason_clears_the_reason(self):
320+ bug = self.factory.makeBug(target=self.target)
321+
322+ with person_logged_in(self.target.owner):
323+ bug.lock(
324+ who=self.target.owner,
325+ status=BugLockStatus.COMMENT_ONLY,
326+ reason='too hot'
327+ )
328+ self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
329+ self.assertEqual('too hot', bug.lock_reason)
330+
331+ form = {
332+ "field.actions.change": "Change",
333+ "field.lock_status": "Unlocked",
334+ "field.lock_reason": "too hot!",
335+ "field.lock_status-empty-marker": "1",
336+ }
337+ with person_logged_in(self.target.owner):
338+ request = LaunchpadTestRequest(
339+ method='POST',
340+ form=form,
341+ )
342+ view = create_initialized_view(
343+ bug.default_bugtask,
344+ name='+lock-status',
345+ request=request
346+ )
347+ self.assertEqual([], view.errors)
348+
349+ self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
350+ self.assertIsNone(bug.lock_reason)
351+
352+ def test_locking_an_unlocked_bug(self):
353+ bug = self.factory.makeBug(target=self.target)
354+
355+ self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
356+ self.assertIsNone(bug.lock_reason)
357+ self.assertEqual(1, bug.activity.count())
358+
359+ form = {
360+ "field.actions.change": "Change",
361+ "field.lock_status": "Comment-only",
362+ "field.lock_reason": "too hot",
363+ "field.lock_status-empty-marker": "1",
364+ }
365+ with person_logged_in(self.target.owner):
366+ request = LaunchpadTestRequest(
367+ method='POST',
368+ form=form,
369+ )
370+ view = create_initialized_view(
371+ bug.default_bugtask,
372+ name='+lock-status',
373+ request=request
374+ )
375+ self.assertEqual([], view.errors)
376+
377+ self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
378+ self.assertEqual('too hot', bug.lock_reason)
379+ self.assertEqual(2, bug.activity.count())
380+ self.assertThat(
381+ bug.activity[1],
382+ MatchesStructure.byEquality(
383+ person=self.target.owner,
384+ whatchanged='lock status',
385+ oldvalue=str(BugLockStatus.UNLOCKED),
386+ newvalue=str(BugLockStatus.COMMENT_ONLY),
387+ )
388+ )
389+
390+ def test_unlocking_a_locked_bug(self):
391+ bug = self.factory.makeBug(target=self.target)
392+ self.assertEqual(1, bug.activity.count())
393+
394+ with person_logged_in(self.target.owner):
395+ bug.lock(
396+ who=self.target.owner,
397+ status=BugLockStatus.COMMENT_ONLY,
398+ reason='too hot'
399+ )
400+ self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
401+ self.assertEqual('too hot', bug.lock_reason)
402+ self.assertEqual(2, bug.activity.count())
403+
404+ form = {
405+ "field.actions.change": "Change",
406+ "field.lock_status": "Unlocked",
407+ "field.lock_reason": "too hot!!",
408+ "field.lock_status-empty-marker": "1",
409+ }
410+ with person_logged_in(self.target.owner):
411+ request = LaunchpadTestRequest(
412+ method='POST',
413+ form=form,
414+ )
415+ view = create_initialized_view(
416+ bug.default_bugtask,
417+ name='+lock-status',
418+ request=request
419+ )
420+ self.assertEqual([], view.errors)
421+
422+ self.assertEqual(BugLockStatus.UNLOCKED, bug.lock_status)
423+ self.assertIsNone(bug.lock_reason)
424+ self.assertEqual(3, bug.activity.count())
425+ self.assertThat(
426+ bug.activity[2],
427+ MatchesStructure.byEquality(
428+ person=self.target.owner,
429+ whatchanged='lock status',
430+ oldvalue=str(BugLockStatus.COMMENT_ONLY),
431+ newvalue=str(BugLockStatus.UNLOCKED),
432+ )
433+ )
434+
435+ def test_changing_lock_reason_of_a_locked_bug(self):
436+ bug = self.factory.makeBug(target=self.target)
437+ self.assertEqual(1, bug.activity.count())
438+
439+ with person_logged_in(self.target.owner):
440+ bug.lock(
441+ who=self.target.owner,
442+ status=BugLockStatus.COMMENT_ONLY,
443+ reason='too hot'
444+ )
445+ self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
446+ self.assertEqual('too hot', bug.lock_reason)
447+ self.assertEqual(2, bug.activity.count())
448+
449+ form = {
450+ "field.actions.change": "Change",
451+ "field.lock_status": "Comment-only",
452+ "field.lock_reason": "too hot!",
453+ "field.lock_status-empty-marker": "1",
454+ }
455+ with person_logged_in(self.target.owner):
456+ request = LaunchpadTestRequest(
457+ method='POST',
458+ form=form,
459+ )
460+ view = create_initialized_view(
461+ bug.default_bugtask,
462+ name='+lock-status',
463+ request=request
464+ )
465+ self.assertEqual([], view.errors)
466+
467+ self.assertEqual(BugLockStatus.COMMENT_ONLY, bug.lock_status)
468+ self.assertEqual('too hot!', bug.lock_reason)
469+ self.assertEqual(3, bug.activity.count())
470+ self.assertThat(
471+ bug.activity[2],
472+ MatchesStructure.byEquality(
473+ person=self.target.owner,
474+ whatchanged='lock reason',
475+ oldvalue='too hot',
476+ newvalue='too hot!',
477+ )
478+ )
479+
480+class TestBugLockFeatures(BrowserTestCase):
481+ """Test for the features related to the locking, unlocking a bug."""
482+
483+ layer = DatabaseFunctionalLayer
484+
485+ def setUp(self):
486+ super().setUp()
487+ self.person = self.factory.makePerson()
488+ self.target = self.factory.makeProduct()
489+
490+ def test_bug_lock_status_page_not_linked_for_non_moderators(self):
491+ bug = self.factory.makeBug(target=self.target)
492+ bugtask_url = canonical_url(bug.default_bugtask)
493+ browser = self.getUserBrowser(
494+ bugtask_url,
495+ user=self.person,
496+ )
497+ self.assertThat(
498+ browser.contents,
499+ Not(
500+ HTMLContains(
501+ Tag(
502+ 'change lock status link tag',
503+ 'a',
504+ text='Change lock status',
505+ attrs={
506+ 'class': "edit",
507+ 'href': '{}/+lock-status'.format(bugtask_url),
508+ }
509+ )
510+ )
511+ )
512+ )
513+
514+ def test_bug_lock_status_page_linked_for_moderators(self):
515+ bug = self.factory.makeBug(target=self.target)
516+ bugtask_url = canonical_url(bug.default_bugtask)
517+
518+ browser = self.getUserBrowser(
519+ bugtask_url,
520+ user=self.target.owner,
521+ )
522+ self.assertThat(
523+ browser.contents,
524+ HTMLContains(
525+ Tag(
526+ 'change lock status link tag',
527+ 'a',
528+ text='Change lock status',
529+ attrs={
530+ 'class': "edit",
531+ 'href': '{}/+lock-status'.format(bugtask_url),
532+ }
533+ )
534+ )
535+ )
536+
537+ def test_bug_readonly_icon_displayed_when_bug_is_locked(self):
538+ bug = self.factory.makeBug(target=self.target)
539+ with person_logged_in(self.target.owner):
540+ bug.lock(
541+ who=self.target.owner,
542+ status=BugLockStatus.COMMENT_ONLY,
543+ reason='too hot'
544+ )
545+
546+ bugtask_url = canonical_url(bug.default_bugtask)
547+
548+ browser = self.getUserBrowser(
549+ bugtask_url,
550+ user=self.target.owner,
551+ )
552+ self.assertThat(
553+ browser.contents,
554+ HTMLContains(
555+ Tag(
556+ 'read-only icon tag',
557+ 'span',
558+ attrs={
559+ 'class': 'read-only',
560+ 'title': 'Locked'
561+ }
562+ )
563+ )
564+ )
565+
566+ def test_bug_readonly_icon_not_displayed_when_bug_is_unlocked(self):
567+ bug = self.factory.makeBug(target=self.target)
568+
569+ bugtask_url = canonical_url(bug.default_bugtask)
570+
571+ browser = self.getUserBrowser(
572+ bugtask_url,
573+ user=self.target.owner,
574+ )
575+ self.assertThat(
576+ browser.contents,
577+ Not(
578+ HTMLContains(
579+ Tag(
580+ 'read-only icon tag',
581+ 'span',
582+ attrs={
583+ 'class': 'read-only',
584+ 'title': 'Locked'
585+ }
586+ )
587+ )
588+ )
589+ )
590diff --git a/lib/lp/bugs/interfaces/bug.py b/lib/lp/bugs/interfaces/bug.py
591index 7459bd5..40a2703 100644
592--- a/lib/lp/bugs/interfaces/bug.py
593+++ b/lib/lp/bugs/interfaces/bug.py
594@@ -387,6 +387,11 @@ class IBugView(Interface):
595 readonly=True,
596 value_type=Reference(schema=IMessage)),
597 exported_as='messages'))
598+ locked = Bool(
599+ title=_('Locked?'),
600+ description=_('Is this bug locked?'),
601+ readonly=True
602+ )
603 lock_status = exported(
604 Choice(
605 title=_('Lock Status'), vocabulary=BugLockStatus,
606diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py
607index f663baa..f3b08a6 100644
608--- a/lib/lp/bugs/model/bug.py
609+++ b/lib/lp/bugs/model/bug.py
610@@ -107,6 +107,7 @@ from lp.bugs.adapters.bugchange import (
611 UnsubscribedFromBug,
612 )
613 from lp.bugs.enums import (
614+ BugLockedStatus,
615 BugLockStatus,
616 BugNotificationLevel,
617 )
618@@ -414,6 +415,14 @@ class Bug(SQLBase, InformationTypeMixin):
619 lock_reason = StringCol(notNull=False, default=None)
620
621 @property
622+ def locked(self):
623+ try:
624+ BugLockedStatus.items[self.lock_status.value]
625+ return True
626+ except KeyError:
627+ return False
628+
629+ @property
630 def linked_branches(self):
631 return [link.branch for link in self.linked_bugbranches]
632
633@@ -2252,8 +2261,8 @@ class Bug(SQLBase, InformationTypeMixin):
634 """See `IBug`."""
635 if self.lock_status != BugLockStatus.UNLOCKED:
636 old_lock_status = self.lock_status
637- self.lock_status = BugLockStatus.UNLOCKED
638 self.lock_reason = None
639+ self.lock_status = BugLockStatus.UNLOCKED
640
641 self.addChange(
642 BugUnlocked(
643diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py
644index 57ad9fa..5e40c90 100644
645--- a/lib/lp/bugs/security.py
646+++ b/lib/lp/bugs/security.py
647@@ -229,6 +229,21 @@ class ModerateBug(AuthorizationBase):
648 )
649
650
651+class ModerateBugTask(DelegatedAuthorization):
652+ """
653+ Security adapter for moderating bug tasks.
654+
655+ This has the same semantics as `ModerateBug`, but can be used where
656+ the context is a bug task rather than a bug.
657+ """
658+
659+ permission = 'launchpad.Moderate'
660+ usedfor = IHasBug
661+
662+ def __init__(self, obj):
663+ super().__init__(obj, obj.bug)
664+
665+
666 class PublicToAllOrPrivateToExplicitSubscribersForBug(AuthorizationBase):
667 permission = 'launchpad.View'
668 usedfor = IBug
669diff --git a/lib/lp/bugs/templates/bug-portlet-actions.pt b/lib/lp/bugs/templates/bug-portlet-actions.pt
670index c2762fd..f6b9b24 100644
671--- a/lib/lp/bugs/templates/bug-portlet-actions.pt
672+++ b/lib/lp/bugs/templates/bug-portlet-actions.pt
673@@ -81,4 +81,11 @@
674 tal:condition="link/enabled"
675 tal:content="structure link/render" />
676 </ul>
677+ <ul id="lock-status-actions">
678+ <li
679+ tal:define="link context_menu/change_lock_status"
680+ tal:condition="python: link.enabled"
681+ tal:content="structure context_menu/change_lock_status/render"
682+ />
683+ </ul>
684 </div>
685diff --git a/lib/lp/bugs/templates/bugtask-index.pt b/lib/lp/bugs/templates/bugtask-index.pt
686index 287bb85..ac2d1ba 100644
687--- a/lib/lp/bugs/templates/bugtask-index.pt
688+++ b/lib/lp/bugs/templates/bugtask-index.pt
689@@ -76,6 +76,7 @@
690 <tal:reporter replace="structure context/bug/owner/fmt:link" />
691 <tal:created
692 replace="structure context/bug/datecreated/fmt:displaydatetitle" />
693+ <span class="badge read-only inline-block" title="Locked" tal:condition="context/bug/locked"></span>
694 </tal:registering>
695
696 <metal:heading fill-slot="heading" tal:define="context_menu context/menu:context">

Subscribers

People subscribed via source and target branches

to status/vote changes: