← Back to team overview

launchpad-reviewers team mailing list archive

lp:~linaro-infrastructure/launchpad/workitems-parsing-and-updating into lp:launchpad

 

Guilherme Salgado has proposed merging lp:~linaro-infrastructure/launchpad/workitems-parsing-and-updating into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~linaro-infrastructure/launchpad/workitems-parsing-and-updating/+merge/94552

This branch adds a new method on ISpecification which will take a list of dictionaries describing work items and update/delete/create work items in the DB to match that.  This is going to be used in the new UI for editing work items (lp:~linaro-infrastructure/launchpad/workitems-widget), which will consist of a text area widget identical to the one currently used for the whiteboard.

In lp:~linaro-infrastructure/launchpad/workitems-widget we're implementing a new field class which will parse the work items entered by the user into the list of dicts expected by updateWorkItems()
-- 
https://code.launchpad.net/~linaro-infrastructure/launchpad/workitems-parsing-and-updating/+merge/94552
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~linaro-infrastructure/launchpad/workitems-parsing-and-updating into lp:launchpad.
=== modified file 'lib/lp/blueprints/interfaces/specification.py'
--- lib/lp/blueprints/interfaces/specification.py	2012-02-09 01:19:00 +0000
+++ lib/lp/blueprints/interfaces/specification.py	2012-02-24 14:50:29 +0000
@@ -41,6 +41,7 @@
     Choice,
     Datetime,
     Int,
+    List,
     Text,
     TextLine,
     )
@@ -63,6 +64,9 @@
     IHasSpecifications,
     ISpecificationTarget,
     )
+from lp.blueprints.interfaces.specificationworkitem import (
+    ISpecificationWorkItem
+    )
 from lp.blueprints.interfaces.sprint import ISprint
 from lp.bugs.interfaces.buglink import IBugLinkTarget
 from lp.code.interfaces.branchlink import IHasLinkedBranches
@@ -289,6 +293,10 @@
     date_goal_decided = Attribute("The date the spec was approved "
         "or declined as a goal.")
 
