Merge lp:~ttx/launchpad/lp1193389 into lp:launchpad

Proposed by Thierry Carrez
Status: Merged
Merged at revision: 16691
Proposed branch: lp:~ttx/launchpad/lp1193389
Merge into: lp:launchpad
Diff against target: 260 lines (+106/-22)
6 files modified
lib/lp/blueprints/configure.zcml (+0/-4)
lib/lp/blueprints/interfaces/specification.py (+33/-3)
lib/lp/blueprints/interfaces/webservice.py (+5/-1)
lib/lp/blueprints/model/specification.py (+6/-3)
lib/lp/blueprints/subscribers.py (+0/-11)
lib/lp/blueprints/tests/test_webservice.py (+62/-0)
To merge this branch: bzr merge lp:~ttx/launchpad/lp1193389
Reviewer Review Type Date Requested Status
William Grant code Needs Fixing
Review via email: mp+171491@code.launchpad.net

Commit message

Expose specification goal management in specification API

- Expose proposeGoal, acceptGoal, declineGoal methods and has_accepted_goal attribute
- Under wgrant advice, remove lp.blueprints.subscribers.specification_goalstatus as it's not used and its security model blocks proxied objects
- Add unit tests

Description of the change

Expose specification goal management in specification API

- Expose proposeGoal, acceptGoal, declineGoal methods and has_accepted_goal attribute
- Under wgrant advice, remove lp.blueprints.subscribers.specification_goalstatus as it's not used and its security model blocks proxied objects
- Add unit tests

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

40 + @operation_parameters(
41 + goal=Reference(schema=IBugTarget, title=_('Target'),
42 + required=False, default=None))

That should probably be something like Reference(schema=ISpecificationTarget, title=_('Series goal')). The indentation is also confusing; I'd say:

@operation_parameters(
    goal=Reference(
        schema=ISpecificationTarget, title=_('Series goal'),
        required=False, default=None))

Additionally, proposeGoal will currently crash with an AssertionError if you pass a goal that isn't a series. You'll need to replace that with a custom exception decorated with @error_status(httplib.BAD_REQUEST) to return a 400 Bad Request rather than an OOPS, as is done with NominationError in Bug.addNomination.

121 + def test_goal_propose_and_accept(self):
122 + # Create spec

We prefer that tests start with a short comment describing the behaviour that they're testing. I'd perhaps replace "Create spec" with "Webservice clients can propose and accept spec series goals."

review: Needs Fixing (code)
Revision history for this message
Thierry Carrez (ttx) wrote :

Looks like ISpecificationTarget is not a series (DistroSeries or ProductSeries), but a 'DistributionOrProduct', i.e. a project... so it doesn't sound like a good match for the series goal ? Will fix the other stuff.

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

Doesn't this branch still permit anyone who can view the specification to call proposeGoal/acceptGoal/declineGoal, even if they would not be able to use the corresponding web UI facilities? In lib/lp/blueprints/browser/configure.zcml, the views that expose proposeGoal require launchpad.Edit, while the view that exposes acceptBy/declineBy requires launchpad.Driver; but the interfaces here are only protected by launchpad.LimitedView, which is much broader.

Revision history for this message
William Grant (wgrant) wrote :

Thanks for the fixes, and your good point about ISpecificationTarget being inappropriate.

However, we'll need to add a further check in proposeGoal: the series' product/distribution must match the target of the blueprint. This is presently enforced by the FilteredProductSeries/FilteredDistroSeries vocabs on the browser fields, but they can't easily be reused at this level of the model. Manual validation of the target match is easy, and you can reuse the exception.

19 @@ -11,12 +11,16 @@
20 'ISpecificationPublic',
21 'ISpecificationSet',
22 'ISpecificationView',
23 + 'GoalProposeError',
24 ]

This should be sorted alphabetically.

101 === modified file 'lib/lp/blueprints/interfaces/webservice.py'

Two bad orderings here too.

=== modified file 'lib/lp/blueprints/model/specification.py'

And one here.

Revision history for this message
Thierry Carrez (ttx) wrote :

William: Blame my lexical-order-dyslexia. Will fix.

