launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06589
[Merge] lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad
Guilherme Salgado has proposed merging lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #944119 in Launchpad itself: "Add help popup to the new work-item editor"
https://bugs.launchpad.net/launchpad/+bug/944119
For more details, see:
https://code.launchpad.net/~salgado/launchpad/workitems-widget-help-popup/+merge/95893
--
https://code.launchpad.net/~salgado/launchpad/workitems-widget-help-popup/+merge/95893
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad.
=== modified file 'lib/lp/blueprints/adapters.py'
--- lib/lp/blueprints/adapters.py 2010-07-30 12:56:27 +0000
+++ lib/lp/blueprints/adapters.py 2012-03-05 13:06:21 +0000
@@ -18,12 +18,14 @@
summary=None, whiteboard=None, specurl=None, productseries=None,
distroseries=None, milestone=None, name=None, priority=None,
definition_status=None, target=None, bugs_linked=None,
- bugs_unlinked=None, approver=None, assignee=None, drafter=None):
+ bugs_unlinked=None, approver=None, assignee=None, drafter=None,
+ workitems_text=None):
self.specification = specification
self.user = user
self.title = title
self.summary = summary
self.whiteboard = whiteboard
+ self.workitems_text = workitems_text
self.specurl = specurl
self.productseries = productseries
self.distroseries = distroseries
=== modified file 'lib/lp/blueprints/browser/configure.zcml'
--- lib/lp/blueprints/browser/configure.zcml 2012-02-17 04:09:06 +0000
+++ lib/lp/blueprints/browser/configure.zcml 2012-03-05 13:06:21 +0000
@@ -373,6 +373,12 @@
permission="launchpad.AnyPerson"
template="../templates/specification-edit.pt"/>
<browser:page
+ name="+workitems"
+ for="lp.blueprints.interfaces.specification.ISpecification"
+ class="lp.blueprints.browser.specification.SpecificationEditWorkItemsView"
+ permission="launchpad.AnyPerson"
+ template="../templates/specification-edit.pt"/>
+ <browser:page
name="+people"
for="lp.blueprints.interfaces.specification.ISpecification"
class="lp.blueprints.browser.specification.SpecificationEditPeopleView"
=== modified file 'lib/lp/blueprints/browser/specification.py'
--- lib/lp/blueprints/browser/specification.py 2012-03-01 20:00:29 +0000
+++ lib/lp/blueprints/browser/specification.py 2012-03-05 13:06:21 +0000
@@ -21,6 +21,7 @@
'SpecificationEditStatusView',
'SpecificationEditView',
'SpecificationEditWhiteboardView',
+ 'SpecificationEditWorkItemsView',
'SpecificationGoalDecideView',
'SpecificationGoalProposeView',
'SpecificationLinkBranchView',
@@ -411,7 +412,7 @@
usedfor = ISpecification
links = ['edit', 'people', 'status', 'priority',
- 'whiteboard', 'proposegoal',
+ 'whiteboard', 'proposegoal', 'workitems',
'milestone', 'requestfeedback', 'givefeedback', 'subscription',
'addsubscriber',
'linkbug', 'unlinkbug', 'linkbranch',
@@ -521,6 +522,11 @@
return Link('+whiteboard', text, icon='edit')
@enabled_with_permission('launchpad.AnyPerson')
+ def workitems(self):
+ text = 'Edit work items'
+ return Link('+workitems', text, icon='edit')
+
+ @enabled_with_permission('launchpad.AnyPerson')
def linkbranch(self):
if self.context.linked_branches.count() > 0:
text = 'Link to another branch'
@@ -648,6 +654,14 @@
hide_empty=False)
@property
+ def workitems_text_widget(self):
+ """The Work Items text as a widget."""
+ return TextAreaEditorWidget(
+ self.context, ISpecification['workitems_text'], title="Work Items",
+ edit_view='+workitems', edit_title='Edit work items',
+ hide_empty=False)
+
+ @property
def direction_widget(self):
return BooleanChoiceWidget(
self.context, ISpecification['direction_approved'],
@@ -710,6 +724,12 @@
custom_widget('whiteboard', TextAreaWidget, height=15)
+class SpecificationEditWorkItemsView(SpecificationEditView):
+ label = 'Edit specification work items'
+ field_names = ['workitems_text']
+ custom_widget('workitems_text', TextAreaWidget, height=15)
+
+
class SpecificationEditPeopleView(SpecificationEditView):
label = 'Change the people involved'
field_names = ['assignee', 'drafter', 'approver', 'whiteboard']
=== modified file 'lib/lp/blueprints/configure.zcml'
--- lib/lp/blueprints/configure.zcml 2012-02-10 17:32:41 +0000
+++ lib/lp/blueprints/configure.zcml 2012-03-05 13:06:21 +0000
@@ -181,7 +181,8 @@
<require
permission="launchpad.AnyPerson"
attributes="linkBug
- unlinkBug"/>
+ unlinkBug
+ setWorkItems"/>
</class>
<class class="lp.blueprints.model.specificationbug.SpecificationBug">
=== added file 'lib/lp/blueprints/help/workitems-help.html'
--- lib/lp/blueprints/help/workitems-help.html 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/help/workitems-help.html 2012-03-05 13:06:21 +0000
@@ -0,0 +1,48 @@
+<html>
+ <head>
+ <title>Blueprint work items</title>
+ <link rel="stylesheet" type="text/css"
+ href="/+icing/yui/cssreset/reset.css" />
+ <link rel="stylesheet" type="text/css"
+ href="/+icing/yui/cssfonts/fonts.css" />
+ <link rel="stylesheet" type="text/css"
+ href="/+icing/yui/cssbase/base.css" />
+ </head>
+ <body>
+ <h1>Using work items</h1>
+
+ Often, it can take a few separate steps to complete the work described in a
+ blueprint. Launchpad lets you track these steps in the "Work items" text box.
+
+ <h2>Describing work items</h2>
+
+ It's easy to track the steps, or work items, necessary to complete the
+ blueprint. Using the <em>Work items</em> text box, give a short description of each work
+ item along with its status. For example:
+
+ <pre>
+
+ Design the UI: DONE
+ Test the UI: TODO
+ Bootstrap the dev environment: POSTPONED
+ </pre>
+
+ Each work item goes on its own line, followed by a colon and its status.
+
+ <h2>Work item statuses</h2>
+
+ Each work item can have one of four statuses:
+
+ <ul>
+ <li>TODO</li>
+ <li>INPROGRESS</li>
+ <li>DONE</li>
+ <li>POSTPONED</li>
+ </ul>
+
+ <h2>More about work items</h2>
+
+ There's <a href="https://help.launchpad.net/WorkItems" target="_blank">more
+ about using work items</a> in the Launchpad help wiki.
+ </body>
+</html>
=== modified file 'lib/lp/blueprints/interfaces/specification.py'
--- lib/lp/blueprints/interfaces/specification.py 2012-02-29 13:27:51 +0000
+++ lib/lp/blueprints/interfaces/specification.py 2012-03-05 13:06:21 +0000
@@ -79,6 +79,7 @@
PublicPersonChoice,
Summary,
Title,
+ WorkItemsText,
)
from lp.services.webapp import canonical_url
from lp.services.webapp.menu import structured
@@ -303,6 +304,13 @@
"Any notes on the status of this spec you would like to "
"make. Your changes will override the current text.")),
as_of="devel")
+ workitems_text = exported(
+ WorkItemsText(
+ title=_('Work Items'), required=False, readonly=True,
+ description=_(
+ "Work items for this specification input in a text format. "
+ "Your changes will override the current work items.")),
+ as_of="devel")
direction_approved = exported(
Bool(title=_('Basic direction approved?'),
required=True, default=False,
@@ -616,6 +624,16 @@
export_as_webservice_entry(as_of="beta")
+ @mutator_for(ISpecificationPublic['workitems_text'])
+ @operation_parameters(new_work_items=WorkItemsText())
+ @export_write_operation()
+ @operation_for_version('devel')
+ def setWorkItems(new_work_items):
+ """Set work items on this specification.
+
+ :param new_work_items: Work items to set.
+ """
+
@operation_parameters(
bug=Reference(schema=Interface)) # Really IBug
@export_write_operation()
@@ -694,6 +712,7 @@
title = Attribute("The spec title or None.")
summary = Attribute("The spec summary or None.")
whiteboard = Attribute("The spec whiteboard or None.")
+ workitems_text = Attribute("The spec work items as text or None.")
specurl = Attribute("The URL to the spec home page (not in Launchpad).")
productseries = Attribute("The product series.")
distroseries = Attribute("The series to which this is targeted.")
=== modified file 'lib/lp/blueprints/model/specification.py'
--- lib/lp/blueprints/model/specification.py 2012-02-29 13:03:19 +0000
+++ lib/lp/blueprints/model/specification.py 2012-03-05 13:06:21 +0000
@@ -224,6 +224,30 @@
self._subscriptions, key=lambda sub: person_sort_key(sub.person))
@property
+ def workitems_text(self):
+ """See ISpecification."""
+ workitems_lines = []
+ milestone = None
+ for work_item in self.work_items:
+ if work_item.milestone != milestone:
+ milestone = work_item.milestone
+ # Separate milestone blocks, but no blank line if this is the
+ # first work item
+ if work_item.sequence > 0:
+ workitems_lines.append("")
+ workitems_lines.append("Work items for %s:" % milestone.name)
+ assignee = work_item.assignee
+ if assignee is not None:
+ assignee_part = "[%s] " % assignee.name
+ else:
+ assignee_part = ""
+ # work_items are ordered by sequence
+ workitems_lines.append("%s%s: %s" % (assignee_part,
+ work_item.title,
+ work_item.status.name))
+ return "\n".join(workitems_lines)
+
+ @property
def target(self):
"""See ISpecification."""
if self.product:
@@ -249,6 +273,10 @@
SpecificationWorkItem, specification=self,
deleted=False).order_by("sequence")
+ def setWorkItems(self, new_work_items):
+ field = ISpecification['workitems_text'].bind(self)
+ self.updateWorkItems(field.parseAndValidate(new_work_items))
+
def _deleteWorkItemsNotMatching(self, titles):
"""Delete all work items whose title does not match the given ones.
@@ -570,7 +598,7 @@
"distroseries", "milestone"))
delta.recordNewAndOld(("name", "priority", "definition_status",
"target", "approver", "assignee", "drafter",
- "whiteboard"))
+ "whiteboard", "workitems_text"))
delta.recordListAddedAndRemoved("bugs",
"bugs_linked",
"bugs_unlinked")
@@ -1035,7 +1063,7 @@
def new(self, name, title, specurl, summary, definition_status,
owner, approver=None, product=None, distribution=None, assignee=None,
- drafter=None, whiteboard=None,
+ drafter=None, whiteboard=None, workitems_text=None,
priority=SpecificationPriority.UNDEFINED):
"""See ISpecificationSet."""
# Adapt the NewSpecificationDefinitionStatus item to a
=== modified file 'lib/lp/blueprints/model/tests/test_specification.py'
--- lib/lp/blueprints/model/tests/test_specification.py 2012-02-28 04:24:19 +0000
+++ lib/lp/blueprints/model/tests/test_specification.py 2012-03-05 13:06:21 +0000
@@ -17,6 +17,7 @@
SpecificationWorkItemStatus,
)
from lp.blueprints.model.specificationworkitem import SpecificationWorkItem
+from lp.registry.model.milestone import Milestone
from lp.services.webapp import canonical_url
from lp.testing import (
ANONYMOUS,
@@ -152,6 +153,22 @@
layer = DatabaseFunctionalLayer
+ def assertWorkItemsTextContains(self, spec, items):
+ expected_lines = []
+ for item in items:
+ if isinstance(item, SpecificationWorkItem):
+ line = ''
+ if item.assignee is not None:
+ line = "[%s] " % item.assignee.name
+ expected_lines.append(u"%s%s: %s" % (line, item.title,
+ item.status.name))
+ else:
+ self.assertIsInstance(item, Milestone)
+ expected_lines.append(u"")
+ expected_lines.append(u"Work items for %s:" % item.name)
+ expected = "\n".join(expected_lines)
+ self.assertEqual(expected, spec.workitems_text)
+
def test_anonymous_newworkitem_not_allowed(self):
spec = self.factory.makeSpecification()
login(ANONYMOUS)
@@ -179,6 +196,80 @@
self.assertEqual(title, work_item.title)
self.assertEqual(milestone, work_item.milestone)
+ def test_workitems_text_no_workitems(self):
+ spec = self.factory.makeSpecification()
+ self.assertEqual('', spec.workitems_text)
+
+ def test_workitems_text_deleted_workitem(self):
+ work_item = self.factory.makeSpecificationWorkItem(deleted=True)
+ self.assertEqual('', work_item.specification.workitems_text)
+
+ def test_workitems_text_single_workitem(self):
+ work_item = self.factory.makeSpecificationWorkItem()
+ self.assertWorkItemsTextContains(work_item.specification, [work_item])
+
+ def test_workitems_text_multi_workitems_all_statuses(self):
+ spec = self.factory.makeSpecification()
+ work_item1 = self.factory.makeSpecificationWorkItem(specification=spec,
+ status=SpecificationWorkItemStatus.TODO)
+ work_item2 = self.factory.makeSpecificationWorkItem(specification=spec,
+ status=SpecificationWorkItemStatus.DONE)
+ work_item3 = self.factory.makeSpecificationWorkItem(specification=spec,
+ status=SpecificationWorkItemStatus.POSTPONED)
+ work_item4 = self.factory.makeSpecificationWorkItem(specification=spec,
+ status=SpecificationWorkItemStatus.INPROGRESS)
+ work_item5 = self.factory.makeSpecificationWorkItem(specification=spec,
+ status=SpecificationWorkItemStatus.BLOCKED)
+ work_items = [work_item1, work_item2, work_item3, work_item4, work_item5]
+ self.assertWorkItemsTextContains(spec, work_items)
+
+ def test_workitems_text_with_milestone(self):
+ spec = self.factory.makeSpecification()
+ milestone = self.factory.makeMilestone(product=spec.product)
+ login_person(spec.owner)
+ work_item = self.factory.makeSpecificationWorkItem(specification=spec,
+ title=u'new-work-item',
+ status=SpecificationWorkItemStatus.TODO,
+ milestone=milestone)
+ items = [milestone, work_item]
+ self.assertWorkItemsTextContains(spec, items)
+
+ def test_workitems_text_with_implicit_and_explicit_milestone(self):
+ spec = self.factory.makeSpecification()
+ milestone = self.factory.makeMilestone(product=spec.product)
+ login_person(spec.owner)
+ work_item1 = self.factory.makeSpecificationWorkItem(specification=spec,
+ title=u'Work item with default milestone',
+ status=SpecificationWorkItemStatus.TODO,
+ milestone=None)
+ work_item2 = self.factory.makeSpecificationWorkItem(specification=spec,
+ title=u'Work item with set milestone',
+ status=SpecificationWorkItemStatus.TODO,
+ milestone=milestone)
+ items = [work_item1, milestone, work_item2]
+ self.assertWorkItemsTextContains(spec, items)
+
+ def test_workitems_text_with_different_milestones(self):
+ spec = self.factory.makeSpecification()
+ milestone1 = self.factory.makeMilestone(product=spec.product)
+ milestone2 = self.factory.makeMilestone(product=spec.product)
+ login_person(spec.owner)
+ work_item1 = self.factory.makeSpecificationWorkItem(specification=spec,
+ title=u'Work item with first milestone',
+ status=SpecificationWorkItemStatus.TODO,
+ milestone=milestone1)
+ work_item2 = self.factory.makeSpecificationWorkItem(specification=spec,
+ title=u'Work item with second milestone',
+ status=SpecificationWorkItemStatus.TODO,
+ milestone=milestone2)
+ items = [milestone1, work_item1, milestone2, work_item2]
+ self.assertWorkItemsTextContains(spec, items)
+
+ def test_workitems_text_with_assignee(self):
+ assignee = self.factory.makePerson()
+ work_item = self.factory.makeSpecificationWorkItem(assignee=assignee)
+ self.assertWorkItemsTextContains(work_item.specification, [work_item])
+
def test_work_items_property(self):
spec = self.factory.makeSpecification()
wi1 = self.factory.makeSpecificationWorkItem(
=== modified file 'lib/lp/blueprints/templates/specification-index.pt'
--- lib/lp/blueprints/templates/specification-index.pt 2012-02-01 15:31:32 +0000
+++ lib/lp/blueprints/templates/specification-index.pt 2012-03-05 13:06:21 +0000
@@ -284,6 +284,12 @@
</div>
<div class="portlet">
+ <a href="/+help-blueprints/workitems-help.html" target="help" class="sprite maybe">
+ <span class="invisible-link">Tag help</span></a>
+ <div class="wide" tal:content="structure view/workitems_text_widget" />
+ </div>
+
+ <div class="portlet">
<tal:deptree condition="view/has_dep_tree">
<h2>Dependency tree</h2>
@@ -320,7 +326,7 @@
</div>
<script type="text/javascript">
- LPJS.use('lp.anim', 'lp.ui', function(Y) {
+ LPJS.use('lp.anim', 'lp.ui', 'node', 'widget', function(Y) {
Y.on('lp:context:implementation_status:changed', function(e) {
var icon = Y.one('#informational-icon');
@@ -361,6 +367,25 @@
window.document.title = title;
});
+ // Watch for the whiteboard for edit mode so we can show/hide a
+ // message to the user to make sure not to put work items in there.
+ var whiteboard_node = Y.one('#edit-whiteboard');
+ var whiteboard = Y.Widget.getByNode(whiteboard_node);
+ var notice_node = Y.Node.create('<p/>');
+ notice_node.set('id', 'wimessage');
+ notice_node.addClass('informational message');
+ notice_node.setContent('Please note that work items go in the separate Work Items input field below.');
+ whiteboard.editor.on('visibleChange', function (ev) {
+ var par = whiteboard_node.get('parentNode');
+ // If we're visible, show the message
+ if (ev.newVal) {
+ par.insertBefore(notice_node, whiteboard_node);
+ } else {
+ // Otherwise we need to remove the node
+ par.removeChild(notice_node)
+ }
+ });
+
});
</script>
=== modified file 'lib/lp/blueprints/tests/test_webservice.py'
--- lib/lp/blueprints/tests/test_webservice.py 2012-01-01 02:58:52 +0000
+++ lib/lp/blueprints/tests/test_webservice.py 2012-03-05 13:06:21 +0000
@@ -143,6 +143,12 @@
spec_webservice = self.getSpecOnWebservice(spec)
self.assertEqual(spec.whiteboard, spec_webservice.whiteboard)
+ def test_representation_contains_workitems(self):
+ work_item = self.factory.makeSpecificationWorkItem()
+ spec_webservice = self.getSpecOnWebservice(work_item.specification)
+ self.assertEqual(work_item.specification.work_items_text,
+ spec_webservice.work_items_text)
+
def test_representation_contains_milestone(self):
product = self.factory.makeProduct()
productseries = self.factory.makeProductSeries(product=product)
=== modified file 'lib/lp/services/fields/__init__.py'
--- lib/lp/services/fields/__init__.py 2012-02-16 00:38:53 +0000
+++ lib/lp/services/fields/__init__.py 2012-03-05 13:06:21 +0000
@@ -53,6 +53,7 @@
'URIField',
'UniqueField',
'Whiteboard',
+ 'WorkItemsText',
'is_public_person_or_closed_team',
'is_public_person',
]
@@ -102,12 +103,18 @@
name_validator,
valid_name,
)
+from lp.blueprints.enums import SpecificationWorkItemStatus
from lp.bugs.errors import InvalidDuplicateValue
from lp.registry.interfaces.pillar import IPillarNameSet
from lp.services.webapp.interfaces import ILaunchBag
# Marker object to tell BaseImageUpload to keep the existing image.
KEEP_SAME_IMAGE = object()
+# Regexp for detecting milestone headers in work items text.
+MILESTONE_RE = re.compile('^work items(.*)\s*:\s*$', re.I)
+# Regexp for work items.
+WORKITEM_RE = re.compile(
+ '^(\[(?P<assignee>.*)\])?\s*(?P<title>.*)\s*:\s*(?P<status>.*)\s*$', re.I)
# Field Interfaces
@@ -858,3 +865,95 @@
else:
# The vocabulary prevents the revealing of private team names.
raise PrivateTeamNotAllowed(value)
+
+
+class WorkItemsText(Text):
+
+ def parseLine(self, line):
+ workitem_match = WORKITEM_RE.search(line)
+ if workitem_match:
+ assignee = workitem_match.group('assignee')
+ title = workitem_match.group('title')
+ status = workitem_match.group('status')
+ else:
+ raise LaunchpadValidationError(
+ 'Invalid work item format: "%s"' % line)
+ if title == '':
+ raise LaunchpadValidationError(
+ 'No work item title found on "%s"' % line)
+ if title.startswith('['):
+ raise LaunchpadValidationError(
+ 'Missing closing "]" for assignee on "%s".' % line)
+
+ return {'title': title, 'status': status.strip().upper(),
+ 'assignee': assignee}
+
+ def parse(self, text):
+ sequence = 0
+ milestone = None
+ work_items = []
+ for line in text.splitlines():
+ if line.strip() == '':
+ continue
+ milestone_match = MILESTONE_RE.search(line)
+ if milestone_match:
+ milestone_part = milestone_match.group(1).strip()
+ milestone = milestone_part.split()[-1]
+ else:
+ new_work_item = self.parseLine(line)
+ new_work_item['milestone'] = milestone
+ new_work_item['sequence'] = sequence
+ sequence += 1
+ work_items.append(new_work_item)
+ return work_items
+
+ def validate(self, value):
+ self.parseAndValidate(value)
+
+ def parseAndValidate(self, text):
+ work_items = self.parse(text)
+ for work_item in work_items:
+ work_item['status'] = self.getStatus(work_item['status'])
+ work_item['assignee'] = self.getAssignee(work_item['assignee'])
+ work_item['milestone'] = self.getMilestone(work_item['milestone'])
+ return work_items
+
+ def getStatus(self, text):
+ valid_statuses = SpecificationWorkItemStatus.items
+ if text.lower() not in [item.name.lower() for item in valid_statuses]:
+ raise LaunchpadValidationError('Unknown status: %s' % text)
+ return valid_statuses[text.upper()]
+
+ def getAssignee(self, assignee_name):
+ if assignee_name is None:
+ return None
+ from lp.registry.interfaces.person import IPersonSet
+ assignee = getUtility(IPersonSet).getByName(assignee_name)
+ if assignee is None:
+ raise LaunchpadValidationError("Unknown person name: %s" % assignee_name)
+ return assignee
+
+ def getMilestone(self, milestone_name):
+ if milestone_name is None:
+ return None
+
+ target = self.context.target
+
+ milestone = None
+ from lp.registry.interfaces.distribution import IDistribution
+ from lp.registry.interfaces.milestone import IMilestoneSet
+ from lp.registry.interfaces.product import IProduct
+ if IProduct.providedBy(target):
+ milestone = getUtility(IMilestoneSet).getByNameAndProduct(
+ milestone_name, target)
+ elif IDistribution.providedBy(target):
+ milestone = getUtility(IMilestoneSet).getByNameAndDistribution(
+ milestone_name, target)
+ else:
+ raise AssertionError("Unexpected target type.")
+
+ if milestone is None:
+ raise LaunchpadValidationError("The milestone '%s' is not valid "
+ "for the target '%s'." % \
+ (milestone_name, target.name))
+ return milestone
=== modified file 'lib/lp/services/fields/tests/test_fields.py'
--- lib/lp/services/fields/tests/test_fields.py 2012-01-01 02:58:52 +0000
+++ lib/lp/services/fields/tests/test_fields.py 2012-03-05 13:06:21 +0000
@@ -14,6 +14,7 @@
from zope.schema.interfaces import TooShort
from lp.app.validators import LaunchpadValidationError
+from lp.blueprints.enums import SpecificationWorkItemStatus
from lp.registry.interfaces.nameblacklist import INameBlacklistSet
from lp.registry.interfaces.person import (
CLOSED_TEAM_POLICY,
@@ -26,6 +27,7 @@
FormattableDate,
is_public_person_or_closed_team,
StrippableText,
+ WorkItemsText,
)
from lp.testing import (
login_person,
@@ -108,6 +110,252 @@
self.assertEqual(None, field.validate(u' a '))
+class TestWorkItemsTextValidation(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestWorkItemsTextValidation, self).setUp()
+ self.field = WorkItemsText(__name__='test')
+
+ def test_parseandvalidate(self):
+ status = SpecificationWorkItemStatus.TODO
+ assignee = self.factory.makePerson()
+ milestone = self.factory.makeMilestone()
+ title = 'A work item'
+ specification = self.factory.makeSpecification(
+ product=milestone.product)
+ field = self.field.bind(specification)
+ work_items_text = ("Work items for %s:\n"
+ "[%s]%s: %s" % (milestone.name, assignee.name, title,
+ status.name))
+ work_item = field.parseAndValidate(work_items_text)[0]
+ self.assertEqual({'assignee': assignee,
+ 'milestone': milestone,
+ 'sequence': 0,
+ 'status': status,
+ 'title': title}, work_item)
+
+ def test_unknown_assignee_is_rejected(self):
+ person_name = 'test-person'
+ self.assertRaises(
+ LaunchpadValidationError, self.field.getAssignee, person_name)
+
+ def test_validate_valid_assignee(self):
+ assignee = self.factory.makePerson()
+ self.assertEqual(assignee, self.field.getAssignee(assignee.name))
+
+ def test_validate_unset_assignee(self):
+ self.assertIs(None, self.field.getAssignee(None))
+
+ def test_validate_unset_milestone(self):
+ self.assertIs(None, self.field.getMilestone(None))
+
+ def test_validate_unknown_milestone(self):
+ specification = self.factory.makeSpecification()
+ field = self.field.bind(specification)
+ self.assertRaises(
+ LaunchpadValidationError, field.getMilestone, 'does-not-exist')
+
+ def test_validate_valid_product_milestone(self):
+ milestone = self.factory.makeMilestone()
+ specification = self.factory.makeSpecification(
+ product=milestone.product)
+ field = self.field.bind(specification)
+ self.assertEqual(milestone, field.getMilestone(milestone.name))
+
+ def test_validate_valid_distro_milestone(self):
+ distro = self.factory.makeDistribution()
+ milestone = self.factory.makeMilestone(distribution=distro)
+ specification = self.factory.makeSpecification(
+ distribution=milestone.distribution)
+ field = self.field.bind(specification)
+ self.assertEqual(milestone, field.getMilestone(milestone.name))
+
+ def test_validate_invalid_milestone(self):
+ milestone_name = 'test-milestone'
+ self.factory.makeMilestone(name=milestone_name)
+ # Milestone exists but is not a target for this spec.
+ specification = self.factory.makeSpecification(product=None)
+ field = self.field.bind(specification)
+ self.assertRaises(
+ LaunchpadValidationError, field.getMilestone, milestone_name)
+
+ def test_validate_invalid_status(self):
+ self.assertRaises(
+ LaunchpadValidationError, self.field.getStatus,
+ 'Invalid status: FOO')
+
+ def test_validate_valid_statuses(self):
+ statuses = [SpecificationWorkItemStatus.TODO,
+ SpecificationWorkItemStatus.DONE,
+ SpecificationWorkItemStatus.POSTPONED,
+ SpecificationWorkItemStatus.INPROGRESS,
+ SpecificationWorkItemStatus.BLOCKED]
+ for status in statuses:
+ validated_status = self.field.getStatus(status.name)
+ self.assertEqual(validated_status, status)
+
+
+class TestWorkItemsText(TestCase):
+
+ def setUp(self):
+ super(TestWorkItemsText, self).setUp()
+ self.field = WorkItemsText(__name__='test')
+
+ def test_validate_raises_LaunchpadValidationError(self):
+ self.assertRaises(
+ LaunchpadValidationError, self.field.validate,
+ 'This is not a valid work item.')
+
+ def test_single_line_parsing(self):
+ work_items_title = 'Test this work item'
+ parsed = self.field.parseLine('%s: TODO' % (work_items_title))
+ self.assertEqual(parsed['title'], work_items_title)
+ self.assertEqual(parsed['status'], 'TODO')
+
+ def test_url_and_colon_in_title(self):
+ work_items_title = 'Test this: which is a url: http://www.linaro.org/'
+ parsed = self.field.parseLine('%s: TODO' % (work_items_title))
+ self.assertEqual(parsed['title'], work_items_title)
+
+ def test_silly_caps_status_parsing(self):
+ parsed_upper = self.field.parseLine('Test this work item: TODO ')
+ self.assertEqual(parsed_upper['status'], 'TODO')
+ parsed_lower = self.field.parseLine('Test this work item: todo')
+ self.assertEqual(parsed_lower['status'], 'TODO')
+ parsed_camel = self.field.parseLine('Test this work item: ToDo')
+ self.assertEqual(parsed_camel['status'], 'TODO')
+
+ def test_parseLine_without_status_fails(self):
+ # We should require an explicit status to avoid the problem of work
+ # items with a url but no status.
+ self.assertRaises(
+ LaunchpadValidationError, self.field.parseLine,
+ 'Missing status')
+
+ def test_parseLine_without_title_fails(self):
+ self.assertRaises(
+ LaunchpadValidationError, self.field.parseLine,
+ ':TODO')
+
+ def test_parseLine_without_title_with_assignee_fails(self):
+ self.assertRaises(
+ LaunchpadValidationError, self.field.parseLine,
+ '[test-person] :TODO')
+
+ def test_multi_line_parsing(self):
+ title_1 = 'Work item 1'
+ title_2 = 'Work item 2'
+ work_items_text = "%s: TODO\n%s: POSTPONED" % (title_1, title_2)
+ parsed = self.field.parse(work_items_text)
+ self.assertEqual(
+ parsed, [{'title': title_1,
+ 'status': 'TODO',
+ 'assignee': None, 'milestone': None, 'sequence': 0},
+ {'title': title_2,
+ 'status': 'POSTPONED',
+ 'assignee': None, 'milestone': None, 'sequence': 1}])
+
+ def test_parse_assignee(self):
+ title = 'Work item 1'
+ assignee = 'test-person'
+ work_items_text = "[%s]%s: TODO" % (assignee, title)
+ parsed = self.field.parseLine(work_items_text)
+ self.assertEqual(parsed['assignee'], assignee)
+
+ def test_parse_assignee_with_space(self):
+ title = 'Work item 1'
+ assignee = 'test-person'
+ work_items_text = "[%s] %s: TODO" % (assignee, title)
+ parsed = self.field.parseLine(work_items_text)
+ self.assertEqual(parsed['assignee'], assignee)
+
+ def test_parseLine_with_missing_closing_bracket_for_assignee(self):
+ self.assertRaises(
+ LaunchpadValidationError, self.field.parseLine,
+ "[test-person A single work item: TODO")
+
+ def test_parse_empty_lines_have_no_meaning(self):
+ parsed = self.field.parse("\n\n\n\n\n\n\n\n")
+ self.assertEqual(parsed, [])
+
+ def test_parse_status(self):
+ work_items_text = "A single work item: TODO"
+ parsed = self.field.parse(work_items_text)
+ self.assertEqual(parsed[0]['status'], 'TODO')
+
+ def test_parse_milestone(self):
+ milestone = '2012.02'
+ title = "Work item for a milestone"
+ work_items_text = "Work items for %s:\n%s: TODO" % (milestone, title)
+ parsed = self.field.parse(work_items_text)
+ self.assertEqual(parsed, [{'title': title,
+ 'status': 'TODO',
+ 'assignee': None, 'milestone': milestone, 'sequence': 0}])
+
+ def test_parse_multi_milestones(self):
+ milestone_1 = '2012.02'
+ milestone_2 = '2012.03'
+ title_1 = "Work item for a milestone"
+ title_2 = "Work item for a later milestone"
+ work_items_text = ("Work items for %s:\n%s: POSTPONED\n\nWork items "
+ "for %s:\n%s: TODO" % (milestone_1, title_1,
+ milestone_2, title_2))
+ parsed = self.field.parse(work_items_text)
+ self.assertEqual(parsed,
+ [{'title': title_1,
+ 'status': 'POSTPONED',
+ 'assignee': None, 'milestone': milestone_1,
+ 'sequence': 0},
+ {'title': title_2,
+ 'status': 'TODO',
+ 'assignee': None, 'milestone': milestone_2,
+ 'sequence': 1}])
+
+ def test_parse_orphaned_work_items(self):
+ # Work items not in a milestone block belong to the latest specified
+ # milestone.
+ milestone_1 = '2012.02'
+ milestone_2 = '2012.03'
+ title_1 = "Work item for a milestone"
+ title_2 = "Work item for a later milestone"
+ title_3 = "A work item preceeded by a blank line"
+ work_items_text = (
+ "Work items for %s:\n%s: POSTPONED\n\nWork items for %s:\n%s: "
+ "TODO\n\n%s: TODO" % (milestone_1, title_1, milestone_2, title_2,
+ title_3))
+ parsed = self.field.parse(work_items_text)
+ self.assertEqual(parsed,
+ [{'title': title_1,
+ 'status': 'POSTPONED',
+ 'assignee': None, 'milestone': milestone_1,
+ 'sequence': 0},
+ {'title': title_2,
+ 'status': 'TODO',
+ 'assignee': None, 'milestone': milestone_2,
+ 'sequence': 1},
+ {'title': title_3,
+ 'status': 'TODO',
+ 'assignee': None, 'milestone': milestone_2,
+ 'sequence': 2}])
+
+ def test_sequence_single_workitem(self):
+ parsed = self.field.parse("A single work item: TODO")
+ self.assertEqual(0, parsed[0]['sequence'])
+
+ def test_only_workitems_get_sequence(self):
+ parsed = self.field.parse("A single work item: TODO\n"
+ "A second work item: TODO\n"
+ "\n"
+ "Work items for 2012.02:\n"
+ "Work item for a milestone: TODO\n")
+ self.assertEqual([(wi['title'], wi['sequence']) for wi in parsed],
+ [("A single work item", 0), ("A second work item", 1),
+ ("Work item for a milestone", 2)])
+
+
+
class TestBlacklistableContentNameField(TestCaseWithFactory):
layer = DatabaseFunctionalLayer