Merge lp:~salgado/launchpad/safe-blueprints-model into lp:launchpad

Proposed by Guilherme Salgado
Status: Merged
Merged at revision: 11976
Proposed branch: lp:~salgado/launchpad/safe-blueprints-model
Merge into: lp:launchpad
Diff against target: 613 lines (+231/-80)
15 files modified
lib/canonical/launchpad/interfaces/launchpad.py (+7/-0)
lib/canonical/launchpad/security.py (+1/-3)
lib/lp/blueprints/browser/specification.py (+11/-32)
lib/lp/blueprints/configure.zcml (+2/-1)
lib/lp/blueprints/doc/specification.txt (+3/-2)
lib/lp/blueprints/errors.py (+19/-0)
lib/lp/blueprints/interfaces/specification.py (+29/-9)
lib/lp/blueprints/model/specification.py (+32/-23)
lib/lp/blueprints/model/sprint.py (+4/-3)
lib/lp/blueprints/tests/test_specification.py (+90/-0)
lib/lp/registry/model/distribution.py (+2/-1)
lib/lp/registry/model/hasdrivers.py (+22/-0)
lib/lp/registry/model/product.py (+2/-1)
lib/lp/registry/model/projectgroup.py (+2/-1)
lib/lp/registry/model/series.py (+5/-4)
To merge this branch: bzr merge lp:~salgado/launchpad/safe-blueprints-model
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Jelmer Vernooij (community) Approve
Review via email: mp+41722@code.launchpad.net

Commit message

[r=jelmer][ui=none][bug=680875] Move some Blueprint code from views to models in preparation for exposing Blueprints on the API.

 - Get rid of propose_goal_with_automatic_approval by merging it with proposeGoal(). This required refactoring the code of the security adapter into a model method that is then used in proposeGoal() and the security adapter

 - Refactor retarget() to take just a target instead of a product or a distribution.

 - Start splitting ISpecification into several interfaces where each will be protected with a different permission.

 - Consolidate validation of blueprint retargeting into a validateMove() method and use that all around.

Description of the change

This branch moves some Blueprint logic from views to models.

We want that before we expose Blueprints over the API so that we don't have to
duplicate the logic into multiple places.

Below is a more detailed list of all the changes:

 - Get rid of propose_goal_with_automatic_approval by merging it with
   proposeGoal(). This required refactoring the code of the security adapter
   into a model method that is then used in proposeGoal() and the security
   adapter

 - Refactor retarget() to take just a target instead of a product or a
   distribution.

 - Start splitting ISpecification into several interfaces where each will be
   protected with a different permission.

 - Consolidate validation of blueprint retargeting into a validateMove()
   method and use that all around.

To post a comment you must log in.
Revision history for this message
Jelmer Vernooij (jelmer) wrote :

Great to see this is being cleaned up!

review: Approve
Revision history for this message
Jelmer Vernooij (jelmer) wrote :