+    work_items = List(
+        description=_("All non-deleted work items for this spec, sorted by "
+                      "their 'sequence'"),
+        value_type=Reference(schema=ISpecificationWorkItem), readonly=True)
     whiteboard = exported(
         Text(title=_('Status Whiteboard'), required=False,
              description=_(
@@ -575,6 +583,20 @@
                     milestone=None):
         """Create a new SpecificationWorkItem."""
 
+    def updateWorkItems(new_work_items):
+        """Update the existing work items to match the given ones.
+
+        First, for every existing work item that is not present on the new
+        list, mark it as deleted. Then, for every tuple in the given list,
+        lookup an existing work item with the same title and update its
+        status, assignee, milestone and sequence (position on the work-items
+        list). If there's no existing work items with that title, we create a
+        new one.
+
+        :param new_work_items: A list of dictionaries containing the following
+            keys: title, status, assignee and milestone.
+        """
+
     def setTarget(target):
         """Set this specification's target.
 

=== modified file 'lib/lp/blueprints/model/specification.py'
--- lib/lp/blueprints/model/specification.py	2012-02-07 01:43:29 +0000
+++ lib/lp/blueprints/model/specification.py	2012-02-24 14:50:29 +0000
@@ -234,10 +234,73 @@
                     status=SpecificationWorkItemStatus.TODO, assignee=None,
                     milestone=None):
         """See ISpecification."""
+        if milestone is not None:
+            assert milestone.target == self.target, (
+                "%s does not belong to this spec's target (%s)" %
+                    (milestone.displayname, self.target.name))
         return SpecificationWorkItem(
             title=title, status=status, specification=self, assignee=assignee,
             milestone=milestone, sequence=sequence)
 
+    @property
+    def work_items(self):
+        """See ISpecification."""
+        return Store.of(self).find(
+            SpecificationWorkItem, specification=self,
+            deleted=False).order_by("sequence")
+
+    def _deleteWorkItemsNotMatching(self, titles):
+        """Delete all work items whose title does not match the given ones.
+
+        Also set the sequence of those deleted work items to -1.
+        """
+        for work_item in self.work_items:
+            if work_item.title not in titles:
+                work_item.deleted = True
+
+    def updateWorkItems(self, new_work_items):
+        """See ISpecification."""
+        # First mark work items with titles that are no longer present as
+        # deleted.
+        self._deleteWorkItemsNotMatching(
+            [wi['title'] for wi in new_work_items])
+        work_items = Store.of(self).find(
+            SpecificationWorkItem, specification=self, deleted=False)
+        work_items = list(work_items.order_by("sequence"))
+        # At this point the list of new_work_items is necessarily the same
+        # size (or longer) than the list of existing ones, so we can just
+        # iterate over it updating the existing items and creating any new
+        # ones.
+        to_insert = []
+        existing_titles = [wi.title for wi in work_items]
+        for i in range(len(new_work_items)):
+            new_wi = new_work_items[i]
+            if new_wi['title'] not in existing_titles:
+                # This is a new work item, so we insert it with 'i' as its
+                # sequence because that's the position it is on the list
+                # entered by the user.
+                to_insert.append((i, new_wi))
+            else:
+                # Get the existing work item with the same title and update
+                # it to match what we have now.
+                existing_wi = work_items[
+                    existing_titles.index(new_wi['title'])]
+                # Update the sequence to match its current position on the
+                # list entered by the user.
+                existing_wi.sequence = i
+                existing_wi.status = new_wi['status']
+                existing_wi.assignee = new_wi['assignee']
+                milestone = new_wi['milestone']
+                if milestone is not None:
+                    assert milestone.target == self.target, (
+                        "%s does not belong to this spec's target (%s)" %
+                            (milestone.displayname, self.target.name))
+                existing_wi.milestone = milestone
+
+        for sequence, item in to_insert:
+            self.newWorkItem(item['title'], sequence, item['status'],
+                             item['assignee'], item['milestone'])
+
     def setTarget(self, target):
         """See ISpecification."""
         if IProduct.providedBy(target):

=== modified file 'lib/lp/blueprints/model/tests/test_specification.py'
--- lib/lp/blueprints/model/tests/test_specification.py	2012-02-10 17:32:41 +0000
+++ lib/lp/blueprints/model/tests/test_specification.py	2012-02-24 14:50:29 +0000
@@ -5,7 +5,10 @@
 
 __metaclass__ = type
 
-from testtools.matchers import Equals
+from testtools.matchers import (
+    Equals,
+    MatchesStructure,
+    )
 
 from lp.app.validators import LaunchpadValidationError
 from lp.blueprints.interfaces.specification import ISpecification
@@ -145,6 +148,7 @@
 
 
 class TestSpecificationWorkItems(TestCaseWithFactory):
+    """Test the Workitem-related methods of ISpecification."""
 
     layer = DatabaseFunctionalLayer
 
@@ -163,7 +167,7 @@
         title = u'new-work-item'
         spec = self.factory.makeSpecification()
         assignee = self.factory.makePerson()
-        milestone = self.factory.makeMilestone()
+        milestone = self.factory.makeMilestone(product=spec.product)
         status = SpecificationWorkItemStatus.DONE
         login_person(spec.owner)
         work_item = spec.newWorkItem(
@@ -174,3 +178,146 @@
         self.assertEqual(status, work_item.status)
         self.assertEqual(title, work_item.title)
         self.assertEqual(milestone, work_item.milestone)
+
+    def test_work_items_property(self):
+        spec = self.factory.makeSpecification()
+        wi1 = self.factory.makeSpecificationWorkItem(
+            specification=spec, sequence=2)
+        wi2 = self.factory.makeSpecificationWorkItem(
+            specification=spec, sequence=1)
+        # This work item won't be included in the results of spec.work_items
+        # because it is deleted.
+        self.factory.makeSpecificationWorkItem(
+            specification=spec, sequence=3, deleted=True)
+        # This work item belongs to a different spec so it won't be returned
+        # by spec.work_items.
+        self.factory.makeSpecificationWorkItem()
+        self.assertEqual([wi2, wi1], list(spec.work_items))
+
+    def test_updateWorkItems_no_existing_items(self):
+        """When there are no existing work items, updateWorkItems will create
+        a new entry for every element in the list given to it.
+        """
+        spec = self.factory.makeSpecification(
+            product=self.factory.makeProduct())
+        milestone = self.factory.makeMilestone(product=spec.product)
+        work_item1_data = dict(
+            title=u'Foo Bar', status=SpecificationWorkItemStatus.DONE,
+            assignee=spec.owner, milestone=None)
+        work_item2_data = dict(
+            title=u'Bar Foo', status=SpecificationWorkItemStatus.TODO,
+            assignee=None, milestone=milestone)
+
+        # We start with no work items.
+        self.assertEquals([], list(spec.work_items))
+
+        login_person(spec.owner)
+        spec.updateWorkItems([work_item1_data, work_item2_data])
+
+        # And after calling updateWorkItems() we have 2 work items.
+        self.assertEqual(2, spec.work_items.count())
+
+        # The data dicts we pass to updateWorkItems() have no sequence because
+        # that's taken from their position on the list, so we update our data
+        # dicts with the sequence we expect our work items to have.
+        work_item1_data['sequence'] = 0
+        work_item2_data['sequence'] = 1
+
+        # Assert that the work items ultimately inserted in the DB are exactly
+        # what we expect them to be.
+        created_wi1, created_wi2 = list(spec.work_items)
+        self.assertThat(
+            created_wi1, MatchesStructure.byEquality(**work_item1_data))
+        self.assertThat(
+            created_wi2, MatchesStructure.byEquality(**work_item2_data))
+
+    def test_updateWorkItems_merges_with_existing_ones(self):
+        spec = self.factory.makeSpecification(
+            product=self.factory.makeProduct())
+        login_person(spec.owner)
+        # Create two work-items in our database.
+        wi1_data = self._createWorkItemAndReturnDataDict(spec)
+        wi2_data = self._createWorkItemAndReturnDataDict(spec)
+        self.assertEqual(2, spec.work_items.count())
+
+        # These are the work items we'll be inserting.
+        new_wi1_data = dict(
+            title=u'Some Title', status=SpecificationWorkItemStatus.TODO,
+            assignee=None, milestone=None)
+        new_wi2_data = dict(
+            title=u'Other title', status=SpecificationWorkItemStatus.TODO,
+            assignee=None, milestone=None)
+
+        # We want to insert the two work items above in the first and third
+        # positions respectively, so the existing ones to be moved around
+        # (e.g. have their sequence updated).
+        work_items = [new_wi1_data, wi1_data, new_wi2_data, wi2_data]
+        spec.updateWorkItems(work_items)
+
+        # Update our data dicts with the sequences we expect the work items in
+        # our DB to have.
+        new_wi1_data['sequence'] = 0
+        wi1_data['sequence'] = 1
+        new_wi2_data['sequence'] = 2
+        wi2_data['sequence'] = 3
+
+        self.assertEqual(4, spec.work_items.count())
+        for data, obj in zip(work_items, list(spec.work_items)):
+            self.assertThat(obj, MatchesStructure.byEquality(**data))
+
+    def test_updateWorkItems_updates_existing_ones(self):
+        spec = self.factory.makeSpecification()
+        login_person(spec.owner)
+        # Create a work-item in our database.
+        wi_data = self._createWorkItemAndReturnDataDict(spec)
+        self.assertEqual(1, spec.work_items.count())
+
+        # This time we're only changing the existing work item; we'll change
+        # its assignee and status.
+        wi_data.update(dict(status=SpecificationWorkItemStatus.DONE,
+                            assignee=spec.owner))
+        spec.updateWorkItems([wi_data])
+
+        self.assertEqual(1, spec.work_items.count())
+        self.assertThat(
+            spec.work_items[0], MatchesStructure.byEquality(**wi_data))
+
+    def test_updateWorkItems_deletes_all_if_given_empty_list(self):
+        work_item = self.factory.makeSpecificationWorkItem()
+        spec = work_item.specification
+        self.assertEqual(1, spec.work_items.count())
+        spec.updateWorkItems([])
+        self.assertEqual(0, spec.work_items.count())
+
+    def test_updateWorkItems_marks_removed_ones_as_deleted(self):
+        spec = self.factory.makeSpecification()
+        self._createWorkItemAndReturnDataDict(spec)
+        wi2_data = self._createWorkItemAndReturnDataDict(spec)
+        self.assertEqual(2, spec.work_items.count())
+        login_person(spec.owner)
+
+        # We have two work items in the DB but now we want to update them to
+        # keep just the second one. The first will be deleted and the sequence
+        # of the second will be changed.
+        spec.updateWorkItems([wi2_data])
+        self.assertEqual(1, spec.work_items.count())
+        wi2_data['sequence'] = 0
+        self.assertThat(
+            spec.work_items[0], MatchesStructure.byEquality(**wi2_data))
+
+    def _createWorkItemAndReturnDataDict(self, spec):
+        """Create a new work item for the given spec using the next available
+        sequence number.
+
+        Return a dict with the title, status, assignee, milestone and sequence
+        attributes of the spec.
+        """
+        if spec.work_items.count() == 0:
+            sequence = 0
+        else:
+            sequence = max(wi.sequence for wi in spec.work_items) + 1
+        wi = self.factory.makeSpecificationWorkItem(
+            specification=spec, sequence=sequence)
+        return dict(
+            title=wi.title, status=wi.status, assignee=wi.assignee,
+            milestone=wi.milestone, sequence=sequence)

=== modified file 'lib/lp/registry/model/milestone.py'
--- lib/lp/registry/model/milestone.py	2012-01-16 00:07:45 +0000
+++ lib/lp/registry/model/milestone.py	2012-02-24 14:50:29 +0000
@@ -366,7 +366,7 @@
     """A virtual milestone implementation for project.
 
     The current database schema has no formal concept of milestones related to
-    projects. A milestone named `milestone` is considererd to belong to
+    projects. A milestone named `milestone` is considered to belong to
     a project if the project contains at least one product with a milestone
     of the same name. A project milestone is considered to be active if at
     least one product milestone with the same name is active.  The