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