Merge lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad

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
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+95893@code.launchpad.net
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">&nbsp;
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