Colin: if William confirms this is a flaw and you all give me a bit more detail on how to fix that, I'll be happy to plug that one. The LP perms model is still a bit fuzzy to me :)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/blueprints/configure.zcml'
2--- lib/lp/blueprints/configure.zcml 2012-09-17 18:55:17 +0000
3+++ lib/lp/blueprints/configure.zcml 2013-06-27 15:14:30 +0000
4@@ -204,10 +204,6 @@
5 <subscriber
6 for="lp.blueprints.interfaces.specification.ISpecification
7 lazr.lifecycle.interfaces.IObjectModifiedEvent"
8- handler="lp.blueprints.subscribers.specification_goalstatus"/>
9- <subscriber
10- for="lp.blueprints.interfaces.specification.ISpecification
11- lazr.lifecycle.interfaces.IObjectModifiedEvent"
12 handler="lp.blueprints.mail.notifications.notify_specification_modified"/>
13
14 <!-- SpecificationSet -->
15
16=== modified file 'lib/lp/blueprints/interfaces/specification.py'
17--- lib/lp/blueprints/interfaces/specification.py 2013-04-11 00:51:46 +0000
18+++ lib/lp/blueprints/interfaces/specification.py 2013-06-27 15:14:30 +0000
19@@ -6,6 +6,7 @@
20 __metaclass__ = type
21
22 __all__ = [
23+ 'GoalProposeError',
24 'ISpecification',
25 'ISpecificationDelta',
26 'ISpecificationPublic',
27@@ -13,10 +14,13 @@
28 'ISpecificationView',
29 ]
30
31+import httplib
32
33 from lazr.restful.declarations import (
34 call_with,
35+ error_status,
36 export_as_webservice_entry,
37+ export_operation_as,
38 export_write_operation,
39 exported,
40 mutator_for,
41@@ -70,6 +74,7 @@
42 )
43 from lp.blueprints.interfaces.sprint import ISprint
44 from lp.bugs.interfaces.buglink import IBugLinkTarget
45+from lp.bugs.interfaces.bugtarget import IBugTarget
46 from lp.code.interfaces.branchlink import IHasLinkedBranches
47 from lp.registry.interfaces.milestone import IMilestone
48 from lp.registry.interfaces.person import IPerson
49@@ -86,6 +91,11 @@
50 from lp.services.webapp.escaping import structured
51
52
53+@error_status(httplib.BAD_REQUEST)
54+class GoalProposeError(Exception):
55+ """Invalid series goal for this specification."""
56+
57+
58 class SpecNameField(ContentNameField):
59
60 errormessage = _("%s is already in use by another blueprint.")
61@@ -476,20 +486,40 @@
62 """Return the list of email addresses that receive notifications."""
63
64 # goal management
65+ @call_with(proposer=REQUEST_USER)
66+ @operation_parameters(
67+ goal=Reference(
68+ schema=IBugTarget, title=_('Target'),
69+ required=False, default=None))
70+ @export_write_operation()
71+ @operation_for_version("devel")
72 def proposeGoal(goal, proposer):
73 """Propose this spec for a series or distroseries."""
74
75+ @call_with(decider=REQUEST_USER)
76+ @export_operation_as('acceptGoal')
77+ @export_write_operation()
78+ @operation_for_version("devel")
79 def acceptBy(decider):
80 """Mark the spec as being accepted for its current series goal."""
81
82+ @call_with(decider=REQUEST_USER)
83+ @export_operation_as('declineGoal')
84+ @export_write_operation()
85+ @operation_for_version("devel")
86 def declineBy(decider):
87 """Mark the spec as being declined as a goal for the proposed
88 series.
89 """
90
91- has_accepted_goal = Attribute('Is true if this specification has been '
92- 'proposed as a goal for a specific series, '
93- 'and the drivers of that series have accepted the goal.')
94+ has_accepted_goal = exported(
95+ Bool(title=_('Series goal is accepted'),
96+ readonly=True, required=True,
97+ description=_(
98+ 'Is true if this specification has been '
99+ 'proposed as a goal for a specific series, '
100+ 'and the drivers of that series have accepted the goal.')),
101+ as_of="devel")
102
103 # lifecycle management
104 def updateLifecycleStatus(user):
105
106=== modified file 'lib/lp/blueprints/interfaces/webservice.py'
107--- lib/lp/blueprints/interfaces/webservice.py 2013-01-03 00:27:37 +0000
108+++ lib/lp/blueprints/interfaces/webservice.py 2013-06-27 15:14:30 +0000
109@@ -10,6 +10,7 @@
110 """
111
112 __all__ = [
113+ 'GoalProposeError',
114 'ISpecification',
115 'ISpecificationBranch',
116 'ISpecificationSubscription',
117@@ -18,7 +19,10 @@
118 # XXX: JonathanLange 2010-11-09 bug=673083: Legacy work-around for circular
119 # import bugs. Break this up into a per-package thing.
120 from lp import _schema_circular_imports
121-from lp.blueprints.interfaces.specification import ISpecification
122+from lp.blueprints.interfaces.specification import (
123+ GoalProposeError,
124+ ISpecification,
125+ )
126 from lp.blueprints.interfaces.specificationbranch import ISpecificationBranch
127 from lp.blueprints.interfaces.specificationsubscription import (
128 ISpecificationSubscription,
129
130=== modified file 'lib/lp/blueprints/model/specification.py'
131--- lib/lp/blueprints/model/specification.py 2013-06-20 05:50:00 +0000
132+++ lib/lp/blueprints/model/specification.py 2013-06-27 15:14:30 +0000
133@@ -57,6 +57,7 @@
134 )
135 from lp.blueprints.errors import TargetAlreadyHasSpecification
136 from lp.blueprints.interfaces.specification import (
137+ GoalProposeError,
138 ISpecification,
139 ISpecificationSet,
140 )
141@@ -506,14 +507,16 @@
142 # we are clearing goals
143 self.productseries = None
144 self.distroseries = None
145- elif IProductSeries.providedBy(goal):
146+ elif (IProductSeries.providedBy(goal) and
147+ goal.product == self.target):
148 # set the product series as a goal
149 self.productseries = goal
150 self.goal_proposer = proposer
151 self.date_goal_proposed = UTC_NOW
152 # and make sure there is no leftover distroseries goal
153 self.distroseries = None
154- elif IDistroSeries.providedBy(goal):
155+ elif (IDistroSeries.providedBy(goal) and
156+ goal.distribution == self.target):
157 # set the distroseries goal
158 self.distroseries = goal
159 self.goal_proposer = proposer
160@@ -521,7 +524,7 @@
161 # and make sure there is no leftover distroseries goal
162 self.productseries = None
163 else:
164- raise AssertionError('Inappropriate goal.')
165+ raise GoalProposeError('Inappropriate goal.')
166 # record who made the proposal, and when
167 self.goal_proposer = proposer
168 self.date_goal_proposed = UTC_NOW
169
170=== modified file 'lib/lp/blueprints/subscribers.py'
171--- lib/lp/blueprints/subscribers.py 2011-12-30 06:14:56 +0000
172+++ lib/lp/blueprints/subscribers.py 2013-06-27 15:14:30 +0000
173@@ -9,17 +9,6 @@
174 from lp.services.database.sqlbase import block_implicit_flushes
175
176
177-@block_implicit_flushes
178-def specification_goalstatus(spec, event):
179- """Update goalstatus if productseries or distroseries is changed."""
180- delta = spec.getDelta(
181- event.object_before_modification, IPerson(event.user))
182- if delta is None:
183- return
184- if delta.productseries is not None or delta.distroseries is not None:
185- spec.goalstatus = SpecificationGoalStatus.PROPOSED
186-
187-
188 def specification_update_lifecycle_status(spec, event):
189 """Mark the specification as started and/or complete if appropriate.
190
191
192=== modified file 'lib/lp/blueprints/tests/test_webservice.py'
193--- lib/lp/blueprints/tests/test_webservice.py 2013-01-25 03:30:08 +0000
194+++ lib/lp/blueprints/tests/test_webservice.py 2013-06-27 15:14:30 +0000
195@@ -401,3 +401,65 @@
196
197 # Now that we've unlinked the bug, there are no linked bugs at all.
198 self.assertEqual(0, spec.bugs.total_size)
199+
200+
201+class TestSpecificationGoalHandling(SpecificationWebserviceTestCase):
202+
203+ layer = AppServerLayer
204+
205+ def setUp(self):
206+ super(TestSpecificationGoalHandling, self).setUp()
207+ self.driver = self.factory.makePerson()
208+ self.proposer = self.factory.makePerson()
209+ self.product = self.factory.makeProduct(driver=self.driver)
210+ self.series = self.factory.makeProductSeries(product=self.product)
211+
212+ def test_goal_propose_and_accept(self):
213+ # Webservice clients can propose and accept spec series goals.
214+ db_spec = self.factory.makeBlueprint(product=self.product,
215+ owner=self.proposer)
216+ # Propose for series goal
217+ with person_logged_in(self.proposer):
218+ launchpad = self.factory.makeLaunchpadService(person=self.proposer)
219+ spec = ws_object(launchpad, db_spec)
220+ series = ws_object(launchpad, self.series)
221+ spec.proposeGoal(goal=series)
222+ transaction.commit()
223+ self.assertEqual(db_spec.goal, self.series)
224+ self.assertFalse(spec.has_accepted_goal)
225+
226+ # Accept series goal
227+ with person_logged_in(self.driver):
228+ launchpad = self.factory.makeLaunchpadService(person=self.driver)
229+ spec = ws_object(launchpad, db_spec)
230+ spec.acceptGoal()
231+ transaction.commit()
232+ self.assertTrue(spec.has_accepted_goal)
233+
234+ def test_goal_propose_decline_and_clear(self):
235+ # Webservice clients can decline and clear spec series goals.
236+ db_spec = self.factory.makeBlueprint(product=self.product,
237+ owner=self.proposer)
238+ # Propose for series goal
239+ with person_logged_in(self.proposer):
240+ launchpad = self.factory.makeLaunchpadService(person=self.proposer)
241+ spec = ws_object(launchpad, db_spec)
242+ series = ws_object(launchpad, self.series)
243+ spec.proposeGoal(goal=series)
244+ transaction.commit()
245+ self.assertEqual(db_spec.goal, self.series)
246+ self.assertFalse(spec.has_accepted_goal)
247+
248+ with person_logged_in(self.driver):
249+ # Decline series goal
250+ launchpad = self.factory.makeLaunchpadService(person=self.driver)
251+ spec = ws_object(launchpad, db_spec)
252+ spec.declineGoal()
253+ transaction.commit()
254+ self.assertFalse(spec.has_accepted_goal)
255+ self.assertEqual(db_spec.goal, self.series)
256+
257+ # Clear series goal as a driver
258+ spec.proposeGoal(goal=None)
259+ transaction.commit()
260+ self.assertIsNone(db_spec.goal)