Merge lp:~wgrant/launchpad/export-bug-nominations into lp:launchpad
- export-bug-nominations
- Merge into devel
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Jeroen T. Vermeulen | ||||
Approved revision: | no longer in the source branch. | ||||
Merged at revision: | not available | ||||
Proposed branch: | lp:~wgrant/launchpad/export-bug-nominations | ||||
Merge into: | lp:launchpad | ||||
Diff against target: | None lines | ||||
To merge this branch: | bzr merge lp:~wgrant/launchpad/export-bug-nominations | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jeroen T. Vermeulen (community) | Approve | ||
Review via email: mp+10715@code.launchpad.net |
Commit message
Description of the change
William Grant (wgrant) wrote : | # |
Jeroen T. Vermeulen (jtv) wrote : | # |
Well done!
Good branch, though there are some little warts that came up on IRC and
which you fixed interactively:
* Don't return None when looking up a malformed id if a well-formed id
that doesn't belong to any bug nomination reaises NotFoundError.
* Don't over-test isApproved() in the doctest, though it was fine as a
transitional check during development. It's already covered in
bug-
* Better not export canApprove() for any person, because it may expose
private team memberships. Just make it apply to the current user:
"Can I approve this?"
* Test tearDown belongs just below setUp.
* Don't derive a test case from another, but lift the commonality up
into a mixin class.
* You inherited a few small pieces of lint; should be easy to clean up.
With that out of the way, r=me.
Jeroen
Preview Diff
1 | === modified file 'lib/canonical/launchpad/interfaces/_schema_circular_imports.py' |
2 | --- lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2009-08-08 06:45:42 +0000 |
3 | +++ lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2009-08-24 12:06:17 +0000 |
4 | @@ -24,6 +24,7 @@ |
5 | |
6 | from lp.bugs.interfaces.bug import IBug |
7 | from lp.bugs.interfaces.bugbranch import IBugBranch |
8 | +from lp.bugs.interfaces.bugnomination import IBugNomination |
9 | from lp.bugs.interfaces.bugtask import IBugTask |
10 | from lp.bugs.interfaces.bugtarget import IHasBugs |
11 | from lp.soyuz.interfaces.build import ( |
12 | @@ -118,6 +119,12 @@ |
13 | IBug, 'unlinkHWSubmission', 'submission', IHWSubmission) |
14 | patch_collection_return_type( |
15 | IBug, 'getHWSubmissions', IHWSubmission) |
16 | +IBug['getNominations'].queryTaggedValue( |
17 | + LAZR_WEBSERVICE_EXPORTED)['params']['nominations'].value_type.schema = ( |
18 | + IBugNomination) |
19 | +patch_entry_return_type(IBug, 'addNomination', IBugNomination) |
20 | +patch_entry_return_type(IBug, 'getNominationFor', IBugNomination) |
21 | +patch_collection_return_type(IBug, 'getNominations', IBugNomination) |
22 | |
23 | patch_choice_parameter_type( |
24 | IHasBugs, 'searchTasks', 'hardware_bus', HWBus) |
25 | |
26 | === modified file 'lib/lp/bugs/browser/bug.py' |
27 | --- lib/lp/bugs/browser/bug.py 2009-08-21 15:33:18 +0000 |
28 | +++ lib/lp/bugs/browser/bug.py 2009-08-23 04:53:33 +0000 |
29 | @@ -58,6 +58,7 @@ |
30 | from lp.bugs.interfaces.bugwatch import IBugWatchSet |
31 | from lp.bugs.interfaces.cve import ICveSet |
32 | from lp.bugs.interfaces.bugattachment import IBugAttachmentSet |
33 | +from lp.bugs.interfaces.bugnomination import IBugNominationSet |
34 | |
35 | from canonical.launchpad.mailnotification import ( |
36 | MailWrapper, format_rfc2822_date) |
37 | @@ -126,6 +127,13 @@ |
38 | if attachment is not None and attachment.bug == self.context: |
39 | return attachment |
40 | |
41 | + @stepthrough('nominations') |
42 | + def traverse_nominations(self, nomination_id): |
43 | + """Traverse to a nomination by id.""" |
44 | + if not nomination_id.isdigit(): |
45 | + return None |
46 | + return getUtility(IBugNominationSet).get(nomination_id) |
47 | + |
48 | |
49 | class BugFacets(StandardLaunchpadFacets): |
50 | """The links that will appear in the facet menu for an `IBug`. |
51 | |
52 | === modified file 'lib/lp/bugs/browser/bugnomination.py' |
53 | --- lib/lp/bugs/browser/bugnomination.py 2009-06-25 00:40:31 +0000 |
54 | +++ lib/lp/bugs/browser/bugnomination.py 2009-08-24 10:45:09 +0000 |
55 | @@ -105,8 +105,9 @@ |
56 | target=series, owner=self.user) |
57 | |
58 | # If the user has the permission to approve the nomination, |
59 | - # then nomination was approved automatically. |
60 | - if nomination.isApproved(): |
61 | + # we approve it automatically. |
62 | + if nomination.canApprove(self.user): |
63 | + nomination.approve(self.user) |
64 | approved_nominations.append( |
65 | nomination.target.bugtargetdisplayname) |
66 | else: |
67 | |
68 | === modified file 'lib/lp/bugs/browser/tests/bugtask-edit-views.txt' |
69 | --- lib/lp/bugs/browser/tests/bugtask-edit-views.txt 2009-08-13 19:03:36 +0000 |
70 | +++ lib/lp/bugs/browser/tests/bugtask-edit-views.txt 2009-08-24 10:45:09 +0000 |
71 | @@ -126,8 +126,7 @@ |
72 | >>> ubuntu_grumpy = ubuntu.getSeries('grumpy') |
73 | >>> nomination = bug_two.addNomination( |
74 | ... target=ubuntu_grumpy, owner=getUtility(ILaunchBag).user) |
75 | - >>> nomination.isApproved() |
76 | - True |
77 | + >>> nomination.approve(getUtility(ILaunchBag).user) |
78 | >>> transaction.commit() |
79 | |
80 | >>> ubuntu_grumpy_task = bug_two.bugtasks[5] |
81 | |
82 | === modified file 'lib/lp/bugs/doc/bug-nomination.txt' |
83 | --- lib/lp/bugs/doc/bug-nomination.txt 2009-06-13 19:58:26 +0000 |
84 | +++ lib/lp/bugs/doc/bug-nomination.txt 2009-08-26 02:33:29 +0000 |
85 | @@ -296,8 +296,8 @@ |
86 | thunderbird (Ubuntu Grumpy) |
87 | thunderbird (Ubuntu) |
88 | |
89 | -If the one nominating a goal is a driver of the series, the |
90 | -nomination will be automatically approved. |
91 | +Let's now nominate for Warty. no_privs is the driver, so will have |
92 | +no problems. |
93 | |
94 | >>> ubuntu_warty = ubuntu.getSeries("warty") |
95 | >>> login("foo.bar@canonical.com") |
96 | @@ -306,6 +306,7 @@ |
97 | |
98 | >>> warty_nomination = bug_one.addNomination( |
99 | ... target=ubuntu_warty, owner=no_privs) |
100 | + >>> warty_nomination.approve(no_privs) |
101 | |
102 | >>> print warty_nomination.status.title |
103 | Approved |
104 | @@ -636,18 +637,18 @@ |
105 | NominationError: ... |
106 | |
107 | Nominating a bug for an obsolete distroseries raises a |
108 | -NominationSeriesObsoleteError. Let's mark warty obsolete to |
109 | -demonstrate. |
110 | +NominationSeriesObsoleteError. Let's make a new obsolete distroseries |
111 | +to demonstrate. |
112 | |
113 | >>> from canonical.launchpad.interfaces import DistroSeriesStatus |
114 | |
115 | -(Temporarily log in as an admin user to change the series status.) |
116 | - |
117 | >>> login("foo.bar@canonical.com") |
118 | - >>> ubuntu_warty.status = DistroSeriesStatus.OBSOLETE |
119 | + >>> ubuntu_edgy = factory.makeDistroRelease( |
120 | + ... distribution=ubuntu, version='6.10', |
121 | + ... status=DistroSeriesStatus.OBSOLETE) |
122 | >>> login("no-priv@canonical.com") |
123 | |
124 | - >>> bug_one.addNomination(target=ubuntu_warty, owner=no_privs) |
125 | + >>> bug_one.addNomination(target=ubuntu_edgy, owner=no_privs) |
126 | Traceback (most recent call last): |
127 | .. |
128 | NominationSeriesObsoleteError: ... |
129 | |
130 | === modified file 'lib/lp/bugs/doc/bug-set-status.txt' |
131 | --- lib/lp/bugs/doc/bug-set-status.txt 2009-06-12 16:36:02 +0000 |
132 | +++ lib/lp/bugs/doc/bug-set-status.txt 2009-08-24 10:45:09 +0000 |
133 | @@ -225,6 +225,9 @@ |
134 | >>> nomination = bug.addNomination( |
135 | ... getUtility(ILaunchBag).user, ubuntu_hoary) |
136 | >>> nomination.isApproved() |
137 | + False |
138 | + >>> nomination.approve(getUtility(ILaunchBag).user) |
139 | + >>> nomination.isApproved() |
140 | True |
141 | >>> login('no-priv@canonical.com') |
142 | |
143 | @@ -247,6 +250,9 @@ |
144 | >>> nomination = bug.addNomination( |
145 | ... getUtility(ILaunchBag).user, ubuntu_warty) |
146 | >>> nomination.isApproved() |
147 | + False |
148 | + >>> nomination.approve(getUtility(ILaunchBag).user) |
149 | + >>> nomination.isApproved() |
150 | True |
151 | >>> login('no-priv@canonical.com') |
152 | |
153 | |
154 | === modified file 'lib/lp/bugs/doc/bugtask.txt' |
155 | --- lib/lp/bugs/doc/bugtask.txt 2009-08-13 19:03:36 +0000 |
156 | +++ lib/lp/bugs/doc/bugtask.txt 2009-08-24 10:45:09 +0000 |
157 | @@ -1123,8 +1123,7 @@ |
158 | ... sourcepackagenameset.queryByName('mozilla-firefox'))) |
159 | <BugTask at ...> |
160 | |
161 | - >>> new_bug.addNomination(mark, ubuntu.currentseries) |
162 | - <BugNomination at ...> |
163 | + >>> new_bug.addNomination(mark, ubuntu.currentseries).approve(mark) |
164 | |
165 | The first task has been created and successfully nominated to Hoary. |
166 | |
167 | |
168 | === modified file 'lib/lp/bugs/interfaces/bug.py' |
169 | --- lib/lp/bugs/interfaces/bug.py 2009-08-06 14:36:45 +0000 |
170 | +++ lib/lp/bugs/interfaces/bug.py 2009-08-24 12:06:17 +0000 |
171 | @@ -559,20 +559,24 @@ |
172 | distroseries=None): |
173 | """Create an INullBugTask and return it for the given parameters.""" |
174 | |
175 | + @operation_parameters( |
176 | + target=Reference(schema=IBugTarget, title=_('Target'))) |
177 | + @call_with(owner=REQUEST_USER) |
178 | + @export_factory_operation(Interface, []) |
179 | def addNomination(owner, target): |
180 | """Nominate a bug for an IDistroSeries or IProductSeries. |
181 | |
182 | :owner: An IPerson. |
183 | :target: An IDistroSeries or IProductSeries. |
184 | |
185 | - The nomination will be automatically approved, if the user has |
186 | - permission to approve it. |
187 | - |
188 | This method creates and returns a BugNomination. (See |
189 | lp.bugs.model.bugnomination.BugNomination.) |
190 | """ |
191 | |
192 | - def canBeNominatedFor(nomination_target): |
193 | + @operation_parameters( |
194 | + target=Reference(schema=IBugTarget, title=_('Target'))) |
195 | + @export_read_operation() |
196 | + def canBeNominatedFor(target): |
197 | """Can this bug nominated for this target? |
198 | |
199 | :nomination_target: An IDistroSeries or IProductSeries. |
200 | @@ -580,7 +584,11 @@ |
201 | Returns True or False. |
202 | """ |
203 | |
204 | - def getNominationFor(nomination_target): |
205 | + @operation_parameters( |
206 | + target=Reference(schema=IBugTarget, title=_('Target'))) |
207 | + @operation_returns_entry(Interface) |
208 | + @export_read_operation() |
209 | + def getNominationFor(target): |
210 | """Return the IBugNomination for the target. |
211 | |
212 | If no nomination is found, a NotFoundError is raised. |
213 | @@ -588,6 +596,15 @@ |
214 | :param nomination_target: An IDistroSeries or IProductSeries. |
215 | """ |
216 | |
217 | + @operation_parameters( |
218 | + target=Reference( |
219 | + schema=IBugTarget, title=_('Target'), required=False), |
220 | + nominations=List( |
221 | + title=_("Nominations to search through."), |
222 | + value_type=Reference(schema=Interface), # IBugNomination |
223 | + required=False)) |
224 | + @operation_returns_collection_of(Interface) # IBugNomination |
225 | + @export_read_operation() |
226 | def getNominations(target=None, nominations=None): |
227 | """Return a list of all IBugNominations for this bug. |
228 | |
229 | |
230 | === modified file 'lib/lp/bugs/interfaces/bugnomination.py' |
231 | --- lib/lp/bugs/interfaces/bugnomination.py 2009-07-17 00:26:05 +0000 |
232 | +++ lib/lp/bugs/interfaces/bugnomination.py 2009-08-26 03:56:55 +0000 |
233 | @@ -19,24 +19,38 @@ |
234 | from zope.schema import Int, Datetime, Choice, Set |
235 | from zope.interface import Interface, Attribute |
236 | from lazr.enum import DBEnumeratedType, DBItem |
237 | +from lazr.restful.declarations import ( |
238 | + REQUEST_USER, call_with, export_as_webservice_entry, |
239 | + export_read_operation, export_write_operation, exported, |
240 | + operation_parameters, webservice_error) |
241 | +from lazr.restful.fields import Reference, ReferenceChoice |
242 | |
243 | from canonical.launchpad import _ |
244 | from canonical.launchpad.fields import PublicPersonChoice |
245 | from canonical.launchpad.interfaces.launchpad import IHasBug, IHasDateCreated |
246 | +from lp.bugs.interfaces.bug import IBug |
247 | +from lp.bugs.interfaces.bugtarget import IBugTarget |
248 | +from lp.registry.interfaces.distroseries import IDistroSeries |
249 | +from lp.registry.interfaces.productseries import IProductSeries |
250 | +from lp.registry.interfaces.person import IPerson |
251 | from lp.registry.interfaces.role import IHasOwner |
252 | from canonical.launchpad.interfaces.validation import ( |
253 | can_be_nominated_for_serieses) |
254 | |
255 | + |
256 | class NominationError(Exception): |
257 | """The bug cannot be nominated for this release.""" |
258 | + webservice_error(400) |
259 | |
260 | |
261 | class NominationSeriesObsoleteError(Exception): |
262 | """A bug cannot be nominated for an obsolete series.""" |
263 | + webservice_error(400) |
264 | |
265 | |
266 | class BugNominationStatusError(Exception): |
267 | """A error occurred while trying to set a bug nomination status.""" |
268 | + webservice_error(400) |
269 | |
270 | |
271 | class BugNominationStatus(DBEnumeratedType): |
272 | @@ -72,38 +86,43 @@ |
273 | |
274 | A nomination can apply to an IDistroSeries or an IProductSeries. |
275 | """ |
276 | + export_as_webservice_entry() |
277 | + |
278 | # We want to customize the titles and descriptions of some of the |
279 | # attributes of our parent interfaces, so we redefine those specific |
280 | # attributes below. |
281 | id = Int(title=_("Bug Nomination #")) |
282 | - bug = Int(title=_("Bug #")) |
283 | - date_created = Datetime( |
284 | + bug = exported(Reference(schema=IBug, readonly=True)) |
285 | + date_created = exported(Datetime( |
286 | title=_("Date Submitted"), |
287 | description=_("The date on which this nomination was submitted."), |
288 | - required=True, readonly=True) |
289 | - date_decided = Datetime( |
290 | + required=True, readonly=True)) |
291 | + date_decided = exported(Datetime( |
292 | title=_("Date Decided"), |
293 | description=_( |
294 | "The date on which this nomination was approved or declined."), |
295 | - required=False, readonly=True) |
296 | - distroseries = Choice( |
297 | - title=_("Series"), required=False, |
298 | - vocabulary="DistroSeries") |
299 | - productseries = Choice( |
300 | - title=_("Series"), required=False, |
301 | - vocabulary="ProductSeries") |
302 | - owner = PublicPersonChoice( |
303 | + required=False, readonly=True)) |
304 | + distroseries = exported(ReferenceChoice( |
305 | + title=_("Series"), required=False, readonly=True, |
306 | + vocabulary="DistroSeries", schema=IDistroSeries)) |
307 | + productseries = exported(ReferenceChoice( |
308 | + title=_("Series"), required=False, readonly=True, |
309 | + vocabulary="ProductSeries", schema=IProductSeries)) |
310 | + owner = exported(PublicPersonChoice( |
311 | title=_('Submitter'), required=True, readonly=True, |
312 | - vocabulary='ValidPersonOrTeam') |
313 | - decider = PublicPersonChoice( |
314 | + vocabulary='ValidPersonOrTeam')) |
315 | + decider = exported(PublicPersonChoice( |
316 | title=_('Decided By'), required=False, readonly=True, |
317 | - vocabulary='ValidPersonOrTeam') |
318 | - target = Attribute( |
319 | - "The IProductSeries or IDistroSeries of this nomination.") |
320 | - status = Choice( |
321 | + vocabulary='ValidPersonOrTeam')) |
322 | + target = exported(Reference( |
323 | + schema=IBugTarget, |
324 | + title=_("The IProductSeries or IDistroSeries of this nomination."))) |
325 | + status = exported(Choice( |
326 | title=_("Status"), vocabulary=BugNominationStatus, |
327 | - default=BugNominationStatus.PROPOSED) |
328 | + default=BugNominationStatus.PROPOSED, readonly=True)) |
329 | |
330 | + @call_with(approver=REQUEST_USER) |
331 | + @export_write_operation() |
332 | def approve(approver): |
333 | """Approve this bug for fixing in a series. |
334 | |
335 | @@ -117,6 +136,8 @@ |
336 | /already/ approved, this method is a noop. |
337 | """ |
338 | |
339 | + @call_with(decliner=REQUEST_USER) |
340 | + @export_write_operation() |
341 | def decline(decliner): |
342 | """Decline this bug for fixing in a series. |
343 | |
344 | @@ -139,6 +160,8 @@ |
345 | def isApproved(): |
346 | """Is this nomination in Approved state?""" |
347 | |
348 | + @operation_parameters(person=Reference(schema=IPerson)) |
349 | + @export_read_operation() |
350 | def canApprove(person): |
351 | """Is this person allowed to approve the nomination?""" |
352 | |
353 | |
354 | === modified file 'lib/lp/bugs/model/bug.py' |
355 | --- lib/lp/bugs/model/bug.py 2009-08-04 12:36:46 +0000 |
356 | +++ lib/lp/bugs/model/bug.py 2009-08-26 01:54:39 +0000 |
357 | @@ -1073,74 +1073,79 @@ |
358 | |
359 | def addNomination(self, owner, target): |
360 | """See `IBug`.""" |
361 | + if not self.canBeNominatedFor(target): |
362 | + raise NominationError( |
363 | + "This bug cannot be nominated for %s." % |
364 | + target.bugtargetdisplayname) |
365 | + |
366 | distroseries = None |
367 | productseries = None |
368 | if IDistroSeries.providedBy(target): |
369 | distroseries = target |
370 | - target_displayname = target.fullseriesname |
371 | if target.status == DistroSeriesStatus.OBSOLETE: |
372 | raise NominationSeriesObsoleteError( |
373 | - "%s is an obsolete series." % target_displayname) |
374 | + "%s is an obsolete series." % target.bugtargetdisplayname) |
375 | else: |
376 | assert IProductSeries.providedBy(target) |
377 | productseries = target |
378 | - target_displayname = target.title |
379 | - |
380 | - if not self.canBeNominatedFor(target): |
381 | - raise NominationError( |
382 | - "This bug cannot be nominated for %s." % target_displayname) |
383 | |
384 | nomination = BugNomination( |
385 | owner=owner, bug=self, distroseries=distroseries, |
386 | productseries=productseries) |
387 | - if nomination.canApprove(owner): |
388 | - nomination.approve(owner) |
389 | - else: |
390 | - self.addChange(SeriesNominated(UTC_NOW, owner, target)) |
391 | + self.addChange(SeriesNominated(UTC_NOW, owner, target)) |
392 | return nomination |
393 | |
394 | - def canBeNominatedFor(self, nomination_target): |
395 | + def canBeNominatedFor(self, target): |
396 | """See `IBug`.""" |
397 | try: |
398 | - self.getNominationFor(nomination_target) |
399 | + self.getNominationFor(target) |
400 | except NotFoundError: |
401 | # No nomination exists. Let's see if the bug is already |
402 | - # directly targeted to this nomination_target. |
403 | - if IDistroSeries.providedBy(nomination_target): |
404 | - target_getter = operator.attrgetter("distroseries") |
405 | - elif IProductSeries.providedBy(nomination_target): |
406 | - target_getter = operator.attrgetter("productseries") |
407 | + # directly targeted to this nomination target. |
408 | + if IDistroSeries.providedBy(target): |
409 | + series_getter = operator.attrgetter("distroseries") |
410 | + pillar_getter = operator.attrgetter("distribution") |
411 | + elif IProductSeries.providedBy(target): |
412 | + series_getter = operator.attrgetter("productseries") |
413 | + pillar_getter = operator.attrgetter("product") |
414 | else: |
415 | - raise AssertionError( |
416 | - "Expected IDistroSeries or IProductSeries target. " |
417 | - "Got %r." % nomination_target) |
418 | + return False |
419 | |
420 | for task in self.bugtasks: |
421 | - if target_getter(task) == nomination_target: |
422 | + if series_getter(task) == target: |
423 | # The bug is already targeted at this |
424 | - # nomination_target. |
425 | + # nomination target. |
426 | return False |
427 | |
428 | # No nomination or tasks are targeted at this |
429 | - # nomination_target. |
430 | - return True |
431 | + # nomination target. But we also don't want to nominate for a |
432 | + # series of a product or distro for which we don't have a |
433 | + # plain pillar task. |
434 | + for task in self.bugtasks: |
435 | + if pillar_getter(task) == pillar_getter(target): |
436 | + return True |
437 | + |
438 | + # No tasks match the candidate's pillar. We must refuse. |
439 | + return False |
440 | else: |
441 | - # The bug is already nominated for this nomination_target. |
442 | + # The bug is already nominated for this nomination target. |
443 | return False |
444 | |
445 | - def getNominationFor(self, nomination_target): |
446 | + def getNominationFor(self, target): |
447 | """See `IBug`.""" |
448 | - if IDistroSeries.providedBy(nomination_target): |
449 | - filter_args = dict(distroseriesID=nomination_target.id) |
450 | + if IDistroSeries.providedBy(target): |
451 | + filter_args = dict(distroseriesID=target.id) |
452 | + elif IProductSeries.providedBy(target): |
453 | + filter_args = dict(productseriesID=target.id) |
454 | else: |
455 | - filter_args = dict(productseriesID=nomination_target.id) |
456 | + return None |
457 | |
458 | nomination = BugNomination.selectOneBy(bugID=self.id, **filter_args) |
459 | |
460 | if nomination is None: |
461 | raise NotFoundError( |
462 | "Bug #%d is not nominated for %s." % ( |
463 | - self.id, nomination_target.displayname)) |
464 | + self.id, target.displayname)) |
465 | |
466 | return nomination |
467 | |
468 | |
469 | === modified file 'lib/lp/bugs/model/bugnomination.py' |
470 | --- lib/lp/bugs/model/bugnomination.py 2009-06-25 00:40:31 +0000 |
471 | +++ lib/lp/bugs/model/bugnomination.py 2009-08-23 07:15:30 +0000 |
472 | @@ -33,7 +33,8 @@ |
473 | from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities |
474 | from canonical.launchpad.webapp.interfaces import NotFoundError |
475 | from lp.bugs.interfaces.bugnomination import ( |
476 | - BugNominationStatus, IBugNomination, IBugNominationSet) |
477 | + BugNominationStatus, BugNominationStatusError, IBugNomination, |
478 | + IBugNominationSet) |
479 | from lp.registry.interfaces.person import validate_public_person |
480 | |
481 | class BugNomination(SQLBase): |
482 | @@ -66,6 +67,9 @@ |
483 | |
484 | def approve(self, approver): |
485 | """See IBugNomination.""" |
486 | + if self.isApproved(): |
487 | + # Approving an approved nomination shouldn't duplicate tasks. |
488 | + return |
489 | self.status = BugNominationStatus.APPROVED |
490 | self.decider = approver |
491 | self.date_decided = datetime.now(pytz.timezone('UTC')) |
492 | @@ -91,6 +95,9 @@ |
493 | |
494 | def decline(self, decliner): |
495 | """See IBugNomination.""" |
496 | + if self.isApproved(): |
497 | + raise BugNominationStatusError( |
498 | + "Cannot decline an approved nomination.") |
499 | self.status = BugNominationStatus.DECLINED |
500 | self.decider = decliner |
501 | self.date_decided = datetime.now(pytz.timezone('UTC')) |
502 | |
503 | === modified file 'lib/lp/bugs/stories/webservice/xx-bug.txt' |
504 | --- lib/lp/bugs/stories/webservice/xx-bug.txt 2009-08-13 15:12:16 +0000 |
505 | +++ lib/lp/bugs/stories/webservice/xx-bug.txt 2009-08-26 03:56:55 +0000 |
506 | @@ -562,6 +562,213 @@ |
507 | "id": ... "title": "Test bug"... |
508 | |
509 | |
510 | +Bug nominations |
511 | +--------------- |
512 | + |
513 | +A bug may be nominated for any number of distro or product series. |
514 | +Nominations can be inspected, created, approved and declined through |
515 | +the webservice. |
516 | + |
517 | +First we'll create Fooix 0.1 and 0.2. Eric is the owner. |
518 | + |
519 | + >>> login('foo.bar@canonical.com') |
520 | + >>> eric = factory.makePerson(name='eric') |
521 | + >>> fooix = factory.makeProduct(name='fooix', owner=eric) |
522 | + >>> fx01 = fooix.newSeries(eric, '0.1', 'The 0.1.x series') |
523 | + >>> fx02 = fooix.newSeries(eric, '0.2', 'The 0.2.x series') |
524 | + >>> debuntu = factory.makeDistribution(name='debuntu', owner=eric) |
525 | + >>> debuntu50 = debuntu.newSeries( |
526 | + ... '5.0', '5.0', '5.0', '5.0', '5.0', '5.0', None, eric) |
527 | + >>> bug = factory.makeBug(product=fooix) |
528 | + >>> logout() |
529 | + |
530 | +Initially there are no nominations. |
531 | + |
532 | + >>> pprint_collection(webservice.named_get( |
533 | + ... '/bugs/%d' % bug.id, 'getNominations').jsonBody()) |
534 | + start: None |
535 | + total_size: 0 |
536 | + --- |
537 | + |
538 | + >>> login('foo.bar@canonical.com') |
539 | + >>> john = factory.makePerson(name='john') |
540 | + >>> logout() |
541 | + |
542 | + >>> from canonical.launchpad.testing.pages import webservice_for_person |
543 | + >>> from canonical.launchpad.webapp.interfaces import OAuthPermission |
544 | + |
545 | + >>> john_webservice = webservice_for_person( |
546 | + ... john, permission=OAuthPermission.WRITE_PRIVATE) |
547 | + |
548 | +But John, an unprivileged user, wants it fixed in Fooix 0.1.1. |
549 | + |
550 | + >>> print john_webservice.named_post( |
551 | + ... '/bugs/%d' % bug.id, 'addNomination', |
552 | + ... target=john_webservice.getAbsoluteUrl('/fooix/0.1')) |
553 | + HTTP/1.1 201 Created |
554 | + ... |
555 | + Location: http://.../bugs/.../nominations/... |
556 | + ... |
557 | + |
558 | + >>> nominations = webservice.named_get( |
559 | + ... '/bugs/%d' % bug.id, 'getNominations').jsonBody() |
560 | + >>> pprint_collection(nominations) |
561 | + start: 0 |
562 | + total_size: 1 |
563 | + --- |
564 | + bug_link: u'http://.../bugs/...' |
565 | + date_created: u'...' |
566 | + date_decided: None |
567 | + decider_link: None |
568 | + distroseries_link: None |
569 | + owner_link: u'http://.../~john' |
570 | + productseries_link: u'http://.../fooix/0.1' |
571 | + resource_type_link: u'http://.../#bug_nomination' |
572 | + self_link: u'http://.../bugs/.../nominations/...' |
573 | + status: u'Nominated' |
574 | + target_link: u'http://.../fooix/0.1' |
575 | + --- |
576 | + |
577 | +John cannot approve or decline the nomination. |
578 | + |
579 | + >>> nom_url = nominations['entries'][0]['self_link'] |
580 | + |
581 | + >>> print john_webservice.named_get( |
582 | + ... nom_url, 'canApprove', |
583 | + ... person=john_webservice.getAbsoluteUrl('/~john')).jsonBody() |
584 | + False |
585 | + |
586 | + >>> print john_webservice.named_post(nom_url, 'approve') |
587 | + HTTP/1.1 401 Unauthorized... |
588 | + |
589 | + >>> print john_webservice.named_post(nom_url, 'decline') |
590 | + HTTP/1.1 401 Unauthorized... |
591 | + |
592 | + >>> login('foo.bar@canonical.com') |
593 | + >>> len(bug.bugtasks) |
594 | + 1 |
595 | + >>> logout() |
596 | + |
597 | +The owner, however, can decline the nomination. |
598 | + |
599 | + >>> print john_webservice.named_get( |
600 | + ... nom_url, 'canApprove', |
601 | + ... person=john_webservice.getAbsoluteUrl('/~eric')).jsonBody() |
602 | + True |
603 | + |
604 | + >>> eric_webservice = webservice_for_person( |
605 | + ... eric, permission=OAuthPermission.WRITE_PRIVATE) |
606 | + >>> print eric_webservice.named_post(nom_url, 'decline') |
607 | + HTTP/1.1 200 Ok... |
608 | + |
609 | + >>> login('foo.bar@canonical.com') |
610 | + >>> len(bug.bugtasks) |
611 | + 1 |
612 | + >>> logout() |
613 | + |
614 | +We can see that the nomination was declined. |
615 | + |
616 | + >>> nominations = webservice.named_get( |
617 | + ... '/bugs/%d' % bug.id, 'getNominations').jsonBody() |
618 | + >>> pprint_collection(nominations) |
619 | + start: 0 |
620 | + total_size: 1 |
621 | + --- |
622 | + bug_link: u'http://.../bugs/...' |
623 | + date_created: u'...' |
624 | + date_decided: u'...' |
625 | + decider_link: u'http://.../~eric' |
626 | + distroseries_link: None |
627 | + owner_link: u'http://.../~john' |
628 | + productseries_link: u'http://.../fooix/0.1' |
629 | + resource_type_link: u'http://.../#bug_nomination' |
630 | + self_link: u'http://.../bugs/.../nominations/...' |
631 | + status: u'Declined' |
632 | + target_link: u'http://.../fooix/0.1' |
633 | + --- |
634 | + |
635 | +The owner can approve a previously declined nomination. |
636 | + |
637 | + >>> print eric_webservice.named_post(nom_url, 'approve') |
638 | + HTTP/1.1 200 Ok... |
639 | + |
640 | +This marks the nomination as Approved, and creates a new task. |
641 | + |
642 | + >>> nominations = webservice.named_get( |
643 | + ... '/bugs/%d' % bug.id, 'getNominations').jsonBody() |
644 | + >>> pprint_collection(nominations) |
645 | + start: 0 |
646 | + total_size: 1 |
647 | + --- |
648 | + bug_link: u'http://.../bugs/...' |
649 | + date_created: u'...' |
650 | + date_decided: u'...' |
651 | + decider_link: u'http://.../~eric' |
652 | + distroseries_link: None |
653 | + owner_link: u'http://.../~john' |
654 | + productseries_link: u'http://.../fooix/0.1' |
655 | + resource_type_link: u'http://.../#bug_nomination' |
656 | + self_link: u'http://.../bugs/.../nominations/...' |
657 | + status: u'Approved' |
658 | + target_link: u'http://.../fooix/0.1' |
659 | + --- |
660 | + |
661 | + >>> login('foo.bar@canonical.com') |
662 | + >>> len(bug.bugtasks) |
663 | + 2 |
664 | + >>> logout() |
665 | + |
666 | +The owner cannot change his mind and decline an approved task. |
667 | + |
668 | + >>> print eric_webservice.named_post(nom_url, 'decline') |
669 | + HTTP/1.1 400 Bad Request |
670 | + ... |
671 | + Cannot decline an approved nomination. |
672 | + |
673 | + >>> login('foo.bar@canonical.com') |
674 | + >>> len(bug.bugtasks) |
675 | + 2 |
676 | + >>> logout() |
677 | + |
678 | +While he can approve it again, it's a no-op. |
679 | + |
680 | + >>> print eric_webservice.named_post(nom_url, 'approve') |
681 | + HTTP/1.1 200 Ok... |
682 | + |
683 | + >>> login('foo.bar@canonical.com') |
684 | + >>> len(bug.bugtasks) |
685 | + 2 |
686 | + >>> logout() |
687 | + |
688 | +A bug cannot be nominated for a non-series. |
689 | + |
690 | + >>> print john_webservice.named_get( |
691 | + ... '/bugs/%d' % bug.id, 'canBeNominatedFor', |
692 | + ... target=john_webservice.getAbsoluteUrl('/fooix')).jsonBody() |
693 | + False |
694 | + |
695 | + >>> print john_webservice.named_post( |
696 | + ... '/bugs/%d' % bug.id, 'addNomination', |
697 | + ... target=john_webservice.getAbsoluteUrl('/fooix')) |
698 | + HTTP/1.1 400 Bad Request |
699 | + ... |
700 | + NominationError: This bug cannot be nominated for Fooix. |
701 | + |
702 | +The bug also can't be nominated for Debuntu 5.0, as it has no |
703 | +Debuntu tasks. |
704 | + |
705 | + >>> print john_webservice.named_get( |
706 | + ... '/bugs/%d' % bug.id, 'canBeNominatedFor', |
707 | + ... target=john_webservice.getAbsoluteUrl('/debuntu/5.0')).jsonBody() |
708 | + False |
709 | + |
710 | + >>> print john_webservice.named_post( |
711 | + ... '/bugs/%d' % bug.id, 'addNomination', |
712 | + ... target=john_webservice.getAbsoluteUrl('/debuntu/5.0')) |
713 | + HTTP/1.1 400 Bad Request |
714 | + ... |
715 | + NominationError: This bug cannot be nominated for Debuntu 5.0. |
716 | + |
717 | Bug subscriptions |
718 | ----------------- |
719 | |
720 | @@ -639,9 +846,6 @@ |
721 | Once we have a member, a web service must be created for that user. |
722 | Then, the user can unsubsribe the group from the bug. |
723 | |
724 | - >>> from canonical.launchpad.testing.pages import webservice_for_person |
725 | - >>> from canonical.launchpad.webapp.interfaces import OAuthPermission |
726 | - |
727 | >>> member_webservice = webservice_for_person( |
728 | ... ubuntu_team_member, permission=OAuthPermission.WRITE_PRIVATE) |
729 | |
730 | |
731 | === modified file 'lib/lp/bugs/tests/test_bugchanges.py' |
732 | --- lib/lp/bugs/tests/test_bugchanges.py 2009-07-17 18:46:25 +0000 |
733 | +++ lib/lp/bugs/tests/test_bugchanges.py 2009-08-24 10:45:09 +0000 |
734 | @@ -1209,38 +1209,6 @@ |
735 | |
736 | self.assertRecordedChange(expected_activity=expected_activity) |
737 | |
738 | - def test_series_nominated_and_approved(self): |
739 | - # When adding a nomination that is approved automatically, it's |
740 | - # like adding a new bug task for the series directly. |
741 | - product = self.factory.makeProduct(owner=self.user) |
742 | - product.driver = self.user |
743 | - series = self.factory.makeProductSeries(product=product) |
744 | - self.bug.addTask(self.user, product) |
745 | - self.saveOldChanges() |
746 | - |
747 | - nomination = self.bug.addNomination(self.user, series) |
748 | - self.assertTrue(nomination.isApproved()) |
749 | - |
750 | - expected_activity = { |
751 | - 'person': self.user, |
752 | - 'newvalue': series.bugtargetname, |
753 | - 'whatchanged': 'bug task added', |
754 | - 'newvalue': series.bugtargetname, |
755 | - } |
756 | - |
757 | - task_added_notification = { |
758 | - 'person': self.user, |
759 | - 'text': ( |
760 | - '** Also affects: %s\n' |
761 | - ' Importance: Undecided\n' |
762 | - ' Status: New' % ( |
763 | - series.bugtargetname)), |
764 | - } |
765 | - |
766 | - self.assertRecordedChange( |
767 | - expected_activity=expected_activity, |
768 | - expected_notification=task_added_notification) |
769 | - |
770 | def test_nomination_approved(self): |
771 | # When a nomination is approved, it's like adding a new bug |
772 | # task for the series directly. |
773 | |
774 | === added file 'lib/lp/bugs/tests/test_bugnomination.py' |
775 | --- lib/lp/bugs/tests/test_bugnomination.py 1970-01-01 00:00:00 +0000 |
776 | +++ lib/lp/bugs/tests/test_bugnomination.py 2009-08-26 02:17:17 +0000 |
777 | @@ -0,0 +1,100 @@ |
778 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
779 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
780 | + |
781 | +"""Tests related to bug nominations.""" |
782 | + |
783 | +__metaclass__ = type |
784 | + |
785 | +import unittest |
786 | + |
787 | +from canonical.launchpad.ftests import login, logout |
788 | +from canonical.testing import DatabaseFunctionalLayer |
789 | +from lp.testing import TestCaseWithFactory |
790 | + |
791 | + |
792 | +class TestBugCanBeNominatedForProductSeries(TestCaseWithFactory): |
793 | + """Test IBug.canBeNominated for IProductSeries nominations.""" |
794 | + |
795 | + layer = DatabaseFunctionalLayer |
796 | + |
797 | + def setUp(self): |
798 | + super(TestBugCanBeNominatedForProductSeries, self).setUp() |
799 | + login('foo.bar@canonical.com') |
800 | + self.eric = self.factory.makePerson(name='eric') |
801 | + self.setUpTarget() |
802 | + |
803 | + |
804 | + def setUpTarget(self): |
805 | + self.series = self.factory.makeProductSeries() |
806 | + self.bug = self.factory.makeBug(product=self.series.product) |
807 | + self.milestone = self.factory.makeMilestone(productseries=self.series) |
808 | + self.random_series = self.factory.makeProductSeries() |
809 | + |
810 | + def test_canBeNominatedFor_series(self): |
811 | + # A bug may be nominated for a series of a product with an existing |
812 | + # task. |
813 | + self.assertTrue(self.bug.canBeNominatedFor(self.series)) |
814 | + |
815 | + def test_not_canBeNominatedFor_already_nominated_series(self): |
816 | + # A bug may not be nominated for a series with an existing nomination. |
817 | + self.assertTrue(self.bug.canBeNominatedFor(self.series)) |
818 | + self.bug.addNomination(self.eric, self.series) |
819 | + self.assertFalse(self.bug.canBeNominatedFor(self.series)) |
820 | + |
821 | + def test_not_canBeNominatedFor_non_series(self): |
822 | + # A bug may not be nominated for something other than a series. |
823 | + self.assertFalse(self.bug.canBeNominatedFor(self.milestone)) |
824 | + |
825 | + def test_not_canBeNominatedFor_already_targeted_series(self): |
826 | + # A bug may not be nominated for a series if a task already exists. |
827 | + # This case should be caught by the check for an existing nomination, |
828 | + # but there are some historical cases where a series task exists |
829 | + # without a nomination. |
830 | + self.assertTrue(self.bug.canBeNominatedFor(self.series)) |
831 | + self.bug.addTask(self.eric, self.series) |
832 | + self.assertFalse(self.bug.canBeNominatedFor(self.series)) |
833 | + |
834 | + def test_not_canBeNominatedFor_random_series(self): |
835 | + # A bug may only be nominated for a series if that series' pillar |
836 | + # already has a task. |
837 | + self.assertFalse(self.bug.canBeNominatedFor(self.random_series)) |
838 | + |
839 | + def tearDown(self): |
840 | + logout() |
841 | + super(TestBugCanBeNominatedForProductSeries, self).tearDown() |
842 | + |
843 | + |
844 | +class TestBugCanBeNominatedForDistroSeries( |
845 | + TestBugCanBeNominatedForProductSeries): |
846 | + """Test IBug.canBeNominated for IDistroSeries nominations.""" |
847 | + |
848 | + def setUpTarget(self): |
849 | + self.series = self.factory.makeDistroRelease() |
850 | + # The factory can't create a distro bug directly. |
851 | + self.bug = self.factory.makeBug() |
852 | + self.bug.addTask(self.eric, self.series.distribution) |
853 | + self.milestone = self.factory.makeMilestone( |
854 | + distribution=self.series.distribution) |
855 | + self.random_series = self.factory.makeDistroRelease() |
856 | + |
857 | + def test_not_canBeNominatedFor_source_package(self): |
858 | + # A bug may not be nominated directly for a source package. The |
859 | + # distroseries must be nominated instead. |
860 | + spn = self.factory.makeSourcePackageName() |
861 | + source_package = self.series.getSourcePackage(spn) |
862 | + self.assertFalse(self.bug.canBeNominatedFor(source_package)) |
863 | + |
864 | + def test_canBeNominatedFor_with_only_distributionsourcepackage(self): |
865 | + # A distribution source package task is sufficient to allow nomination |
866 | + # to a series of that distribution. |
867 | + sp_bug = self.factory.makeBug() |
868 | + spn = self.factory.makeSourcePackageName() |
869 | + |
870 | + self.assertFalse(sp_bug.canBeNominatedFor(self.series)) |
871 | + sp_bug.addTask( |
872 | + self.eric, self.series.distribution.getSourcePackage(spn)) |
873 | + self.assertTrue(sp_bug.canBeNominatedFor(self.series)) |
874 | + |
875 | + |
876 | +def test_suite(): |
877 | + return unittest.TestLoader().loadTestsFromName(__name__) |
= Summary =
Bug nominations want to be exported through the API.
== Proposed fix ==
Export IBugNomination and relevant methods from IBug, moving access control code from views to model classes.
== Implementation details ==
All of the relevant IBug and IBugNomination methods already took the user as an argument, so no signature changes were required.
The main non-trivial bits are the refactoring of IBug.canBeNomin atedFor and IBug.addNomination to properly forbid attempts to nominate a non-series. Rather than introduce an ISeriesBugTarget as suggested in the bug, I opted to just use IBugTarget. Further type checking was going to be required anyway when dealing with an ISeriesBugTarget, so it's probably cleaner this way.
There are four significant model behaviour changes: approve is now a no-op if it is already approved, as IBugNomination says. Previously it would crash or create a duplicate task. decline now fails if it is already approved, as IBugNomination says. Previously it would successfully decline the task.
- IBug.addNomination will no longer automatically approve nominations if possible. The view does this explicitly instead.
- BugNomination.
- BugNomination.
- Bug.addNomination will refuse to create a nomination for a series on a pillar that does not already have a task. This has always been enforced in the view.
== Tests == atedFor and the webservice, and a test to verify that nominations are automatically approved was dropped. Other tests required trivial changes to comply with the new model restrictions.
Direct tests were added for IBug.canBeNomin
$ bin/test -t test_bugnomination -t bugtask- edit-views. txt -t bug-nomination.txt -t bug-set-status.txt -t bugtask.txt -t xx-bug.txt -t test_bugchanges