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

Proposed by Guilherme Salgado
Status: Merged
Approved by: Abel Deuring
Approved revision: no longer in the source branch.
Merged at revision: 14868
Proposed branch: lp:~linaro-infrastructure/launchpad/workitems-parsing-and-updating
Merge into: lp:launchpad
Diff against target: 324 lines (+235/-3)
4 files modified
lib/lp/blueprints/interfaces/specification.py (+22/-0)
lib/lp/blueprints/model/specification.py (+63/-0)
lib/lp/blueprints/model/tests/test_specification.py (+149/-2)
lib/lp/registry/model/milestone.py (+1/-1)
To merge this branch: bzr merge lp:~linaro-infrastructure/launchpad/workitems-parsing-and-updating
Reviewer Review Type Date Requested Status
Abel Deuring (community) code Approve
Review via email: mp+94552@code.launchpad.net

Commit message

[r=adeuring][bug=940446] new method Specification.updateWorkItems()

Description of the change

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()

To post a comment you must log in.
Revision history for this message
Abel Deuring (adeuring) wrote :

Hi Salgado,

the changes look good overall, I just have a few formal nitpicks, see below.

> + 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,

s/tuple/dictionary/

> + 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 _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.
> + """

Since the work item will not literally be deleted, I think "Mark all
work items as deleted" is a bit more precise than "Delete all work items"

> + 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)):

Our style guide does not allow single-letter variable names. WHat about
"for position in..." or "for index in..."?

review: Approve (code)
Revision history for this message
Guilherme Salgado (salgado) wrote :

On 24/02/12 12:43, Abel Deuring wrote:
> Review: Approve code
>
> Hi Salgado,
>
> the changes look good overall, I just have a few formal nitpicks, see below.

Thanks, Abel!

>
>> + 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,
>
> s/tuple/dictionary/

Oops, fixed.

>
>> + 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 _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.
>> + """
>
> Since the work item will not literally be deleted, I think "Mark all
> work items as deleted" is a bit more precise than "Delete all work items"

Good point, changed.

>
>
>> + 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)):
>
> Our style guide does not allow single-letter variable names. WHat about
> "for position in..." or "for index in..."?

