Merge ~lgp171188/launchpad:add-ui-bug-lock-unlock into launchpad:master
- Git
- lp:~lgp171188/launchpad
- add-ui-bug-lock-unlock
- Merge into 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) |
Related bugs: |
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.
be able to access the page and change the lock status and reason.
Also display a read-only icon for the locked bugs.
Description of the change
To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote : | # |
Revision history for this message
Guruprasad (lgp171188) wrote : | # |
Here are the screenshots illustrating the UI changes.
https:/
https:/
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
1 | diff --git a/lib/canonical/launchpad/icing/css/typography.scss b/lib/canonical/launchpad/icing/css/typography.scss |
2 | index 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 | } |
21 | diff --git a/lib/lp/bugs/browser/bug.py b/lib/lp/bugs/browser/bug.py |
22 | index 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 | |
147 | diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml |
148 | index 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" |
170 | 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 |
171 | new file mode 100644 |
172 | index 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 | + ) |
590 | diff --git a/lib/lp/bugs/interfaces/bug.py b/lib/lp/bugs/interfaces/bug.py |
591 | index 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, |
606 | diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py |
607 | index 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( |
643 | diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py |
644 | index 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 |
669 | diff --git a/lib/lp/bugs/templates/bug-portlet-actions.pt b/lib/lp/bugs/templates/bug-portlet-actions.pt |
670 | index 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> |
685 | diff --git a/lib/lp/bugs/templates/bugtask-index.pt b/lib/lp/bugs/templates/bugtask-index.pt |
686 | index 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"> |
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.