Merge lp:~wgrant/launchpad/export-bug-nominations into lp:launchpad

Proposed by William Grant
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
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Review via email: mp+10715@code.launchpad.net
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

= 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.canBeNominatedFor 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:
 - IBug.addNomination will no longer automatically approve nominations if possible. The view does this explicitly instead.
 - BugNomination.approve is now a no-op if it is already approved, as IBugNomination says. Previously it would crash or create a duplicate task.
 - BugNomination.decline now fails if it is already approved, as IBugNomination says. Previously it would successfully decline the task.
 - 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 ==
Direct tests were added for IBug.canBeNominatedFor 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.

 $ 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

Revision history for this message
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-nominations.txt.

 * 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

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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__)