One really minor note: it'd be nice to have docstrings for the module and testcase in lib/lp/blueprints/tests/test_specification.py.

Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you for addressing the retarget and permission issues. In this branch. This branch address most of my concerns about https://code.launchpad.net/~james-w/launchpad/expose-blueprints/+merge/30026

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/interfaces/launchpad.py'
2--- lib/canonical/launchpad/interfaces/launchpad.py 2010-10-03 15:30:06 +0000
3+++ lib/canonical/launchpad/interfaces/launchpad.py 2010-11-24 18:10:40 +0000
4@@ -414,6 +414,13 @@
5 """
6 drivers = Attribute("A list of drivers")
7
8+ def personHasDriverRights(person):
9+ """Does the given person have launchpad.Driver rights on this object?
10+
11+ True if the person is one of this object's drivers, its owner or a
12+ Launchpad admin.
13+ """
14+
15
16 class IHasAppointedDriver(Interface):
17 """An object that has an appointed driver."""
18
19=== modified file 'lib/canonical/launchpad/security.py'
20--- lib/canonical/launchpad/security.py 2010-11-12 23:30:57 +0000
21+++ lib/canonical/launchpad/security.py 2010-11-24 18:10:40 +0000
22@@ -998,9 +998,7 @@
23 usedfor = IHasDrivers
24
25 def checkAuthenticated(self, user):
26- return (user.isOneOfDrivers(self.obj) or
27- user.isOwner(self.obj) or
28- user.in_admin)
29+ return self.obj.personHasDriverRights(user)
30
31
32 class ViewProductSeries(AnonymousAuthorization):
33
34=== modified file 'lib/lp/blueprints/browser/specification.py'
35--- lib/lp/blueprints/browser/specification.py 2010-11-23 23:22:27 +0000
36+++ lib/lp/blueprints/browser/specification.py 2010-11-24 18:10:40 +0000
37@@ -96,6 +96,7 @@
38 ISpecification,
39 ISpecificationSet,
40 )
41+from lp.blueprints.errors import TargetAlreadyHasSpecification
42 from lp.blueprints.interfaces.specificationbranch import ISpecificationBranch
43 from lp.blueprints.interfaces.sprintspecification import ISprintSpecification
44 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
45@@ -129,7 +130,7 @@
46 # Propose the specification as a series goal, if specified.
47 series = data.get('series')
48 if series is not None:
49- propose_goal_with_automatic_approval(spec, series, self.user)
50+ spec.proposeGoal(series, self.user)
51 # Propose the specification as a sprint topic, if specified.
52 sprint = data.get('sprint')
53 if sprint is not None:
54@@ -603,8 +604,7 @@
55 @action('Continue', name='continue')
56 def continue_action(self, action, data):
57 self.context.whiteboard = data['whiteboard']
58- propose_goal_with_automatic_approval(
59- self.context, data['distroseries'], self.user)
60+ self.context.proposeGoal(data['distroseries'], self.user)
61 self.next_url = canonical_url(self.context)
62
63 @property
64@@ -619,8 +619,7 @@
65 @action('Continue', name='continue')
66 def continue_action(self, action, data):
67 self.context.whiteboard = data['whiteboard']
68- propose_goal_with_automatic_approval(
69- self.context, data['productseries'], self.user)
70+ self.context.proposeGoal(data['productseries'], self.user)
71 self.next_url = canonical_url(self.context)
72
73 @property
74@@ -628,16 +627,6 @@
75 return canonical_url(self.context)
76
77
78-def propose_goal_with_automatic_approval(specification, series, user):
79- """Proposes the given specification as a goal for the given series. If
80- the given user has permission, the proposal is approved automatically.
81- """
82- specification.proposeGoal(series, user)
83- # If the proposer has permission, approve the goal automatically.
84- if series is not None and check_permission('launchpad.Driver', series):
85- specification.acceptBy(user)
86-
87-
88 class SpecificationGoalDecideView(LaunchpadFormView):
89 """View used to allow the drivers of a series to accept
90 or decline the spec as a goal for that series. Typically they would use
91@@ -688,29 +677,17 @@
92 self.request.form.get("field.target"))
93 return
94
95- if target.getSpecification(self.context.name) is not None:
96+ try:
97+ self.context.validateMove(target)
98+ except TargetAlreadyHasSpecification:
99 self.setFieldError('target',
100 'There is already a blueprint with this name for %s. '
101 'Please change the name of this blueprint and try again.' %
102 target.displayname)
103- return
104
105 @action(_('Retarget Blueprint'), name='retarget')
106- def register_action(self, action, data):
107- # we need to ensure that there is not already a spec with this name
108- # for this new target
109- target = data['target']
110- if target.getSpecification(self.context.name) is not None:
111- return '%s already has a blueprint called %s' % (
112- target.displayname, self.context.name)
113- product = distribution = None
114- if IProduct.providedBy(target):
115- product = target
116- elif IDistribution.providedBy(target):
117- distribution = target
118- else:
119- raise AssertionError('Unknown target.')
120- self.context.retarget(product=product, distribution=distribution)
121+ def retarget_action(self, action, data):
122+ self.context.retarget(data['target'])
123 self._nextURL = canonical_url(self.context)
124
125 @property
126@@ -775,6 +752,8 @@
127 SUPERSEDED = SpecificationDefinitionStatus.SUPERSEDED
128 NEW = SpecificationDefinitionStatus.NEW
129 self.context.superseded_by = data['superseded_by']
130+ # XXX: salgado, 2010-11-24, bug=680880: This logic should be in model
131+ # code.
132 if data['superseded_by'] is not None:
133 # set the state to superseded
134 self.context.definition_status = SUPERSEDED
135
136=== modified file 'lib/lp/blueprints/configure.zcml'
137--- lib/lp/blueprints/configure.zcml 2010-11-09 14:35:44 +0000
138+++ lib/lp/blueprints/configure.zcml 2010-11-24 18:10:40 +0000
139@@ -152,7 +152,7 @@
140 <!-- Specification -->
141
142 <class class="lp.blueprints.model.specification.Specification">
143- <allow interface="lp.blueprints.interfaces.specification.ISpecification"/>
144+ <allow interface="lp.blueprints.interfaces.specification.ISpecificationPublic"/>
145 <!-- We allow any authenticated person to update the whiteboard -->
146 <require
147 permission="launchpad.AnyPerson"
148@@ -162,6 +162,7 @@
149 methods -->
150 <require
151 permission="launchpad.Edit"
152+ interface="lp.blueprints.interfaces.specification.ISpecificationEditRestricted"
153 set_attributes="name title summary definition_status specurl
154 superseded_by milestone product distribution
155 approver assignee drafter man_days
156
157=== modified file 'lib/lp/blueprints/doc/specification.txt'
158--- lib/lp/blueprints/doc/specification.txt 2010-11-01 03:32:29 +0000
159+++ lib/lp/blueprints/doc/specification.txt 2010-11-24 18:10:40 +0000
160@@ -435,10 +435,11 @@
161 'Declined'
162
163 And finally, if we propose a new goal, then the decision status is
164-invalidated.
165+invalidated. (Notice that we propose the goal as jdub as goals proposed by one
166+of their drivers [e.g. mark] would be automatically accepted)
167
168 >>> trunk = upstream_firefox.getSeries('trunk')
169- >>> e4x.proposeGoal(trunk, mark)
170+ >>> e4x.proposeGoal(trunk, jdub)
171 >>> e4x.goalstatus.title
172 'Proposed'
173
174
175=== added file 'lib/lp/blueprints/errors.py'
176--- lib/lp/blueprints/errors.py 1970-01-01 00:00:00 +0000
177+++ lib/lp/blueprints/errors.py 2010-11-24 18:10:40 +0000
178@@ -0,0 +1,19 @@
179+# Copyright 2010 Canonical Ltd. This software is licensed under the
180+# GNU Affero General Public License version 3 (see the file LICENSE).
181+
182+"""Specification views."""
183+
184+__metaclass__ = type
185+
186+__all__ = [
187+ 'TargetAlreadyHasSpecification',
188+ ]
189+
190+
191+class TargetAlreadyHasSpecification(Exception):
192+ """The ISpecificationTarget already has a specification of that name."""
193+
194+ def __init__(self, target, name):
195+ msg = "The target %s already has a specification named %s" % (
196+ target, name)
197+ super(TargetAlreadyHasSpecification, self).__init__(msg)
198
199=== modified file 'lib/lp/blueprints/interfaces/specification.py'
200--- lib/lp/blueprints/interfaces/specification.py 2010-11-04 03:34:54 +0000
201+++ lib/lp/blueprints/interfaces/specification.py 2010-11-24 18:10:40 +0000
202@@ -207,11 +207,27 @@
203 required=True, vocabulary='DistributionOrProduct')
204
205
206-class ISpecification(INewSpecification, INewSpecificationTarget, IHasOwner,
207- IHasLinkedBranches):
208- """A Specification."""
209-
210- export_as_webservice_entry()
211+class ISpecificationEditRestricted(Interface):
212+ """Specification's attributes and methods protected with launchpad.Edit.
213+ """
214+
215+ def setTarget(target):
216+ """Set this specification's target.
217+
218+ :param target: an IProduct or IDistribution.
219+ """
220+
221+ def retarget(target):
222+ """Move the spec to the given target.
223+
224+ The new target must be an IProduct or IDistribution.
225+ """
226+
227+
228+class ISpecificationPublic(
229+ INewSpecification, INewSpecificationTarget, IHasOwner,
230+ IHasLinkedBranches):
231+ """Specification's public attributes and methods."""
232
233 # TomBerger 2007-06-20: 'id' is required for
234 # SQLObject to be able to assign a security-proxied
235@@ -344,10 +360,8 @@
236 default=SpecificationLifecycleStatus.NOTSTARTED,
237 readonly=True)
238
239- def retarget(product=None, distribution=None):
240- """Retarget the spec to a new product or distribution. One of
241- product or distribution must be None (but not both).
242- """
243+ def validateMove(target):
244+ """Check that the specification can be moved to the target."""
245
246 def getSprintSpecification(sprintname):
247 """Get the record that links this spec to the named sprint."""
248@@ -450,6 +464,12 @@
249 """Return the SpecificationBranch link for the branch, or None."""
250
251
252+class ISpecification(ISpecificationPublic, ISpecificationEditRestricted):
253+ """A Specification."""
254+
255+ export_as_webservice_entry()
256+
257+
258 class ISpecificationSet(IHasSpecifications):
259 """A container for specifications."""
260
261
262=== modified file 'lib/lp/blueprints/model/specification.py'
263--- lib/lp/blueprints/model/specification.py 2010-11-04 03:58:05 +0000
264+++ lib/lp/blueprints/model/specification.py 2010-11-24 18:10:40 +0000
265@@ -60,6 +60,7 @@
266 SpecificationPriority,
267 SpecificationSort,
268 )
269+from lp.blueprints.errors import TargetAlreadyHasSpecification
270 from lp.blueprints.interfaces.specification import (
271 ISpecification,
272 ISpecificationSet,
273@@ -77,9 +78,11 @@
274 from lp.blueprints.model.sprintspecification import SprintSpecification
275 from lp.bugs.interfaces.buglink import IBugLinkTarget
276 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
277+from lp.registry.interfaces.distribution import IDistribution
278 from lp.registry.interfaces.distroseries import IDistroSeries
279 from lp.registry.interfaces.person import validate_public_person
280 from lp.registry.interfaces.productseries import IProductSeries
281+from lp.registry.interfaces.product import IProduct
282
283
284 class Specification(SQLBase, BugLinkTargetMixin):
285@@ -182,7 +185,6 @@
286 otherColumn='specification', orderBy='title',
287 intermediateTable='SpecificationDependency')
288
289- # attributes
290 @property
291 def target(self):
292 """See ISpecification."""
293@@ -190,25 +192,27 @@
294 return self.product
295 return self.distribution
296
297- def retarget(self, product=None, distribution=None):
298- """See ISpecification."""
299- assert not (product and distribution)
300- assert (product or distribution)
301-
302- # we need to ensure that there is not already a spec with this name
303- # for this new target
304- if product:
305- assert product.getSpecification(self.name) is None
306- elif distribution:
307- assert distribution.getSpecification(self.name) is None
308-
309- # if we are not changing anything, then return
310- if self.product == product and self.distribution == distribution:
311+ def setTarget(self, target):
312+ """See ISpecification."""
313+ if IProduct.providedBy(target):
314+ self.product = target
315+ self.distribution = None
316+ elif IDistribution.providedBy(target):
317+ self.product = None
318+ self.distribution = target
319+ else:
320+ raise AssertionError("Unknown target: %s" % target)
321+
322+ def retarget(self, target):
323+ """See ISpecification."""
324+ if self.target == target:
325 return
326
327- # we must lose any goal we have set and approved/declined because we
328- # are moving to a different product that will have different
329- # policies and drivers
330+ self.validateMove(target)
331+
332+ # We must lose any goal we have set and approved/declined because we
333+ # are moving to a different target that will have different
334+ # policies and drivers.
335 self.productseries = None
336 self.distroseries = None
337 self.goalstatus = SpecificationGoalStatus.PROPOSED
338@@ -216,12 +220,15 @@
339 self.date_goal_proposed = None
340 self.milestone = None
341
342- # set the new values
343- self.product = product
344- self.distribution = distribution
345+ self.setTarget(target)
346 self.priority = SpecificationPriority.UNDEFINED
347 self.direction_approved = False
348
349+ def validateMove(self, target):
350+ """See ISpecification."""
351+ if target.getSpecification(self.name) is not None:
352+ raise TargetAlreadyHasSpecification(target, self.name)
353+
354 @property
355 def goal(self):
356 """See ISpecification."""
357@@ -259,6 +266,8 @@
358 # the goal should now also not have a decider
359 self.goal_decider = None
360 self.date_goal_decided = None
361+ if goal is not None and goal.personHasDriverRights(proposer):
362+ self.acceptBy(proposer)
363
364 def acceptBy(self, decider):
365 """See ISpecification."""
366@@ -658,7 +667,7 @@
367
368 def _specification_sort(self, sort):
369 """Return the storm sort order for 'specifications'.
370-
371+
372 :param sort: As per HasSpecificationsMixin.specifications.
373 """
374 # sort by priority descending, by default
375@@ -671,7 +680,7 @@
376
377 def _preload_specifications_people(self, query):
378 """Perform eager loading of people and their validity for query.
379-
380+
381 :param query: a string query generated in the 'specifications'
382 method.
383 :return: A DecoratedResultSet with Person precaching setup.
384
385=== modified file 'lib/lp/blueprints/model/sprint.py'
386--- lib/lp/blueprints/model/sprint.py 2010-11-01 03:57:52 +0000
387+++ lib/lp/blueprints/model/sprint.py 2010-11-24 18:10:40 +0000
388@@ -45,9 +45,10 @@
389 from lp.blueprints.model.sprintattendance import SprintAttendance
390 from lp.blueprints.model.sprintspecification import SprintSpecification
391 from lp.registry.interfaces.person import validate_public_person
392-
393-
394-class Sprint(SQLBase):
395+from lp.registry.model.hasdrivers import HasDriversMixin
396+
397+
398+class Sprint(SQLBase, HasDriversMixin):
399 """See `ISprint`."""
400
401 implements(ISprint, IHasLogo, IHasMugshot, IHasIcon)
402
403=== added file 'lib/lp/blueprints/tests/test_specification.py'
404--- lib/lp/blueprints/tests/test_specification.py 1970-01-01 00:00:00 +0000
405+++ lib/lp/blueprints/tests/test_specification.py 2010-11-24 18:10:40 +0000
406@@ -0,0 +1,90 @@
407+# Copyright 2010 Canonical Ltd. This software is licensed under the
408+# GNU Affero General Public License version 3 (see the file LICENSE).
409+
410+"""Unit tests for Specification."""
411+
412+__metaclass__ = type
413+
414+
415+from zope.security.interfaces import Unauthorized
416+from zope.security.proxy import removeSecurityProxy
417+
418+from canonical.testing.layers import DatabaseFunctionalLayer
419+from lp.blueprints.errors import TargetAlreadyHasSpecification
420+from lp.blueprints.interfaces.specification import SpecificationGoalStatus
421+from lp.testing import TestCaseWithFactory
422+
423+
424+class SpecificationTests(TestCaseWithFactory):
425+
426+ layer = DatabaseFunctionalLayer
427+
428+ def test_auto_accept_of_goal_for_drivers(self):
429+ """Drivers of a series accept the goal when they propose."""
430+ product = self.factory.makeProduct()
431+ proposer = self.factory.makePerson()
432+ productseries = self.factory.makeProductSeries(product=product)
433+ removeSecurityProxy(productseries).driver = proposer
434+ specification = self.factory.makeSpecification(product=product)
435+ specification.proposeGoal(productseries, proposer)
436+ self.assertEqual(
437+ SpecificationGoalStatus.ACCEPTED, specification.goalstatus)
438+
439+ def test_goal_not_accepted_for_non_drivers(self):
440+ """People who aren't drivers don't have their proposals approved."""
441+ product = self.factory.makeProduct()
442+ proposer = self.factory.makePerson()
443+ productseries = self.factory.makeProductSeries(product=product)
444+ specification = self.factory.makeSpecification(product=product)
445+ specification.proposeGoal(productseries, proposer)
446+ self.assertEqual(
447+ SpecificationGoalStatus.PROPOSED, specification.goalstatus)
448+
449+ def test_retarget_existing_specification(self):
450+ """An error is raised if the name is already taken."""
451+ product1 = self.factory.makeProduct()
452+ product2 = self.factory.makeProduct()
453+ specification1 = self.factory.makeSpecification(
454+ product=product1, name="foo")
455+ specification2 = self.factory.makeSpecification(
456+ product=product2, name="foo")
457+ self.assertRaises(
458+ TargetAlreadyHasSpecification,
459+ removeSecurityProxy(specification1).retarget, product2)
460+
461+ def test_retarget_is_protected(self):
462+ specification = self.factory.makeSpecification(
463+ product=self.factory.makeProduct())
464+ self.assertRaises(
465+ Unauthorized, getattr, specification, 'retarget')
466+
467+ def test_validate_move_existing_specification(self):
468+ """An error is raised by validateMove if the name is already taken."""
469+ product1 = self.factory.makeProduct()
470+ product2 = self.factory.makeProduct()
471+ specification1 = self.factory.makeSpecification(
472+ product=product1, name="foo")
473+ specification2 = self.factory.makeSpecification(
474+ product=product2, name="foo")
475+ self.assertRaises(
476+ TargetAlreadyHasSpecification, specification1.validateMove,
477+ product2)
478+
479+ def test_setTarget(self):
480+ product = self.factory.makeProduct()
481+ specification = self.factory.makeSpecification(product=product)
482+ self.assertEqual(product, specification.target)
483+ self.assertIs(None, specification.distribution)
484+
485+ distribution = self.factory.makeDistribution()
486+ removeSecurityProxy(specification).setTarget(distribution)
487+
488+ self.assertEqual(distribution, specification.target)
489+ self.assertEqual(distribution, specification.distribution)
490+ self.assertIs(None, specification.product)
491+
492+ def test_setTarget_is_protected(self):
493+ specification = self.factory.makeSpecification(
494+ product=self.factory.makeProduct())
495+ self.assertRaises(
496+ Unauthorized, getattr, specification, 'setTarget')
497
498=== modified file 'lib/lp/registry/model/distribution.py'
499--- lib/lp/registry/model/distribution.py 2010-11-12 23:30:57 +0000
500+++ lib/lp/registry/model/distribution.py 2010-11-24 18:10:40 +0000
501@@ -146,6 +146,7 @@
502 Milestone,
503 )
504 from lp.registry.model.pillar import HasAliasMixin
505+from lp.registry.model.hasdrivers import HasDriversMixin
506 from lp.registry.model.sourcepackagename import SourcePackageName
507 from lp.registry.model.structuralsubscription import (
508 StructuralSubscriptionTargetMixin,
509@@ -201,7 +202,7 @@
510 HasTranslationImportsMixin, KarmaContextMixin,
511 OfficialBugTagTargetMixin, QuestionTargetMixin,
512 StructuralSubscriptionTargetMixin, HasMilestonesMixin,
513- HasBugHeatMixin):
514+ HasBugHeatMixin, HasDriversMixin):
515 """A distribution of an operating system, e.g. Debian GNU/Linux."""
516 implements(
517 IDistribution, IFAQTarget, IHasBugHeat, IHasBugSupervisor,
518
519=== added file 'lib/lp/registry/model/hasdrivers.py'
520--- lib/lp/registry/model/hasdrivers.py 1970-01-01 00:00:00 +0000
521+++ lib/lp/registry/model/hasdrivers.py 2010-11-24 18:10:40 +0000
522@@ -0,0 +1,22 @@
523+# Copyright 2010 Canonical Ltd. This software is licensed under the
524+# GNU Affero General Public License version 3 (see the file LICENSE).
525+
526+"""Common implementations for IHasDrivers."""
527+
528+__metaclass__ = type
529+
530+__all__ = [
531+ 'HasDriversMixin',
532+ ]
533+
534+from canonical.launchpad.interfaces.launchpad import IPersonRoles
535+
536+
537+class HasDriversMixin:
538+
539+ def personHasDriverRights(self, person):
540+ """See `IHasDrivers`."""
541+ person_roles = IPersonRoles(person)
542+ return (person_roles.isOneOfDrivers(self) or
543+ person_roles.isOwner(self) or
544+ person_roles.in_admin)
545
546=== modified file 'lib/lp/registry/model/product.py'
547--- lib/lp/registry/model/product.py 2010-11-19 17:27:35 +0000
548+++ lib/lp/registry/model/product.py 2010-11-24 18:10:40 +0000
549@@ -156,6 +156,7 @@
550 from lp.registry.model.productlicense import ProductLicense
551 from lp.registry.model.productrelease import ProductRelease
552 from lp.registry.model.productseries import ProductSeries
553+from lp.registry.model.hasdrivers import HasDriversMixin
554 from lp.registry.model.sourcepackagename import SourcePackageName
555 from lp.registry.model.structuralsubscription import (
556 StructuralSubscriptionTargetMixin,
557@@ -286,7 +287,7 @@
558
559
560 class Product(SQLBase, BugTargetBase, MakesAnnouncements,
561- HasSpecificationsMixin, HasSprintsMixin,
562+ HasDriversMixin, HasSpecificationsMixin, HasSprintsMixin,
563 KarmaContextMixin, BranchVisibilityPolicyMixin,
564 QuestionTargetMixin, HasTranslationImportsMixin,
565 HasAliasMixin, StructuralSubscriptionTargetMixin,
566
567=== modified file 'lib/lp/registry/model/projectgroup.py'
568--- lib/lp/registry/model/projectgroup.py 2010-11-09 08:43:34 +0000
569+++ lib/lp/registry/model/projectgroup.py 2010-11-24 18:10:40 +0000
570@@ -99,6 +99,7 @@
571 from lp.registry.model.pillar import HasAliasMixin
572 from lp.registry.model.product import Product
573 from lp.registry.model.productseries import ProductSeries
574+from lp.registry.model.hasdrivers import HasDriversMixin
575 from lp.registry.model.structuralsubscription import (
576 StructuralSubscriptionTargetMixin,
577 )
578@@ -111,7 +112,7 @@
579 KarmaContextMixin, BranchVisibilityPolicyMixin,
580 StructuralSubscriptionTargetMixin,
581 HasBranchesMixin, HasMergeProposalsMixin, HasBugHeatMixin,
582- HasMilestonesMixin):
583+ HasMilestonesMixin, HasDriversMixin):
584 """A ProjectGroup"""
585
586 implements(IProjectGroup, IFAQCollection, IHasBugHeat, IHasIcon, IHasLogo,
587
588=== modified file 'lib/lp/registry/model/series.py'
589--- lib/lp/registry/model/series.py 2010-08-20 20:31:18 +0000
590+++ lib/lp/registry/model/series.py 2010-11-24 18:10:40 +0000
591@@ -18,9 +18,10 @@
592 ISeriesMixin,
593 SeriesStatus,
594 )
595-
596-
597-class SeriesMixin:
598+from lp.registry.model.hasdrivers import HasDriversMixin
599+
600+
601+class SeriesMixin(HasDriversMixin):
602 """See `ISeriesMixin`."""
603
604 implements(ISeriesMixin)
605@@ -48,7 +49,7 @@
606
607 @property
608 def drivers(self):
609- """See `ISeriesMixin`."""
610+ """See `IHasDrivers`."""
611 drivers = set()
612 drivers.add(self.driver)
613 drivers = drivers.union(self.parent.drivers)