I don't think this should apply to a counter variable used in a for
loop, but I'm happy to rename it to index. :)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/blueprints/interfaces/specification.py'
2--- lib/lp/blueprints/interfaces/specification.py 2012-02-09 01:19:00 +0000
3+++ lib/lp/blueprints/interfaces/specification.py 2012-02-24 14:50:29 +0000
4@@ -41,6 +41,7 @@
5 Choice,
6 Datetime,
7 Int,
8+ List,
9 Text,
10 TextLine,
11 )
12@@ -63,6 +64,9 @@
13 IHasSpecifications,
14 ISpecificationTarget,
15 )
16+from lp.blueprints.interfaces.specificationworkitem import (
17+ ISpecificationWorkItem
18+ )
19 from lp.blueprints.interfaces.sprint import ISprint
20 from lp.bugs.interfaces.buglink import IBugLinkTarget
21 from lp.code.interfaces.branchlink import IHasLinkedBranches
22@@ -289,6 +293,10 @@
23 date_goal_decided = Attribute("The date the spec was approved "
24 "or declined as a goal.")
25
26+ work_items = List(
27+ description=_("All non-deleted work items for this spec, sorted by "
28+ "their 'sequence'"),
29+ value_type=Reference(schema=ISpecificationWorkItem), readonly=True)
30 whiteboard = exported(
31 Text(title=_('Status Whiteboard'), required=False,
32 description=_(
33@@ -575,6 +583,20 @@
34 milestone=None):
35 """Create a new SpecificationWorkItem."""
36
37+ def updateWorkItems(new_work_items):
38+ """Update the existing work items to match the given ones.
39+
40+ First, for every existing work item that is not present on the new
41+ list, mark it as deleted. Then, for every tuple in the given list,
42+ lookup an existing work item with the same title and update its
43+ status, assignee, milestone and sequence (position on the work-items
44+ list). If there's no existing work items with that title, we create a
45+ new one.
46+
47+ :param new_work_items: A list of dictionaries containing the following
48+ keys: title, status, assignee and milestone.
49+ """
50+
51 def setTarget(target):
52 """Set this specification's target.
53
54
55=== modified file 'lib/lp/blueprints/model/specification.py'
56--- lib/lp/blueprints/model/specification.py 2012-02-07 01:43:29 +0000
57+++ lib/lp/blueprints/model/specification.py 2012-02-24 14:50:29 +0000
58@@ -234,10 +234,73 @@
59 status=SpecificationWorkItemStatus.TODO, assignee=None,
60 milestone=None):
61 """See ISpecification."""
62+ if milestone is not None:
63+ assert milestone.target == self.target, (
64+ "%s does not belong to this spec's target (%s)" %
65+ (milestone.displayname, self.target.name))
66 return SpecificationWorkItem(
67 title=title, status=status, specification=self, assignee=assignee,
68 milestone=milestone, sequence=sequence)
69
70+ @property
71+ def work_items(self):
72+ """See ISpecification."""
73+ return Store.of(self).find(
74+ SpecificationWorkItem, specification=self,
75+ deleted=False).order_by("sequence")
76+
77+ def _deleteWorkItemsNotMatching(self, titles):
78+ """Delete all work items whose title does not match the given ones.
79+
80+ Also set the sequence of those deleted work items to -1.
81+ """
82+ for work_item in self.work_items:
83+ if work_item.title not in titles:
84+ work_item.deleted = True
85+
86+ def updateWorkItems(self, new_work_items):
87+ """See ISpecification."""
88+ # First mark work items with titles that are no longer present as
89+ # deleted.
90+ self._deleteWorkItemsNotMatching(
91+ [wi['title'] for wi in new_work_items])
92+ work_items = Store.of(self).find(
93+ SpecificationWorkItem, specification=self, deleted=False)
94+ work_items = list(work_items.order_by("sequence"))
95+ # At this point the list of new_work_items is necessarily the same
96+ # size (or longer) than the list of existing ones, so we can just
97+ # iterate over it updating the existing items and creating any new
98+ # ones.
99+ to_insert = []
100+ existing_titles = [wi.title for wi in work_items]
101+ for i in range(len(new_work_items)):
102+ new_wi = new_work_items[i]
103+ if new_wi['title'] not in existing_titles:
104+ # This is a new work item, so we insert it with 'i' as its
105+ # sequence because that's the position it is on the list
106+ # entered by the user.
107+ to_insert.append((i, new_wi))
108+ else:
109+ # Get the existing work item with the same title and update
110+ # it to match what we have now.
111+ existing_wi = work_items[
112+ existing_titles.index(new_wi['title'])]
113+ # Update the sequence to match its current position on the
114+ # list entered by the user.
115+ existing_wi.sequence = i
116+ existing_wi.status = new_wi['status']
117+ existing_wi.assignee = new_wi['assignee']
118+ milestone = new_wi['milestone']
119+ if milestone is not None:
120+ assert milestone.target == self.target, (
121+ "%s does not belong to this spec's target (%s)" %
122+ (milestone.displayname, self.target.name))
123+ existing_wi.milestone = milestone
124+
125+ for sequence, item in to_insert:
126+ self.newWorkItem(item['title'], sequence, item['status'],
127+ item['assignee'], item['milestone'])
128+
129 def setTarget(self, target):
130 """See ISpecification."""
131 if IProduct.providedBy(target):
132
133=== modified file 'lib/lp/blueprints/model/tests/test_specification.py'
134--- lib/lp/blueprints/model/tests/test_specification.py 2012-02-10 17:32:41 +0000
135+++ lib/lp/blueprints/model/tests/test_specification.py 2012-02-24 14:50:29 +0000
136@@ -5,7 +5,10 @@
137
138 __metaclass__ = type
139
140-from testtools.matchers import Equals
141+from testtools.matchers import (
142+ Equals,
143+ MatchesStructure,
144+ )
145
146 from lp.app.validators import LaunchpadValidationError
147 from lp.blueprints.interfaces.specification import ISpecification
148@@ -145,6 +148,7 @@
149
150
151 class TestSpecificationWorkItems(TestCaseWithFactory):
152+ """Test the Workitem-related methods of ISpecification."""
153
154 layer = DatabaseFunctionalLayer
155
156@@ -163,7 +167,7 @@
157 title = u'new-work-item'
158 spec = self.factory.makeSpecification()
159 assignee = self.factory.makePerson()
160- milestone = self.factory.makeMilestone()
161+ milestone = self.factory.makeMilestone(product=spec.product)
162 status = SpecificationWorkItemStatus.DONE
163 login_person(spec.owner)
164 work_item = spec.newWorkItem(
165@@ -174,3 +178,146 @@
166 self.assertEqual(status, work_item.status)
167 self.assertEqual(title, work_item.title)
168 self.assertEqual(milestone, work_item.milestone)
169+
170+ def test_work_items_property(self):
171+ spec = self.factory.makeSpecification()
172+ wi1 = self.factory.makeSpecificationWorkItem(
173+ specification=spec, sequence=2)
174+ wi2 = self.factory.makeSpecificationWorkItem(
175+ specification=spec, sequence=1)
176+ # This work item won't be included in the results of spec.work_items
177+ # because it is deleted.
178+ self.factory.makeSpecificationWorkItem(
179+ specification=spec, sequence=3, deleted=True)
180+ # This work item belongs to a different spec so it won't be returned
181+ # by spec.work_items.
182+ self.factory.makeSpecificationWorkItem()
183+ self.assertEqual([wi2, wi1], list(spec.work_items))
184+
185+ def test_updateWorkItems_no_existing_items(self):
186+ """When there are no existing work items, updateWorkItems will create
187+ a new entry for every element in the list given to it.
188+ """
189+ spec = self.factory.makeSpecification(
190+ product=self.factory.makeProduct())
191+ milestone = self.factory.makeMilestone(product=spec.product)
192+ work_item1_data = dict(
193+ title=u'Foo Bar', status=SpecificationWorkItemStatus.DONE,
194+ assignee=spec.owner, milestone=None)
195+ work_item2_data = dict(
196+ title=u'Bar Foo', status=SpecificationWorkItemStatus.TODO,
197+ assignee=None, milestone=milestone)
198+
199+ # We start with no work items.
200+ self.assertEquals([], list(spec.work_items))
201+
202+ login_person(spec.owner)
203+ spec.updateWorkItems([work_item1_data, work_item2_data])
204+
205+ # And after calling updateWorkItems() we have 2 work items.
206+ self.assertEqual(2, spec.work_items.count())
207+
208+ # The data dicts we pass to updateWorkItems() have no sequence because
209+ # that's taken from their position on the list, so we update our data
210+ # dicts with the sequence we expect our work items to have.
211+ work_item1_data['sequence'] = 0
212+ work_item2_data['sequence'] = 1
213+
214+ # Assert that the work items ultimately inserted in the DB are exactly
215+ # what we expect them to be.
216+ created_wi1, created_wi2 = list(spec.work_items)
217+ self.assertThat(
218+ created_wi1, MatchesStructure.byEquality(**work_item1_data))
219+ self.assertThat(
220+ created_wi2, MatchesStructure.byEquality(**work_item2_data))
221+
222+ def test_updateWorkItems_merges_with_existing_ones(self):
223+ spec = self.factory.makeSpecification(
224+ product=self.factory.makeProduct())
225+ login_person(spec.owner)
226+ # Create two work-items in our database.
227+ wi1_data = self._createWorkItemAndReturnDataDict(spec)
228+ wi2_data = self._createWorkItemAndReturnDataDict(spec)
229+ self.assertEqual(2, spec.work_items.count())
230+
231+ # These are the work items we'll be inserting.
232+ new_wi1_data = dict(
233+ title=u'Some Title', status=SpecificationWorkItemStatus.TODO,
234+ assignee=None, milestone=None)
235+ new_wi2_data = dict(
236+ title=u'Other title', status=SpecificationWorkItemStatus.TODO,
237+ assignee=None, milestone=None)
238+
239+ # We want to insert the two work items above in the first and third
240+ # positions respectively, so the existing ones to be moved around
241+ # (e.g. have their sequence updated).
242+ work_items = [new_wi1_data, wi1_data, new_wi2_data, wi2_data]
243+ spec.updateWorkItems(work_items)
244+
245+ # Update our data dicts with the sequences we expect the work items in
246+ # our DB to have.
247+ new_wi1_data['sequence'] = 0
248+ wi1_data['sequence'] = 1
249+ new_wi2_data['sequence'] = 2
250+ wi2_data['sequence'] = 3
251+
252+ self.assertEqual(4, spec.work_items.count())
253+ for data, obj in zip(work_items, list(spec.work_items)):
254+ self.assertThat(obj, MatchesStructure.byEquality(**data))
255+
256+ def test_updateWorkItems_updates_existing_ones(self):
257+ spec = self.factory.makeSpecification()
258+ login_person(spec.owner)
259+ # Create a work-item in our database.
260+ wi_data = self._createWorkItemAndReturnDataDict(spec)
261+ self.assertEqual(1, spec.work_items.count())
262+
263+ # This time we're only changing the existing work item; we'll change
264+ # its assignee and status.
265+ wi_data.update(dict(status=SpecificationWorkItemStatus.DONE,
266+ assignee=spec.owner))
267+ spec.updateWorkItems([wi_data])
268+
269+ self.assertEqual(1, spec.work_items.count())
270+ self.assertThat(
271+ spec.work_items[0], MatchesStructure.byEquality(**wi_data))
272+
273+ def test_updateWorkItems_deletes_all_if_given_empty_list(self):
274+ work_item = self.factory.makeSpecificationWorkItem()
275+ spec = work_item.specification
276+ self.assertEqual(1, spec.work_items.count())
277+ spec.updateWorkItems([])
278+ self.assertEqual(0, spec.work_items.count())
279+
280+ def test_updateWorkItems_marks_removed_ones_as_deleted(self):
281+ spec = self.factory.makeSpecification()
282+ self._createWorkItemAndReturnDataDict(spec)
283+ wi2_data = self._createWorkItemAndReturnDataDict(spec)
284+ self.assertEqual(2, spec.work_items.count())
285+ login_person(spec.owner)
286+
287+ # We have two work items in the DB but now we want to update them to
288+ # keep just the second one. The first will be deleted and the sequence
289+ # of the second will be changed.
290+ spec.updateWorkItems([wi2_data])
291+ self.assertEqual(1, spec.work_items.count())
292+ wi2_data['sequence'] = 0
293+ self.assertThat(
294+ spec.work_items[0], MatchesStructure.byEquality(**wi2_data))
295+
296+ def _createWorkItemAndReturnDataDict(self, spec):
297+ """Create a new work item for the given spec using the next available
298+ sequence number.
299+
300+ Return a dict with the title, status, assignee, milestone and sequence
301+ attributes of the spec.
302+ """
303+ if spec.work_items.count() == 0:
304+ sequence = 0
305+ else:
306+ sequence = max(wi.sequence for wi in spec.work_items) + 1
307+ wi = self.factory.makeSpecificationWorkItem(
308+ specification=spec, sequence=sequence)
309+ return dict(
310+ title=wi.title, status=wi.status, assignee=wi.assignee,
311+ milestone=wi.milestone, sequence=sequence)
312
313=== modified file 'lib/lp/registry/model/milestone.py'
314--- lib/lp/registry/model/milestone.py 2012-01-16 00:07:45 +0000
315+++ lib/lp/registry/model/milestone.py 2012-02-24 14:50:29 +0000
316@@ -366,7 +366,7 @@
317 """A virtual milestone implementation for project.
318
319 The current database schema has no formal concept of milestones related to
320- projects. A milestone named `milestone` is considererd to belong to
321+ projects. A milestone named `milestone` is considered to belong to
322 a project if the project contains at least one product with a milestone
323 of the same name. A project milestone is considered to be active if at
324 least one product milestone with the same name is active. The