Merge lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad
- workitems-widget-help-popup
- Merge into devel
Proposed by
Guilherme Salgado
Status: | Superseded | ||||
---|---|---|---|---|---|
Proposed branch: | lp:~salgado/launchpad/workitems-widget-help-popup | ||||
Merge into: | lp:launchpad | ||||
Diff against target: |
864 lines (+599/-6) 12 files modified
lib/lp/blueprints/adapters.py (+3/-1) lib/lp/blueprints/browser/configure.zcml (+6/-0) lib/lp/blueprints/browser/specification.py (+21/-1) lib/lp/blueprints/configure.zcml (+2/-1) lib/lp/blueprints/help/workitems-help.html (+48/-0) lib/lp/blueprints/interfaces/specification.py (+19/-0) lib/lp/blueprints/model/specification.py (+30/-2) lib/lp/blueprints/model/tests/test_specification.py (+91/-0) lib/lp/blueprints/templates/specification-index.pt (+26/-1) lib/lp/blueprints/tests/test_webservice.py (+6/-0) lib/lp/services/fields/__init__.py (+99/-0) lib/lp/services/fields/tests/test_fields.py (+248/-0) |
||||
To merge this branch: | bzr merge lp:~salgado/launchpad/workitems-widget-help-popup | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Launchpad code reviewers | Pending | ||
Review via email: mp+95893@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/blueprints/adapters.py' |
2 | --- lib/lp/blueprints/adapters.py 2010-07-30 12:56:27 +0000 |
3 | +++ lib/lp/blueprints/adapters.py 2012-03-05 13:11:20 +0000 |
4 | @@ -18,12 +18,14 @@ |
5 | summary=None, whiteboard=None, specurl=None, productseries=None, |
6 | distroseries=None, milestone=None, name=None, priority=None, |
7 | definition_status=None, target=None, bugs_linked=None, |
8 | - bugs_unlinked=None, approver=None, assignee=None, drafter=None): |
9 | + bugs_unlinked=None, approver=None, assignee=None, drafter=None, |
10 | + workitems_text=None): |
11 | self.specification = specification |
12 | self.user = user |
13 | self.title = title |
14 | self.summary = summary |
15 | self.whiteboard = whiteboard |
16 | + self.workitems_text = workitems_text |
17 | self.specurl = specurl |
18 | self.productseries = productseries |
19 | self.distroseries = distroseries |
20 | |
21 | === modified file 'lib/lp/blueprints/browser/configure.zcml' |
22 | --- lib/lp/blueprints/browser/configure.zcml 2012-02-17 04:09:06 +0000 |
23 | +++ lib/lp/blueprints/browser/configure.zcml 2012-03-05 13:11:20 +0000 |
24 | @@ -373,6 +373,12 @@ |
25 | permission="launchpad.AnyPerson" |
26 | template="../templates/specification-edit.pt"/> |
27 | <browser:page |
28 | + name="+workitems" |
29 | + for="lp.blueprints.interfaces.specification.ISpecification" |
30 | + class="lp.blueprints.browser.specification.SpecificationEditWorkItemsView" |
31 | + permission="launchpad.AnyPerson" |
32 | + template="../templates/specification-edit.pt"/> |
33 | + <browser:page |
34 | name="+people" |
35 | for="lp.blueprints.interfaces.specification.ISpecification" |
36 | class="lp.blueprints.browser.specification.SpecificationEditPeopleView" |
37 | |
38 | === modified file 'lib/lp/blueprints/browser/specification.py' |
39 | --- lib/lp/blueprints/browser/specification.py 2012-03-01 20:00:29 +0000 |
40 | +++ lib/lp/blueprints/browser/specification.py 2012-03-05 13:11:20 +0000 |
41 | @@ -21,6 +21,7 @@ |
42 | 'SpecificationEditStatusView', |
43 | 'SpecificationEditView', |
44 | 'SpecificationEditWhiteboardView', |
45 | + 'SpecificationEditWorkItemsView', |
46 | 'SpecificationGoalDecideView', |
47 | 'SpecificationGoalProposeView', |
48 | 'SpecificationLinkBranchView', |
49 | @@ -411,7 +412,7 @@ |
50 | |
51 | usedfor = ISpecification |
52 | links = ['edit', 'people', 'status', 'priority', |
53 | - 'whiteboard', 'proposegoal', |
54 | + 'whiteboard', 'proposegoal', 'workitems', |
55 | 'milestone', 'requestfeedback', 'givefeedback', 'subscription', |
56 | 'addsubscriber', |
57 | 'linkbug', 'unlinkbug', 'linkbranch', |
58 | @@ -521,6 +522,11 @@ |
59 | return Link('+whiteboard', text, icon='edit') |
60 | |
61 | @enabled_with_permission('launchpad.AnyPerson') |
62 | + def workitems(self): |
63 | + text = 'Edit work items' |
64 | + return Link('+workitems', text, icon='edit') |
65 | + |
66 | + @enabled_with_permission('launchpad.AnyPerson') |
67 | def linkbranch(self): |
68 | if self.context.linked_branches.count() > 0: |
69 | text = 'Link to another branch' |
70 | @@ -648,6 +654,14 @@ |
71 | hide_empty=False) |
72 | |
73 | @property |
74 | + def workitems_text_widget(self): |
75 | + """The Work Items text as a widget.""" |
76 | + return TextAreaEditorWidget( |
77 | + self.context, ISpecification['workitems_text'], title="Work Items", |
78 | + edit_view='+workitems', edit_title='Edit work items', |
79 | + hide_empty=False) |
80 | + |
81 | + @property |
82 | def direction_widget(self): |
83 | return BooleanChoiceWidget( |
84 | self.context, ISpecification['direction_approved'], |
85 | @@ -710,6 +724,12 @@ |
86 | custom_widget('whiteboard', TextAreaWidget, height=15) |
87 | |
88 | |
89 | +class SpecificationEditWorkItemsView(SpecificationEditView): |
90 | + label = 'Edit specification work items' |
91 | + field_names = ['workitems_text'] |
92 | + custom_widget('workitems_text', TextAreaWidget, height=15) |
93 | + |
94 | + |
95 | class SpecificationEditPeopleView(SpecificationEditView): |
96 | label = 'Change the people involved' |
97 | field_names = ['assignee', 'drafter', 'approver', 'whiteboard'] |
98 | |
99 | === modified file 'lib/lp/blueprints/configure.zcml' |
100 | --- lib/lp/blueprints/configure.zcml 2012-02-10 17:32:41 +0000 |
101 | +++ lib/lp/blueprints/configure.zcml 2012-03-05 13:11:20 +0000 |
102 | @@ -181,7 +181,8 @@ |
103 | <require |
104 | permission="launchpad.AnyPerson" |
105 | attributes="linkBug |
106 | - unlinkBug"/> |
107 | + unlinkBug |
108 | + setWorkItems"/> |
109 | </class> |
110 | |
111 | <class class="lp.blueprints.model.specificationbug.SpecificationBug"> |
112 | |
113 | === added file 'lib/lp/blueprints/help/workitems-help.html' |
114 | --- lib/lp/blueprints/help/workitems-help.html 1970-01-01 00:00:00 +0000 |
115 | +++ lib/lp/blueprints/help/workitems-help.html 2012-03-05 13:11:20 +0000 |
116 | @@ -0,0 +1,48 @@ |
117 | +<html> |
118 | + <head> |
119 | + <title>Blueprint work items</title> |
120 | + <link rel="stylesheet" type="text/css" |
121 | + href="/+icing/yui/cssreset/reset.css" /> |
122 | + <link rel="stylesheet" type="text/css" |
123 | + href="/+icing/yui/cssfonts/fonts.css" /> |
124 | + <link rel="stylesheet" type="text/css" |
125 | + href="/+icing/yui/cssbase/base.css" /> |
126 | + </head> |
127 | + <body> |
128 | + <h1>Using work items</h1> |
129 | + |
130 | + Often, it can take a few separate steps to complete the work described in a |
131 | + blueprint. Launchpad lets you track these steps in the "Work items" text box. |
132 | + |
133 | + <h2>Describing work items</h2> |
134 | + |
135 | + It's easy to track the steps, or work items, necessary to complete the |
136 | + blueprint. Using the <em>Work items</em> text box, give a short description of each work |
137 | + item along with its status. For example: |
138 | + |
139 | + <pre> |
140 | + |
141 | + Design the UI: DONE |
142 | + Test the UI: TODO |
143 | + Bootstrap the dev environment: POSTPONED |
144 | + </pre> |
145 | + |
146 | + Each work item goes on its own line, followed by a colon and its status. |
147 | + |
148 | + <h2>Work item statuses</h2> |
149 | + |
150 | + Each work item can have one of four statuses: |
151 | + |
152 | + <ul> |
153 | + <li>TODO</li> |
154 | + <li>INPROGRESS</li> |
155 | + <li>DONE</li> |
156 | + <li>POSTPONED</li> |
157 | + </ul> |
158 | + |
159 | + <h2>More about work items</h2> |
160 | + |
161 | + There's <a href="https://help.launchpad.net/WorkItems" target="_blank">more |
162 | + about using work items</a> in the Launchpad help wiki. |
163 | + </body> |
164 | +</html> |
165 | |
166 | === modified file 'lib/lp/blueprints/interfaces/specification.py' |
167 | --- lib/lp/blueprints/interfaces/specification.py 2012-02-29 13:27:51 +0000 |
168 | +++ lib/lp/blueprints/interfaces/specification.py 2012-03-05 13:11:20 +0000 |
169 | @@ -79,6 +79,7 @@ |
170 | PublicPersonChoice, |
171 | Summary, |
172 | Title, |
173 | + WorkItemsText, |
174 | ) |
175 | from lp.services.webapp import canonical_url |
176 | from lp.services.webapp.menu import structured |
177 | @@ -303,6 +304,13 @@ |
178 | "Any notes on the status of this spec you would like to " |
179 | "make. Your changes will override the current text.")), |
180 | as_of="devel") |
181 | + workitems_text = exported( |
182 | + WorkItemsText( |
183 | + title=_('Work Items'), required=False, readonly=True, |
184 | + description=_( |
185 | + "Work items for this specification input in a text format. " |
186 | + "Your changes will override the current work items.")), |
187 | + as_of="devel") |
188 | direction_approved = exported( |
189 | Bool(title=_('Basic direction approved?'), |
190 | required=True, default=False, |
191 | @@ -616,6 +624,16 @@ |
192 | |
193 | export_as_webservice_entry(as_of="beta") |
194 | |
195 | + @mutator_for(ISpecificationPublic['workitems_text']) |
196 | + @operation_parameters(new_work_items=WorkItemsText()) |
197 | + @export_write_operation() |
198 | + @operation_for_version('devel') |
199 | + def setWorkItems(new_work_items): |
200 | + """Set work items on this specification. |
201 | + |
202 | + :param new_work_items: Work items to set. |
203 | + """ |
204 | + |
205 | @operation_parameters( |
206 | bug=Reference(schema=Interface)) # Really IBug |
207 | @export_write_operation() |
208 | @@ -694,6 +712,7 @@ |
209 | title = Attribute("The spec title or None.") |
210 | summary = Attribute("The spec summary or None.") |
211 | whiteboard = Attribute("The spec whiteboard or None.") |
212 | + workitems_text = Attribute("The spec work items as text or None.") |
213 | specurl = Attribute("The URL to the spec home page (not in Launchpad).") |
214 | productseries = Attribute("The product series.") |
215 | distroseries = Attribute("The series to which this is targeted.") |
216 | |
217 | === modified file 'lib/lp/blueprints/model/specification.py' |
218 | --- lib/lp/blueprints/model/specification.py 2012-02-29 13:03:19 +0000 |
219 | +++ lib/lp/blueprints/model/specification.py 2012-03-05 13:11:20 +0000 |
220 | @@ -224,6 +224,30 @@ |
221 | self._subscriptions, key=lambda sub: person_sort_key(sub.person)) |
222 | |
223 | @property |
224 | + def workitems_text(self): |
225 | + """See ISpecification.""" |
226 | + workitems_lines = [] |
227 | + milestone = None |
228 | + for work_item in self.work_items: |
229 | + if work_item.milestone != milestone: |
230 | + milestone = work_item.milestone |
231 | + # Separate milestone blocks, but no blank line if this is the |
232 | + # first work item |
233 | + if work_item.sequence > 0: |
234 | + workitems_lines.append("") |
235 | + workitems_lines.append("Work items for %s:" % milestone.name) |
236 | + assignee = work_item.assignee |
237 | + if assignee is not None: |
238 | + assignee_part = "[%s] " % assignee.name |
239 | + else: |
240 | + assignee_part = "" |
241 | + # work_items are ordered by sequence |
242 | + workitems_lines.append("%s%s: %s" % (assignee_part, |
243 | + work_item.title, |
244 | + work_item.status.name)) |
245 | + return "\n".join(workitems_lines) |
246 | + |
247 | + @property |
248 | def target(self): |
249 | """See ISpecification.""" |
250 | if self.product: |
251 | @@ -249,6 +273,10 @@ |
252 | SpecificationWorkItem, specification=self, |
253 | deleted=False).order_by("sequence") |
254 | |
255 | + def setWorkItems(self, new_work_items): |
256 | + field = ISpecification['workitems_text'].bind(self) |
257 | + self.updateWorkItems(field.parseAndValidate(new_work_items)) |
258 | + |
259 | def _deleteWorkItemsNotMatching(self, titles): |
260 | """Delete all work items whose title does not match the given ones. |
261 | |
262 | @@ -570,7 +598,7 @@ |
263 | "distroseries", "milestone")) |
264 | delta.recordNewAndOld(("name", "priority", "definition_status", |
265 | "target", "approver", "assignee", "drafter", |
266 | - "whiteboard")) |
267 | + "whiteboard", "workitems_text")) |
268 | delta.recordListAddedAndRemoved("bugs", |
269 | "bugs_linked", |
270 | "bugs_unlinked") |
271 | @@ -1035,7 +1063,7 @@ |
272 | |
273 | def new(self, name, title, specurl, summary, definition_status, |
274 | owner, approver=None, product=None, distribution=None, assignee=None, |
275 | - drafter=None, whiteboard=None, |
276 | + drafter=None, whiteboard=None, workitems_text=None, |
277 | priority=SpecificationPriority.UNDEFINED): |
278 | """See ISpecificationSet.""" |
279 | # Adapt the NewSpecificationDefinitionStatus item to a |
280 | |
281 | === modified file 'lib/lp/blueprints/model/tests/test_specification.py' |
282 | --- lib/lp/blueprints/model/tests/test_specification.py 2012-02-28 04:24:19 +0000 |
283 | +++ lib/lp/blueprints/model/tests/test_specification.py 2012-03-05 13:11:20 +0000 |
284 | @@ -17,6 +17,7 @@ |
285 | SpecificationWorkItemStatus, |
286 | ) |
287 | from lp.blueprints.model.specificationworkitem import SpecificationWorkItem |
288 | +from lp.registry.model.milestone import Milestone |
289 | from lp.services.webapp import canonical_url |
290 | from lp.testing import ( |
291 | ANONYMOUS, |
292 | @@ -152,6 +153,22 @@ |
293 | |
294 | layer = DatabaseFunctionalLayer |
295 | |
296 | + def assertWorkItemsTextContains(self, spec, items): |
297 | + expected_lines = [] |
298 | + for item in items: |
299 | + if isinstance(item, SpecificationWorkItem): |
300 | + line = '' |
301 | + if item.assignee is not None: |
302 | + line = "[%s] " % item.assignee.name |
303 | + expected_lines.append(u"%s%s: %s" % (line, item.title, |
304 | + item.status.name)) |
305 | + else: |
306 | + self.assertIsInstance(item, Milestone) |
307 | + expected_lines.append(u"") |
308 | + expected_lines.append(u"Work items for %s:" % item.name) |
309 | + expected = "\n".join(expected_lines) |
310 | + self.assertEqual(expected, spec.workitems_text) |
311 | + |
312 | def test_anonymous_newworkitem_not_allowed(self): |
313 | spec = self.factory.makeSpecification() |
314 | login(ANONYMOUS) |
315 | @@ -179,6 +196,80 @@ |
316 | self.assertEqual(title, work_item.title) |
317 | self.assertEqual(milestone, work_item.milestone) |
318 | |
319 | + def test_workitems_text_no_workitems(self): |
320 | + spec = self.factory.makeSpecification() |
321 | + self.assertEqual('', spec.workitems_text) |
322 | + |
323 | + def test_workitems_text_deleted_workitem(self): |
324 | + work_item = self.factory.makeSpecificationWorkItem(deleted=True) |
325 | + self.assertEqual('', work_item.specification.workitems_text) |
326 | + |
327 | + def test_workitems_text_single_workitem(self): |
328 | + work_item = self.factory.makeSpecificationWorkItem() |
329 | + self.assertWorkItemsTextContains(work_item.specification, [work_item]) |
330 | + |
331 | + def test_workitems_text_multi_workitems_all_statuses(self): |
332 | + spec = self.factory.makeSpecification() |
333 | + work_item1 = self.factory.makeSpecificationWorkItem(specification=spec, |
334 | + status=SpecificationWorkItemStatus.TODO) |
335 | + work_item2 = self.factory.makeSpecificationWorkItem(specification=spec, |
336 | + status=SpecificationWorkItemStatus.DONE) |
337 | + work_item3 = self.factory.makeSpecificationWorkItem(specification=spec, |
338 | + status=SpecificationWorkItemStatus.POSTPONED) |
339 | + work_item4 = self.factory.makeSpecificationWorkItem(specification=spec, |
340 | + status=SpecificationWorkItemStatus.INPROGRESS) |
341 | + work_item5 = self.factory.makeSpecificationWorkItem(specification=spec, |
342 | + status=SpecificationWorkItemStatus.BLOCKED) |
343 | + work_items = [work_item1, work_item2, work_item3, work_item4, work_item5] |
344 | + self.assertWorkItemsTextContains(spec, work_items) |
345 | + |
346 | + def test_workitems_text_with_milestone(self): |
347 | + spec = self.factory.makeSpecification() |
348 | + milestone = self.factory.makeMilestone(product=spec.product) |
349 | + login_person(spec.owner) |
350 | + work_item = self.factory.makeSpecificationWorkItem(specification=spec, |
351 | + title=u'new-work-item', |
352 | + status=SpecificationWorkItemStatus.TODO, |
353 | + milestone=milestone) |
354 | + items = [milestone, work_item] |
355 | + self.assertWorkItemsTextContains(spec, items) |
356 | + |
357 | + def test_workitems_text_with_implicit_and_explicit_milestone(self): |
358 | + spec = self.factory.makeSpecification() |
359 | + milestone = self.factory.makeMilestone(product=spec.product) |
360 | + login_person(spec.owner) |
361 | + work_item1 = self.factory.makeSpecificationWorkItem(specification=spec, |
362 | + title=u'Work item with default milestone', |
363 | + status=SpecificationWorkItemStatus.TODO, |
364 | + milestone=None) |
365 | + work_item2 = self.factory.makeSpecificationWorkItem(specification=spec, |
366 | + title=u'Work item with set milestone', |
367 | + status=SpecificationWorkItemStatus.TODO, |
368 | + milestone=milestone) |
369 | + items = [work_item1, milestone, work_item2] |
370 | + self.assertWorkItemsTextContains(spec, items) |
371 | + |
372 | + def test_workitems_text_with_different_milestones(self): |
373 | + spec = self.factory.makeSpecification() |
374 | + milestone1 = self.factory.makeMilestone(product=spec.product) |
375 | + milestone2 = self.factory.makeMilestone(product=spec.product) |
376 | + login_person(spec.owner) |
377 | + work_item1 = self.factory.makeSpecificationWorkItem(specification=spec, |
378 | + title=u'Work item with first milestone', |
379 | + status=SpecificationWorkItemStatus.TODO, |
380 | + milestone=milestone1) |
381 | + work_item2 = self.factory.makeSpecificationWorkItem(specification=spec, |
382 | + title=u'Work item with second milestone', |
383 | + status=SpecificationWorkItemStatus.TODO, |
384 | + milestone=milestone2) |
385 | + items = [milestone1, work_item1, milestone2, work_item2] |
386 | + self.assertWorkItemsTextContains(spec, items) |
387 | + |
388 | + def test_workitems_text_with_assignee(self): |
389 | + assignee = self.factory.makePerson() |
390 | + work_item = self.factory.makeSpecificationWorkItem(assignee=assignee) |
391 | + self.assertWorkItemsTextContains(work_item.specification, [work_item]) |
392 | + |
393 | def test_work_items_property(self): |
394 | spec = self.factory.makeSpecification() |
395 | wi1 = self.factory.makeSpecificationWorkItem( |
396 | |
397 | === modified file 'lib/lp/blueprints/templates/specification-index.pt' |
398 | --- lib/lp/blueprints/templates/specification-index.pt 2012-02-01 15:31:32 +0000 |
399 | +++ lib/lp/blueprints/templates/specification-index.pt 2012-03-05 13:11:20 +0000 |
400 | @@ -284,6 +284,12 @@ |
401 | </div> |
402 | |
403 | <div class="portlet"> |
404 | + <a href="/+help-blueprints/workitems-help.html" target="help" class="sprite maybe"> |
405 | + <span class="invisible-link">Tag help</span></a> |
406 | + <div class="wide" tal:content="structure view/workitems_text_widget" /> |
407 | + </div> |
408 | + |
409 | + <div class="portlet"> |
410 | <tal:deptree condition="view/has_dep_tree"> |
411 | <h2>Dependency tree</h2> |
412 | |
413 | @@ -320,7 +326,7 @@ |
414 | </div> |
415 | |
416 | <script type="text/javascript"> |
417 | - LPJS.use('lp.anim', 'lp.ui', function(Y) { |
418 | + LPJS.use('lp.anim', 'lp.ui', 'node', 'widget', function(Y) { |
419 | |
420 | Y.on('lp:context:implementation_status:changed', function(e) { |
421 | var icon = Y.one('#informational-icon'); |
422 | @@ -361,6 +367,25 @@ |
423 | window.document.title = title; |
424 | }); |
425 | |
426 | + // Watch for the whiteboard for edit mode so we can show/hide a |
427 | + // message to the user to make sure not to put work items in there. |
428 | + var whiteboard_node = Y.one('#edit-whiteboard'); |
429 | + var whiteboard = Y.Widget.getByNode(whiteboard_node); |
430 | + var notice_node = Y.Node.create('<p/>'); |
431 | + notice_node.set('id', 'wimessage'); |
432 | + notice_node.addClass('informational message'); |
433 | + notice_node.setContent('Please note that work items go in the separate Work Items input field below.'); |
434 | + whiteboard.editor.on('visibleChange', function (ev) { |
435 | + var par = whiteboard_node.get('parentNode'); |
436 | + // If we're visible, show the message |
437 | + if (ev.newVal) { |
438 | + par.insertBefore(notice_node, whiteboard_node); |
439 | + } else { |
440 | + // Otherwise we need to remove the node |
441 | + par.removeChild(notice_node) |
442 | + } |
443 | + }); |
444 | + |
445 | }); |
446 | </script> |
447 | |
448 | |
449 | === modified file 'lib/lp/blueprints/tests/test_webservice.py' |
450 | --- lib/lp/blueprints/tests/test_webservice.py 2012-01-01 02:58:52 +0000 |
451 | +++ lib/lp/blueprints/tests/test_webservice.py 2012-03-05 13:11:20 +0000 |
452 | @@ -143,6 +143,12 @@ |
453 | spec_webservice = self.getSpecOnWebservice(spec) |
454 | self.assertEqual(spec.whiteboard, spec_webservice.whiteboard) |
455 | |
456 | + def test_representation_contains_workitems(self): |
457 | + work_item = self.factory.makeSpecificationWorkItem() |
458 | + spec_webservice = self.getSpecOnWebservice(work_item.specification) |
459 | + self.assertEqual(work_item.specification.work_items_text, |
460 | + spec_webservice.work_items_text) |
461 | + |
462 | def test_representation_contains_milestone(self): |
463 | product = self.factory.makeProduct() |
464 | productseries = self.factory.makeProductSeries(product=product) |
465 | |
466 | === modified file 'lib/lp/services/fields/__init__.py' |
467 | --- lib/lp/services/fields/__init__.py 2012-02-16 00:38:53 +0000 |
468 | +++ lib/lp/services/fields/__init__.py 2012-03-05 13:11:20 +0000 |
469 | @@ -53,6 +53,7 @@ |
470 | 'URIField', |
471 | 'UniqueField', |
472 | 'Whiteboard', |
473 | + 'WorkItemsText', |
474 | 'is_public_person_or_closed_team', |
475 | 'is_public_person', |
476 | ] |
477 | @@ -102,12 +103,18 @@ |
478 | name_validator, |
479 | valid_name, |
480 | ) |
481 | +from lp.blueprints.enums import SpecificationWorkItemStatus |
482 | from lp.bugs.errors import InvalidDuplicateValue |
483 | from lp.registry.interfaces.pillar import IPillarNameSet |
484 | from lp.services.webapp.interfaces import ILaunchBag |
485 | |
486 | # Marker object to tell BaseImageUpload to keep the existing image. |
487 | KEEP_SAME_IMAGE = object() |
488 | +# Regexp for detecting milestone headers in work items text. |
489 | +MILESTONE_RE = re.compile('^work items(.*)\s*:\s*$', re.I) |
490 | +# Regexp for work items. |
491 | +WORKITEM_RE = re.compile( |
492 | + '^(\[(?P<assignee>.*)\])?\s*(?P<title>.*)\s*:\s*(?P<status>.*)\s*$', re.I) |
493 | |
494 | |
495 | # Field Interfaces |
496 | @@ -858,3 +865,95 @@ |
497 | else: |
498 | # The vocabulary prevents the revealing of private team names. |
499 | raise PrivateTeamNotAllowed(value) |
500 | + |
501 | + |
502 | +class WorkItemsText(Text): |
503 | + |
504 | + def parseLine(self, line): |
505 | + workitem_match = WORKITEM_RE.search(line) |
506 | + if workitem_match: |
507 | + assignee = workitem_match.group('assignee') |
508 | + title = workitem_match.group('title') |
509 | + status = workitem_match.group('status') |
510 | + else: |
511 | + raise LaunchpadValidationError( |
512 | + 'Invalid work item format: "%s"' % line) |
513 | + if title == '': |
514 | + raise LaunchpadValidationError( |
515 | + 'No work item title found on "%s"' % line) |
516 | + if title.startswith('['): |
517 | + raise LaunchpadValidationError( |
518 | + 'Missing closing "]" for assignee on "%s".' % line) |
519 | + |
520 | + return {'title': title, 'status': status.strip().upper(), |
521 | + 'assignee': assignee} |
522 | + |
523 | + def parse(self, text): |
524 | + sequence = 0 |
525 | + milestone = None |
526 | + work_items = [] |
527 | + for line in text.splitlines(): |
528 | + if line.strip() == '': |
529 | + continue |
530 | + milestone_match = MILESTONE_RE.search(line) |
531 | + if milestone_match: |
532 | + milestone_part = milestone_match.group(1).strip() |
533 | + milestone = milestone_part.split()[-1] |
534 | + else: |
535 | + new_work_item = self.parseLine(line) |
536 | + new_work_item['milestone'] = milestone |
537 | + new_work_item['sequence'] = sequence |
538 | + sequence += 1 |
539 | + work_items.append(new_work_item) |
540 | + return work_items |
541 | + |
542 | + def validate(self, value): |
543 | + self.parseAndValidate(value) |
544 | + |
545 | + def parseAndValidate(self, text): |
546 | + work_items = self.parse(text) |
547 | + for work_item in work_items: |
548 | + work_item['status'] = self.getStatus(work_item['status']) |
549 | + work_item['assignee'] = self.getAssignee(work_item['assignee']) |
550 | + work_item['milestone'] = self.getMilestone(work_item['milestone']) |
551 | + return work_items |
552 | + |
553 | + def getStatus(self, text): |
554 | + valid_statuses = SpecificationWorkItemStatus.items |
555 | + if text.lower() not in [item.name.lower() for item in valid_statuses]: |
556 | + raise LaunchpadValidationError('Unknown status: %s' % text) |
557 | + return valid_statuses[text.upper()] |
558 | + |
559 | + def getAssignee(self, assignee_name): |
560 | + if assignee_name is None: |
561 | + return None |
562 | + from lp.registry.interfaces.person import IPersonSet |
563 | + assignee = getUtility(IPersonSet).getByName(assignee_name) |
564 | + if assignee is None: |
565 | + raise LaunchpadValidationError("Unknown person name: %s" % assignee_name) |
566 | + return assignee |
567 | + |
568 | + def getMilestone(self, milestone_name): |
569 | + if milestone_name is None: |
570 | + return None |
571 | + |
572 | + target = self.context.target |
573 | + |
574 | + milestone = None |
575 | + from lp.registry.interfaces.distribution import IDistribution |
576 | + from lp.registry.interfaces.milestone import IMilestoneSet |
577 | + from lp.registry.interfaces.product import IProduct |
578 | + if IProduct.providedBy(target): |
579 | + milestone = getUtility(IMilestoneSet).getByNameAndProduct( |
580 | + milestone_name, target) |
581 | + elif IDistribution.providedBy(target): |
582 | + milestone = getUtility(IMilestoneSet).getByNameAndDistribution( |
583 | + milestone_name, target) |
584 | + else: |
585 | + raise AssertionError("Unexpected target type.") |
586 | + |
587 | + if milestone is None: |
588 | + raise LaunchpadValidationError("The milestone '%s' is not valid " |
589 | + "for the target '%s'." % \ |
590 | + (milestone_name, target.name)) |
591 | + return milestone |
592 | |
593 | === modified file 'lib/lp/services/fields/tests/test_fields.py' |
594 | --- lib/lp/services/fields/tests/test_fields.py 2012-01-01 02:58:52 +0000 |
595 | +++ lib/lp/services/fields/tests/test_fields.py 2012-03-05 13:11:20 +0000 |
596 | @@ -14,6 +14,7 @@ |
597 | from zope.schema.interfaces import TooShort |
598 | |
599 | from lp.app.validators import LaunchpadValidationError |
600 | +from lp.blueprints.enums import SpecificationWorkItemStatus |
601 | from lp.registry.interfaces.nameblacklist import INameBlacklistSet |
602 | from lp.registry.interfaces.person import ( |
603 | CLOSED_TEAM_POLICY, |
604 | @@ -26,6 +27,7 @@ |
605 | FormattableDate, |
606 | is_public_person_or_closed_team, |
607 | StrippableText, |
608 | + WorkItemsText, |
609 | ) |
610 | from lp.testing import ( |
611 | login_person, |
612 | @@ -108,6 +110,252 @@ |
613 | self.assertEqual(None, field.validate(u' a ')) |
614 | |
615 | |
616 | +class TestWorkItemsTextValidation(TestCaseWithFactory): |
617 | + |
618 | + layer = DatabaseFunctionalLayer |
619 | + |
620 | + def setUp(self): |
621 | + super(TestWorkItemsTextValidation, self).setUp() |
622 | + self.field = WorkItemsText(__name__='test') |
623 | + |
624 | + def test_parseandvalidate(self): |
625 | + status = SpecificationWorkItemStatus.TODO |
626 | + assignee = self.factory.makePerson() |
627 | + milestone = self.factory.makeMilestone() |
628 | + title = 'A work item' |
629 | + specification = self.factory.makeSpecification( |
630 | + product=milestone.product) |
631 | + field = self.field.bind(specification) |
632 | + work_items_text = ("Work items for %s:\n" |
633 | + "[%s]%s: %s" % (milestone.name, assignee.name, title, |
634 | + status.name)) |
635 | + work_item = field.parseAndValidate(work_items_text)[0] |
636 | + self.assertEqual({'assignee': assignee, |
637 | + 'milestone': milestone, |
638 | + 'sequence': 0, |
639 | + 'status': status, |
640 | + 'title': title}, work_item) |
641 | + |
642 | + def test_unknown_assignee_is_rejected(self): |
643 | + person_name = 'test-person' |
644 | + self.assertRaises( |
645 | + LaunchpadValidationError, self.field.getAssignee, person_name) |
646 | + |
647 | + def test_validate_valid_assignee(self): |
648 | + assignee = self.factory.makePerson() |
649 | + self.assertEqual(assignee, self.field.getAssignee(assignee.name)) |
650 | + |
651 | + def test_validate_unset_assignee(self): |
652 | + self.assertIs(None, self.field.getAssignee(None)) |
653 | + |
654 | + def test_validate_unset_milestone(self): |
655 | + self.assertIs(None, self.field.getMilestone(None)) |
656 | + |
657 | + def test_validate_unknown_milestone(self): |
658 | + specification = self.factory.makeSpecification() |
659 | + field = self.field.bind(specification) |
660 | + self.assertRaises( |
661 | + LaunchpadValidationError, field.getMilestone, 'does-not-exist') |
662 | + |
663 | + def test_validate_valid_product_milestone(self): |
664 | + milestone = self.factory.makeMilestone() |
665 | + specification = self.factory.makeSpecification( |
666 | + product=milestone.product) |
667 | + field = self.field.bind(specification) |
668 | + self.assertEqual(milestone, field.getMilestone(milestone.name)) |
669 | + |
670 | + def test_validate_valid_distro_milestone(self): |
671 | + distro = self.factory.makeDistribution() |
672 | + milestone = self.factory.makeMilestone(distribution=distro) |
673 | + specification = self.factory.makeSpecification( |
674 | + distribution=milestone.distribution) |
675 | + field = self.field.bind(specification) |
676 | + self.assertEqual(milestone, field.getMilestone(milestone.name)) |
677 | + |
678 | + def test_validate_invalid_milestone(self): |
679 | + milestone_name = 'test-milestone' |
680 | + self.factory.makeMilestone(name=milestone_name) |
681 | + # Milestone exists but is not a target for this spec. |
682 | + specification = self.factory.makeSpecification(product=None) |
683 | + field = self.field.bind(specification) |
684 | + self.assertRaises( |
685 | + LaunchpadValidationError, field.getMilestone, milestone_name) |
686 | + |
687 | + def test_validate_invalid_status(self): |
688 | + self.assertRaises( |
689 | + LaunchpadValidationError, self.field.getStatus, |
690 | + 'Invalid status: FOO') |
691 | + |
692 | + def test_validate_valid_statuses(self): |
693 | + statuses = [SpecificationWorkItemStatus.TODO, |
694 | + SpecificationWorkItemStatus.DONE, |
695 | + SpecificationWorkItemStatus.POSTPONED, |
696 | + SpecificationWorkItemStatus.INPROGRESS, |
697 | + SpecificationWorkItemStatus.BLOCKED] |
698 | + for status in statuses: |
699 | + validated_status = self.field.getStatus(status.name) |
700 | + self.assertEqual(validated_status, status) |
701 | + |
702 | + |
703 | +class TestWorkItemsText(TestCase): |
704 | + |
705 | + def setUp(self): |
706 | + super(TestWorkItemsText, self).setUp() |
707 | + self.field = WorkItemsText(__name__='test') |
708 | + |
709 | + def test_validate_raises_LaunchpadValidationError(self): |
710 | + self.assertRaises( |
711 | + LaunchpadValidationError, self.field.validate, |
712 | + 'This is not a valid work item.') |
713 | + |
714 | + def test_single_line_parsing(self): |
715 | + work_items_title = 'Test this work item' |
716 | + parsed = self.field.parseLine('%s: TODO' % (work_items_title)) |
717 | + self.assertEqual(parsed['title'], work_items_title) |
718 | + self.assertEqual(parsed['status'], 'TODO') |
719 | + |
720 | + def test_url_and_colon_in_title(self): |
721 | + work_items_title = 'Test this: which is a url: http://www.linaro.org/' |
722 | + parsed = self.field.parseLine('%s: TODO' % (work_items_title)) |
723 | + self.assertEqual(parsed['title'], work_items_title) |
724 | + |
725 | + def test_silly_caps_status_parsing(self): |
726 | + parsed_upper = self.field.parseLine('Test this work item: TODO ') |
727 | + self.assertEqual(parsed_upper['status'], 'TODO') |
728 | + parsed_lower = self.field.parseLine('Test this work item: todo') |
729 | + self.assertEqual(parsed_lower['status'], 'TODO') |
730 | + parsed_camel = self.field.parseLine('Test this work item: ToDo') |
731 | + self.assertEqual(parsed_camel['status'], 'TODO') |
732 | + |
733 | + def test_parseLine_without_status_fails(self): |
734 | + # We should require an explicit status to avoid the problem of work |
735 | + # items with a url but no status. |
736 | + self.assertRaises( |
737 | + LaunchpadValidationError, self.field.parseLine, |
738 | + 'Missing status') |
739 | + |
740 | + def test_parseLine_without_title_fails(self): |
741 | + self.assertRaises( |
742 | + LaunchpadValidationError, self.field.parseLine, |
743 | + ':TODO') |
744 | + |
745 | + def test_parseLine_without_title_with_assignee_fails(self): |
746 | + self.assertRaises( |
747 | + LaunchpadValidationError, self.field.parseLine, |
748 | + '[test-person] :TODO') |
749 | + |
750 | + def test_multi_line_parsing(self): |
751 | + title_1 = 'Work item 1' |
752 | + title_2 = 'Work item 2' |
753 | + work_items_text = "%s: TODO\n%s: POSTPONED" % (title_1, title_2) |
754 | + parsed = self.field.parse(work_items_text) |
755 | + self.assertEqual( |
756 | + parsed, [{'title': title_1, |
757 | + 'status': 'TODO', |
758 | + 'assignee': None, 'milestone': None, 'sequence': 0}, |
759 | + {'title': title_2, |
760 | + 'status': 'POSTPONED', |
761 | + 'assignee': None, 'milestone': None, 'sequence': 1}]) |
762 | + |
763 | + def test_parse_assignee(self): |
764 | + title = 'Work item 1' |
765 | + assignee = 'test-person' |
766 | + work_items_text = "[%s]%s: TODO" % (assignee, title) |
767 | + parsed = self.field.parseLine(work_items_text) |
768 | + self.assertEqual(parsed['assignee'], assignee) |
769 | + |
770 | + def test_parse_assignee_with_space(self): |
771 | + title = 'Work item 1' |
772 | + assignee = 'test-person' |
773 | + work_items_text = "[%s] %s: TODO" % (assignee, title) |
774 | + parsed = self.field.parseLine(work_items_text) |
775 | + self.assertEqual(parsed['assignee'], assignee) |
776 | + |
777 | + def test_parseLine_with_missing_closing_bracket_for_assignee(self): |
778 | + self.assertRaises( |
779 | + LaunchpadValidationError, self.field.parseLine, |
780 | + "[test-person A single work item: TODO") |
781 | + |
782 | + def test_parse_empty_lines_have_no_meaning(self): |
783 | + parsed = self.field.parse("\n\n\n\n\n\n\n\n") |
784 | + self.assertEqual(parsed, []) |
785 | + |
786 | + def test_parse_status(self): |
787 | + work_items_text = "A single work item: TODO" |
788 | + parsed = self.field.parse(work_items_text) |
789 | + self.assertEqual(parsed[0]['status'], 'TODO') |
790 | + |
791 | + def test_parse_milestone(self): |
792 | + milestone = '2012.02' |
793 | + title = "Work item for a milestone" |
794 | + work_items_text = "Work items for %s:\n%s: TODO" % (milestone, title) |
795 | + parsed = self.field.parse(work_items_text) |
796 | + self.assertEqual(parsed, [{'title': title, |
797 | + 'status': 'TODO', |
798 | + 'assignee': None, 'milestone': milestone, 'sequence': 0}]) |
799 | + |
800 | + def test_parse_multi_milestones(self): |
801 | + milestone_1 = '2012.02' |
802 | + milestone_2 = '2012.03' |
803 | + title_1 = "Work item for a milestone" |
804 | + title_2 = "Work item for a later milestone" |
805 | + work_items_text = ("Work items for %s:\n%s: POSTPONED\n\nWork items " |
806 | + "for %s:\n%s: TODO" % (milestone_1, title_1, |
807 | + milestone_2, title_2)) |
808 | + parsed = self.field.parse(work_items_text) |
809 | + self.assertEqual(parsed, |
810 | + [{'title': title_1, |
811 | + 'status': 'POSTPONED', |
812 | + 'assignee': None, 'milestone': milestone_1, |
813 | + 'sequence': 0}, |
814 | + {'title': title_2, |
815 | + 'status': 'TODO', |
816 | + 'assignee': None, 'milestone': milestone_2, |
817 | + 'sequence': 1}]) |
818 | + |
819 | + def test_parse_orphaned_work_items(self): |
820 | + # Work items not in a milestone block belong to the latest specified |
821 | + # milestone. |
822 | + milestone_1 = '2012.02' |
823 | + milestone_2 = '2012.03' |
824 | + title_1 = "Work item for a milestone" |
825 | + title_2 = "Work item for a later milestone" |
826 | + title_3 = "A work item preceeded by a blank line" |
827 | + work_items_text = ( |
828 | + "Work items for %s:\n%s: POSTPONED\n\nWork items for %s:\n%s: " |
829 | + "TODO\n\n%s: TODO" % (milestone_1, title_1, milestone_2, title_2, |
830 | + title_3)) |
831 | + parsed = self.field.parse(work_items_text) |
832 | + self.assertEqual(parsed, |
833 | + [{'title': title_1, |
834 | + 'status': 'POSTPONED', |
835 | + 'assignee': None, 'milestone': milestone_1, |
836 | + 'sequence': 0}, |
837 | + {'title': title_2, |
838 | + 'status': 'TODO', |
839 | + 'assignee': None, 'milestone': milestone_2, |
840 | + 'sequence': 1}, |
841 | + {'title': title_3, |
842 | + 'status': 'TODO', |
843 | + 'assignee': None, 'milestone': milestone_2, |
844 | + 'sequence': 2}]) |
845 | + |
846 | + def test_sequence_single_workitem(self): |
847 | + parsed = self.field.parse("A single work item: TODO") |
848 | + self.assertEqual(0, parsed[0]['sequence']) |
849 | + |
850 | + def test_only_workitems_get_sequence(self): |
851 | + parsed = self.field.parse("A single work item: TODO\n" |
852 | + "A second work item: TODO\n" |
853 | + "\n" |
854 | + "Work items for 2012.02:\n" |
855 | + "Work item for a milestone: TODO\n") |
856 | + self.assertEqual([(wi['title'], wi['sequence']) for wi in parsed], |
857 | + [("A single work item", 0), ("A second work item", 1), |
858 | + ("Work item for a milestone", 2)]) |
859 | + |
860 | + |
861 | + |
862 | class TestBlacklistableContentNameField(TestCaseWithFactory): |
863 | |
864 | layer = DatabaseFunctionalLayer |