Merge lp:~linaro-infrastructure/launchpad/team-engineering-view into lp:launchpad

Proposed by Guilherme Salgado on 2012-03-26
Status: Merged
Approved by: Benji York on 2012-03-26
Approved revision: no longer in the source branch.
Merged at revision: 15029
Proposed branch: lp:~linaro-infrastructure/launchpad/team-engineering-view
Merge into: lp:launchpad
Diff against target: 704 lines (+522/-12)
9 files modified
lib/lp/blueprints/model/specificationworkitem.py (+2/-1)
lib/lp/bugs/interfaces/bugtask.py (+4/-1)
lib/lp/bugs/model/bug.py (+1/-1)
lib/lp/bugs/model/bugtasksearch.py (+12/-0)
lib/lp/bugs/tests/test_bugtask_search.py (+30/-0)
lib/lp/registry/interfaces/person.py (+14/-0)
lib/lp/registry/model/person.py (+106/-1)
lib/lp/registry/tests/test_person.py (+343/-1)
lib/lp/testing/factory.py (+10/-7)
To merge this branch: bzr merge lp:~linaro-infrastructure/launchpad/team-engineering-view
Reviewer Review Type Date Requested Status
Benji York (community) code 2012-03-26 Approve on 2012-03-27
Review via email: mp+99342@code.launchpad.net

Commit Message

[bug=965446][r=benji] New IPerson methods to get assigned SpecificationWorkItems/BugTasks targeted to a milestone whose due date is between today and a given date.

Description of the Change

Those new methods will be used by a new view showing the upcoming work assigned to members of a team (https://dev.launchpad.net/Projects/WorkItems).

Both queries do some LeftJoins to bring in all the related data in a single query and avoid hitting the DB again for every returned item. The new filters to BugTaskSet.search() was a suggestion from Robert and we agreed to do the conjoined master filtering in python because the existing one in BugTaskSet.search() works only when you're filtering results for a single milestone.

Also, the plan is to not do batching on those pages as the page only includes stuff assigned to a team member *and* targeted to a milestone in the next few months. However, the new page will be guarded by a feature flag and we'll be conducting user testing to make sure we can avoid batching there.

To post a comment you must log in.
Benji York (benji) wrote :

This branch looks good. The only think that struck me during the review
is that the fact that milestone_dateexpected_after is really "on or
after" and milestone_dateexpected_before is "on or before" is a little
confusing.

It may be that there is no better way to phrase that in an acceptable
variable name.

Maybe the thing to do is to add "(inclusive)" to the docstrings
describing the ranges for getAssignedSpecificationWorkItemsDueBefore and
getAssignedBugTasksDueBefore.

review: Approve (code)
Guilherme Salgado (salgado) wrote :

Thanks for the review, Benji!

On 26/03/12 12:41, Benji York wrote:
> Review: Approve code
>
> This branch looks good. The only think that struck me during the review
> is that the fact that milestone_dateexpected_after is really "on or
> after" and milestone_dateexpected_before is "on or before" is a little
> confusing.
>
> It may be that there is no better way to phrase that in an acceptable
> variable name.

Yeah, I'm not super happy with that either but couldn't think of a
reasonable name.

> Maybe the thing to do is to add "(inclusive)" to the docstrings
> describing the ranges for getAssignedSpecificationWorkItemsDueBefore and
> getAssignedBugTasksDueBefore.

I've done that change. Would you mind landing it for me?

Robert Collins (lifeless) wrote :

Just a data point: you're bring back the same people multiple times.
This is likely to be much slower than separate queries, once per type
of data being retrieved. (You will be updating your storm cache once
per person per workitem per milestone). This is *horrible* overhead.

Benji York (benji) wrote :

The recent revisions look great. The comments are especially nice.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/blueprints/model/specificationworkitem.py'
2--- lib/lp/blueprints/model/specificationworkitem.py 2012-02-28 04:24:19 +0000
3+++ lib/lp/blueprints/model/specificationworkitem.py 2012-03-27 19:49:28 +0000
4@@ -47,8 +47,9 @@
5
6 def __repr__(self):
7 title = self.title.encode('ASCII', 'backslashreplace')
8+ assignee = getattr(self.assignee, 'name', None)
9 return '<SpecificationWorkItem [%s] %s: %s of %s>' % (
10- self.assignee, title, self.status, self.specification)
11+ assignee, title, self.status.name, self.specification)
12
13 def __init__(self, title, status, specification, assignee, milestone,
14 sequence):
15
16=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
17--- lib/lp/bugs/interfaces/bugtask.py 2012-03-06 21:13:36 +0000
18+++ lib/lp/bugs/interfaces/bugtask.py 2012-03-27 19:49:28 +0000
19@@ -1185,7 +1185,8 @@
20 linked_branches=None, linked_blueprints=None,
21 structural_subscriber=None, modified_since=None,
22 created_since=None, exclude_conjoined_tasks=False, cve=None,
23- upstream_target=None):
24+ upstream_target=None, milestone_dateexpected_before=None,
25+ milestone_dateexpected_after=None):
26
27 self.bug = bug
28 self.searchtext = searchtext
29@@ -1236,6 +1237,8 @@
30 self.exclude_conjoined_tasks = exclude_conjoined_tasks
31 self.cve = cve
32 self.upstream_target = upstream_target
33+ self.milestone_dateexpected_before = milestone_dateexpected_before
34+ self.milestone_dateexpected_after = milestone_dateexpected_after
35
36 def setProduct(self, product):
37 """Set the upstream context on which to filter the search."""
38
39=== modified file 'lib/lp/bugs/model/bug.py'
40--- lib/lp/bugs/model/bug.py 2012-03-25 23:33:53 +0000
41+++ lib/lp/bugs/model/bug.py 2012-03-27 19:49:28 +0000
42@@ -2129,7 +2129,7 @@
43 """A set of known persons able to view this bug.
44
45 This method must return an empty set or bug searches will trigger late
46- evaluation. Any 'should be set on load' propertis must be done by the
47+ evaluation. Any 'should be set on load' properties must be done by the
48 bug search.
49
50 If you are tempted to change this method, don't. Instead see
51
52=== modified file 'lib/lp/bugs/model/bugtasksearch.py'
53--- lib/lp/bugs/model/bugtasksearch.py 2012-03-20 09:43:46 +0000
54+++ lib/lp/bugs/model/bugtasksearch.py 2012-03-27 19:49:28 +0000
55@@ -659,6 +659,18 @@
56 extra_clauses.append(nominated_for_clause)
57 clauseTables.append(BugNomination)
58
59+ dateexpected_before = params.milestone_dateexpected_before
60+ dateexpected_after = params.milestone_dateexpected_after
61+ if dateexpected_after or dateexpected_before:
62+ clauseTables.append(Milestone)
63+ extra_clauses.append("BugTask.milestone = Milestone.id")
64+ if dateexpected_after:
65+ extra_clauses.append("Milestone.dateexpected >= %s"
66+ % sqlvalues(dateexpected_after))
67+ if dateexpected_before:
68+ extra_clauses.append("Milestone.dateexpected <= %s"
69+ % sqlvalues(dateexpected_before))
70+
71 clause, decorator = _get_bug_privacy_filter_with_decorator(params.user)
72 if clause:
73 extra_clauses.append(clause)
74
75=== modified file 'lib/lp/bugs/tests/test_bugtask_search.py'
76--- lib/lp/bugs/tests/test_bugtask_search.py 2012-03-08 11:51:36 +0000
77+++ lib/lp/bugs/tests/test_bugtask_search.py 2012-03-27 19:49:28 +0000
78@@ -1572,6 +1572,36 @@
79 return [bugtask.bug.id for bugtask in expected_bugtasks]
80
81
82+class TestMilestoneDueDateFiltering(TestCaseWithFactory):
83+
84+ layer = LaunchpadFunctionalLayer
85+
86+ def test_milestone_date_filters(self):
87+ today = datetime.today().date()
88+ ten_days_ago = today - timedelta(days=10)
89+ ten_days_from_now = today + timedelta(days=10)
90+ current_milestone = self.factory.makeMilestone(dateexpected=today)
91+ old_milestone = self.factory.makeMilestone(
92+ dateexpected=ten_days_ago)
93+ future_milestone = self.factory.makeMilestone(
94+ dateexpected=ten_days_from_now)
95+ current_milestone_bug = self.factory.makeBug(
96+ milestone=current_milestone)
97+ old_milestone_bug = self.factory.makeBug(milestone=old_milestone)
98+ future_milestone_bug = self.factory.makeBug(
99+ milestone=future_milestone)
100+ # Search for bugs whose milestone.dateexpected is between yesterday
101+ # and tomorrow. This will return only the one task targeted to
102+ # current_milestone.
103+ params = BugTaskSearchParams(
104+ user=None,
105+ milestone_dateexpected_after=today - timedelta(days=1),
106+ milestone_dateexpected_before=today + timedelta(days=1))
107+ result = getUtility(IBugTaskSet).search(params)
108+ self.assertEqual(
109+ current_milestone_bug.bugtasks, list(result))
110+
111+
112 def test_suite():
113 suite = unittest.TestSuite()
114 loader = unittest.TestLoader()
115
116=== modified file 'lib/lp/registry/interfaces/person.py'
117--- lib/lp/registry/interfaces/person.py 2012-03-02 19:53:30 +0000
118+++ lib/lp/registry/interfaces/person.py 2012-03-27 19:49:28 +0000
119@@ -1518,6 +1518,20 @@
120 :return: a boolean.
121 """
122
123+ def getAssignedSpecificationWorkItemsDueBefore(date):
124+ """Return SpecificationWorkItems assigned to this person (or members
125+ of this team) and whose milestone is due between today and the given
126+ date (inclusive).
127+ """
128+
129+ def getAssignedBugTasksDueBefore(date, user):
130+ """Get all BugTasks assigned to this person (or members of this team)
131+ and whose milestone is due between today and the given date
132+ (inclusive).
133+ """
134+
135+ participant_ids = List(
136+ title=_("The DB IDs of this team's participants"), value_type=Int())
137 active_member_count = Attribute(
138 "The number of real people who are members of this team.")
139 # activemembers.value_type.schema will be set to IPerson once
140
141=== modified file 'lib/lp/registry/model/person.py'
142--- lib/lp/registry/model/person.py 2012-03-25 23:33:53 +0000
143+++ lib/lp/registry/model/person.py 2012-03-27 19:49:28 +0000
144@@ -62,6 +62,7 @@
145 from storm.expr import (
146 Alias,
147 And,
148+ Coalesce,
149 Desc,
150 Exists,
151 In,
152@@ -127,6 +128,7 @@
153 SpecificationImplementationStatus,
154 SpecificationSort,
155 )
156+from lp.blueprints.model.specificationworkitem import SpecificationWorkItem
157 from lp.blueprints.model.specification import (
158 HasSpecificationsMixin,
159 Specification,
160@@ -221,6 +223,7 @@
161 KarmaCategory,
162 KarmaTotalCache,
163 )
164+from lp.registry.model.milestone import Milestone
165 from lp.registry.model.personlocation import PersonLocation
166 from lp.registry.model.pillar import PillarName
167 from lp.registry.model.sourcepackagename import SourcePackageName
168@@ -230,7 +233,10 @@
169 TeamParticipation,
170 )
171 from lp.services.config import config
172-from lp.services.database import postgresql
173+from lp.services.database import (
174+ bulk,
175+ postgresql,
176+ )
177 from lp.services.database.constants import UTC_NOW
178 from lp.services.database.datetimecol import UtcDateTimeCol
179 from lp.services.database.decoratedresultset import DecoratedResultSet
180@@ -290,6 +296,7 @@
181 REDEEMABLE_VOUCHER_STATUSES,
182 VOUCHER_STATUSES,
183 )
184+from lp.services.searchbuilder import any
185 from lp.services.statistics.interfaces.statistic import ILaunchpadStatisticSet
186 from lp.services.verification.interfaces.authtoken import LoginTokenType
187 from lp.services.verification.interfaces.logintoken import ILoginTokenSet
188@@ -1468,6 +1475,104 @@
189 """See `IPerson`."""
190 self._inTeam_cache = {}
191
192+ @cachedproperty
193+ def participant_ids(self):
194+ """See `IPerson`."""
195+ return list(Store.of(self).find(
196+ TeamParticipation.personID, TeamParticipation.teamID == self.id))
197+
198+ def getAssignedSpecificationWorkItemsDueBefore(self, date):
199+ """See `IPerson`."""
200+ from lp.registry.model.person import Person
201+ from lp.registry.model.product import Product
202+ from lp.registry.model.distribution import Distribution
203+ store = Store.of(self)
204+ WorkItem = SpecificationWorkItem
205+ origin = [
206+ WorkItem,
207+ Join(Specification, WorkItem.specification == Specification.id),
208+ # WorkItems may not have a milestone and in that case they inherit
209+ # the one from the spec.
210+ Join(Milestone,
211+ Coalesce(WorkItem.milestone_id,
212+ Specification.milestoneID) == Milestone.id),
213+ ]
214+ today = datetime.today().date()
215+ query = AND(
216+ Milestone.dateexpected <= date, Milestone.dateexpected >= today,
217+ OR(WorkItem.assignee_id.is_in(self.participant_ids),
218+ AND(WorkItem.assignee == None,
219+ Specification.assigneeID.is_in(self.participant_ids))))
220+ result = store.using(*origin).find(WorkItem, query)
221+ def eager_load(workitems):
222+ specs = bulk.load_related(
223+ Specification, workitems, ['specification_id'])
224+ bulk.load_related(Product, specs, ['productID'])
225+ bulk.load_related(Distribution, specs, ['distributionID'])
226+ assignee_ids = set(
227+ [workitem.assignee_id for workitem in workitems]
228+ + [spec.assigneeID for spec in specs])
229+ assignee_ids.discard(None)
230+ bulk.load(Person, assignee_ids, store)
231+ milestone_ids = set(
232+ [workitem.milestone_id for workitem in workitems]
233+ + [spec.milestoneID for spec in specs])
234+ milestone_ids.discard(None)
235+ bulk.load(Milestone, milestone_ids, store)
236+ return DecoratedResultSet(result, pre_iter_hook=eager_load)
237+
238+ def getAssignedBugTasksDueBefore(self, date, user):
239+ """See `IPerson`."""
240+ from lp.bugs.model.bugtask import BugTask
241+ from lp.registry.model.distribution import Distribution
242+ from lp.registry.model.distroseries import DistroSeries
243+ from lp.registry.model.productseries import ProductSeries
244+ today = datetime.today().date()
245+ search_params = BugTaskSearchParams(
246+ user, assignee=any(*self.participant_ids),
247+ milestone_dateexpected_before=date,
248+ milestone_dateexpected_after=today)
249+
250+ # Cast to a list to avoid DecoratedResultSet running pre_iter_hook
251+ # multiple times when load_related() iterates over through the tasks.
252+ tasks = list(getUtility(IBugTaskSet).search(search_params))
253+ # Eager load the things we need that are not already eager loaded by
254+ # BugTaskSet.search().
255+ bulk.load_related(ProductSeries, tasks, ['productseriesID'])
256+ bulk.load_related(Distribution, tasks, ['distributionID'])
257+ bulk.load_related(DistroSeries, tasks, ['distroseriesID'])
258+ bulk.load_related(Person, tasks, ['assigneeID'])
259+ bulk.load_related(Milestone, tasks, ['milestoneID'])
260+
261+ for task in tasks:
262+ # We skip masters (instead of slaves) from conjoined relationships
263+ # because we can do that without hittind the DB, which would not
264+ # be possible if we wanted to skip the slaves. The simple (but
265+ # expensive) way to skip the slaves would be to skip any tasks
266+ # that have a non-None .conjoined_master.
267+ productseries = task.productseries
268+ distroseries = task.distroseries
269+ if productseries is not None and task.product is None:
270+ dev_focus_id = productseries.product.development_focusID
271+ if (productseries.id == dev_focus_id and
272+ task.status not in BugTask._NON_CONJOINED_STATUSES):
273+ continue
274+ elif distroseries is not None:
275+ candidate = None
276+ for possible_slave in tasks:
277+ sourcepackagename_id = possible_slave.sourcepackagenameID
278+ if sourcepackagename_id == task.sourcepackagenameID:
279+ candidate = possible_slave
280+ # Distribution.currentseries is expensive to run for every
281+ # bugtask (as it goes through every series of that
282+ # distribution), but it's a cached property and there's only
283+ # one distribution with bugs in LP, so we can afford to do
284+ # it here.
285+ if (candidate is not None and
286+ distroseries.distribution.currentseries == distroseries):
287+ continue
288+ yield task
289+
290 #
291 # ITeam methods
292 #
293
294=== modified file 'lib/lp/registry/tests/test_person.py'
295--- lib/lp/registry/tests/test_person.py 2012-02-28 04:24:19 +0000
296+++ lib/lp/registry/tests/test_person.py 2012-03-27 19:49:28 +0000
297@@ -3,7 +3,10 @@
298
299 __metaclass__ = type
300
301-from datetime import datetime
302+from datetime import (
303+ datetime,
304+ timedelta,
305+ )
306
307 from lazr.lifecycle.snapshot import Snapshot
308 from lazr.restful.utils import smartquote
309@@ -42,6 +45,10 @@
310 get_recipients,
311 Person,
312 )
313+from lp.services.database.sqlbase import (
314+ flush_database_caches,
315+ flush_database_updates,
316+ )
317 from lp.services.features.testing import FeatureFixture
318 from lp.services.identity.interfaces.account import AccountStatus
319 from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
320@@ -1113,3 +1120,338 @@
321 self.assertContentEqual(
322 [team2.teamowner],
323 get_recipients(team2))
324+
325+
326+class Test_getAssignedSpecificationWorkItemsDueBefore(TestCaseWithFactory):
327+ layer = DatabaseFunctionalLayer
328+
329+ def setUp(self):
330+ super(Test_getAssignedSpecificationWorkItemsDueBefore, self).setUp()
331+ self.team = self.factory.makeTeam()
332+ today = datetime.today().date()
333+ next_month = today + timedelta(days=30)
334+ next_year = today + timedelta(days=366)
335+ self.current_milestone = self.factory.makeMilestone(
336+ dateexpected=next_month)
337+ self.product = self.current_milestone.product
338+ self.future_milestone = self.factory.makeMilestone(
339+ dateexpected=next_year, product=self.product)
340+
341+ def test_basic(self):
342+ assigned_spec = self.factory.makeSpecification(
343+ assignee=self.team.teamowner, milestone=self.current_milestone,
344+ product=self.product)
345+ # Create a workitem with no explicit assignee/milestone. This way it
346+ # will inherit the ones from the spec it belongs to.
347+ workitem = self.factory.makeSpecificationWorkItem(
348+ title=u'workitem 1', specification=assigned_spec)
349+
350+ # Create a workitem with somebody who's not a member of our team as
351+ # the assignee. This workitem must not be in the list returned by
352+ # getAssignedSpecificationWorkItemsDueBefore().
353+ self.factory.makeSpecificationWorkItem(
354+ title=u'workitem 2', specification=assigned_spec,
355+ assignee=self.factory.makePerson())
356+
357+ # Create a workitem targeted to a milestone too far in the future.
358+ # This workitem must not be in the list returned by
359+ # getAssignedSpecificationWorkItemsDueBefore().
360+ self.factory.makeSpecificationWorkItem(
361+ title=u'workitem 3', specification=assigned_spec,
362+ milestone=self.future_milestone)
363+
364+ workitems = self.team.getAssignedSpecificationWorkItemsDueBefore(
365+ self.current_milestone.dateexpected)
366+
367+ self.assertEqual([workitem], list(workitems))
368+
369+ def test_skips_workitems_with_milestone_in_the_past(self):
370+ today = datetime.today().date()
371+ milestone = self.factory.makeMilestone(
372+ dateexpected=today - timedelta(days=1))
373+ spec = self.factory.makeSpecification(
374+ assignee=self.team.teamowner, milestone=milestone,
375+ product=milestone.product)
376+ self.factory.makeSpecificationWorkItem(
377+ title=u'workitem 1', specification=spec)
378+
379+ workitems = self.team.getAssignedSpecificationWorkItemsDueBefore(today)
380+
381+ self.assertEqual([], list(workitems))
382+
383+ def test_includes_workitems_from_future_spec(self):
384+ assigned_spec = self.factory.makeSpecification(
385+ assignee=self.team.teamowner, milestone=self.future_milestone,
386+ product=self.product)
387+ # This workitem inherits the spec's milestone and that's too far in
388+ # the future so it won't be in the returned list.
389+ self.factory.makeSpecificationWorkItem(
390+ title=u'workitem 1', specification=assigned_spec)
391+ # This one, on the other hand, is explicitly targeted to the current
392+ # milestone, so it is included in the returned list even though its
393+ # spec is targeted to the future milestone.
394+ workitem = self.factory.makeSpecificationWorkItem(
395+ title=u'workitem 2', specification=assigned_spec,
396+ milestone=self.current_milestone)
397+
398+ workitems = self.team.getAssignedSpecificationWorkItemsDueBefore(
399+ self.current_milestone.dateexpected)
400+
401+ self.assertEqual([workitem], list(workitems))
402+
403+ def test_includes_workitems_from_foreign_spec(self):
404+ # This spec is assigned to a person who's not a member of our team, so
405+ # only the workitems that are explicitly assigned to a member of our
406+ # team will be in the returned list.
407+ foreign_spec = self.factory.makeSpecification(
408+ assignee=self.factory.makePerson(),
409+ milestone=self.current_milestone, product=self.product)
410+ # This one is not explicitly assigned to anyone, so it inherits the
411+ # assignee of its spec and hence is not in the returned list.
412+ self.factory.makeSpecificationWorkItem(
413+ title=u'workitem 1', specification=foreign_spec)
414+
415+ # This one, on the other hand, is explicitly assigned to the a member
416+ # of our team, so it is included in the returned list even though its
417+ # spec is not assigned to a member of our team.
418+ workitem = self.factory.makeSpecificationWorkItem(
419+ title=u'workitem 2', specification=foreign_spec,
420+ assignee=self.team.teamowner)
421+
422+ workitems = self.team.getAssignedSpecificationWorkItemsDueBefore(
423+ self.current_milestone.dateexpected)
424+
425+ self.assertEqual([workitem], list(workitems))
426+
427+ def _makeProductSpec(self, milestone_dateexpected):
428+ assignee = self.factory.makePerson()
429+ with person_logged_in(self.team.teamowner):
430+ self.team.addMember(assignee, reviewer=self.team.teamowner)
431+ milestone = self.factory.makeMilestone(
432+ dateexpected=milestone_dateexpected)
433+ spec = self.factory.makeSpecification(
434+ product=milestone.product, milestone=milestone, assignee=assignee)
435+ return spec
436+
437+ def _makeDistroSpec(self, milestone_dateexpected):
438+ assignee = self.factory.makePerson()
439+ with person_logged_in(self.team.teamowner):
440+ self.team.addMember(assignee, reviewer=self.team.teamowner)
441+ distro = self.factory.makeDistribution()
442+ milestone = self.factory.makeMilestone(
443+ dateexpected=milestone_dateexpected, distribution=distro)
444+ spec = self.factory.makeSpecification(
445+ distribution=distro, milestone=milestone, assignee=assignee)
446+ return spec
447+
448+ def test_query_count(self):
449+ dateexpected = self.current_milestone.dateexpected
450+ # Create 10 SpecificationWorkItems, each of them with a different
451+ # specification, milestone and assignee. Also, half of the
452+ # specifications will have a Product as a target and the other half
453+ # will have a Distribution.
454+ for i in range(5):
455+ spec = self._makeProductSpec(dateexpected)
456+ self.factory.makeSpecificationWorkItem(
457+ title=u'product work item %d' % i, assignee=spec.assignee,
458+ milestone=spec.milestone, specification=spec)
459+ spec2 = self._makeDistroSpec(dateexpected)
460+ self.factory.makeSpecificationWorkItem(
461+ title=u'distro work item %d' % i, assignee=spec2.assignee,
462+ milestone=spec2.milestone, specification=spec2)
463+ flush_database_updates()
464+ flush_database_caches()
465+ with StormStatementRecorder() as recorder:
466+ workitems = list(
467+ self.team.getAssignedSpecificationWorkItemsDueBefore(
468+ dateexpected))
469+ for workitem in workitems:
470+ workitem.assignee
471+ workitem.milestone
472+ workitem.specification
473+ workitem.specification.assignee
474+ workitem.specification.milestone
475+ workitem.specification.target
476+ self.assertEqual(10, len(workitems))
477+ # 1. One query to get all team members;
478+ # 2. One to get all SpecWorkItems;
479+ # 3. One to get all Specifications;
480+ # 4. One to get all SpecWorkItem/Specification assignees;
481+ # 5. One to get all SpecWorkItem/Specification milestones;
482+ # 6. One to get all Specification products;
483+ # 7. One to get all Specification distributions;
484+ self.assertThat(recorder, HasQueryCount(Equals(7)))
485+
486+
487+class Test_getAssignedBugTasksDueBefore(TestCaseWithFactory):
488+ layer = DatabaseFunctionalLayer
489+
490+ def setUp(self):
491+ super(Test_getAssignedBugTasksDueBefore, self).setUp()
492+ self.team = self.factory.makeTeam()
493+ self.today = datetime.today().date()
494+
495+ def _assignBugTaskToTeamOwner(self, bugtask):
496+ removeSecurityProxy(bugtask).assignee = self.team.teamowner
497+
498+ def test_basic(self):
499+ milestone = self.factory.makeMilestone(dateexpected=self.today)
500+ # This bug is assigned to a team member and targeted to a milestone
501+ # whose due date is before the cutoff date we pass in, so it will be
502+ # included in the return of getAssignedBugTasksDueBefore().
503+ milestoned_bug = self.factory.makeBug(milestone=milestone)
504+ self._assignBugTaskToTeamOwner(milestoned_bug.bugtasks[0])
505+ # This one is assigned to a team member but not milestoned, so it is
506+ # not included in the return of getAssignedBugTasksDueBefore().
507+ non_milestoned_bug = self.factory.makeBug()
508+ self._assignBugTaskToTeamOwner(non_milestoned_bug.bugtasks[0])
509+ # This one is milestoned but not assigned to a team member, so it is
510+ # not included in the return of getAssignedBugTasksDueBefore() either.
511+ non_assigned_bug = self.factory.makeBug()
512+ self._assignBugTaskToTeamOwner(non_assigned_bug.bugtasks[0])
513+
514+ bugtasks = list(self.team.getAssignedBugTasksDueBefore(
515+ self.today + timedelta(days=1), user=None))
516+
517+ self.assertEqual(1, len(bugtasks))
518+ self.assertEqual(milestoned_bug.bugtasks[0], bugtasks[0])
519+
520+ def test_skips_tasks_targeted_to_old_milestones(self):
521+ past_milestone = self.factory.makeMilestone(
522+ dateexpected=self.today - timedelta(days=1))
523+ bug = self.factory.makeBug(milestone=past_milestone)
524+ self._assignBugTaskToTeamOwner(bug.bugtasks[0])
525+
526+ bugtasks = list(self.team.getAssignedBugTasksDueBefore(
527+ self.today + timedelta(days=1), user=None))
528+
529+ self.assertEqual(0, len(bugtasks))
530+
531+ def test_skips_private_bugs_the_user_is_not_allowed_to_see(self):
532+ milestone = self.factory.makeMilestone(dateexpected=self.today)
533+ private_bug = removeSecurityProxy(
534+ self.factory.makeBug(milestone=milestone, private=True))
535+ self._assignBugTaskToTeamOwner(private_bug.bugtasks[0])
536+ private_bug2 = removeSecurityProxy(
537+ self.factory.makeBug(milestone=milestone, private=True))
538+ self._assignBugTaskToTeamOwner(private_bug2.bugtasks[0])
539+
540+ with person_logged_in(private_bug2.owner):
541+ bugtasks = list(self.team.getAssignedBugTasksDueBefore(
542+ self.today + timedelta(days=1),
543+ removeSecurityProxy(private_bug2).owner))
544+
545+ self.assertEqual(private_bug2.bugtasks, bugtasks)
546+
547+ def test_skips_distroseries_task_that_is_a_conjoined_master(self):
548+ distroseries = self.factory.makeDistroSeries()
549+ sourcepackagename = self.factory.makeSourcePackageName()
550+ milestone = self.factory.makeMilestone(
551+ distroseries=distroseries, dateexpected=self.today)
552+ self.factory.makeSourcePackagePublishingHistory(
553+ distroseries=distroseries, sourcepackagename=sourcepackagename)
554+ bug = self.factory.makeBug(
555+ milestone=milestone, sourcepackagename=sourcepackagename,
556+ distribution=distroseries.distribution)
557+ package = distroseries.getSourcePackage(sourcepackagename.name)
558+ removeSecurityProxy(bug).addTask(bug.owner, package)
559+ self.assertEqual(2, len(bug.bugtasks))
560+ slave, master = bug.bugtasks
561+ self._assignBugTaskToTeamOwner(master)
562+ self.assertEqual(None, master.conjoined_master)
563+ self.assertEqual(master, slave.conjoined_master)
564+ self.assertEqual(slave.milestone, master.milestone)
565+ self.assertEqual(slave.assignee, master.assignee)
566+
567+ bugtasks = list(self.team.getAssignedBugTasksDueBefore(
568+ self.today + timedelta(days=1), user=None))
569+
570+ self.assertEqual([slave], bugtasks)
571+
572+ def test_skips_productseries_task_that_is_a_conjoined_master(self):
573+ milestone = self.factory.makeMilestone(dateexpected=self.today)
574+ removeSecurityProxy(milestone.product).development_focus = (
575+ milestone.productseries)
576+ bug = self.factory.makeBug(
577+ series=milestone.productseries, milestone=milestone)
578+ self.assertEqual(2, len(bug.bugtasks))
579+ slave, master = bug.bugtasks
580+
581+ # This will cause the assignee to propagate to the other bugtask as
582+ # well since they're conjoined.
583+ self._assignBugTaskToTeamOwner(slave)
584+ self.assertEqual(master, slave.conjoined_master)
585+ self.assertEqual(slave.milestone, master.milestone)
586+ self.assertEqual(slave.assignee, master.assignee)
587+
588+ bugtasks = list(self.team.getAssignedBugTasksDueBefore(
589+ self.today + timedelta(days=1), user=None))
590+
591+ self.assertEqual([slave], bugtasks)
592+
593+ def _assignBugTaskToTeamOwnerAndSetMilestone(self, task, milestone):
594+ self._assignBugTaskToTeamOwner(task)
595+ removeSecurityProxy(task).milestone = milestone
596+
597+ def test_query_count(self):
598+ # Create one Product bugtask;
599+ milestone = self.factory.makeMilestone(dateexpected=self.today)
600+ product_bug = self.factory.makeBug(product=milestone.product)
601+ self._assignBugTaskToTeamOwnerAndSetMilestone(
602+ product_bug.bugtasks[0], milestone)
603+
604+ # One ProductSeries bugtask;
605+ productseries_bug = self.factory.makeBug(
606+ series=milestone.productseries)
607+ self._assignBugTaskToTeamOwnerAndSetMilestone(
608+ productseries_bug.bugtasks[1], milestone)
609+
610+ # One DistroSeries bugtask;
611+ distro = self.factory.makeDistribution()
612+ distro_milestone = self.factory.makeMilestone(
613+ distribution=distro, dateexpected=self.today)
614+ distroseries_bug = self.factory.makeBug(
615+ series=distro_milestone.distroseries)
616+ self._assignBugTaskToTeamOwnerAndSetMilestone(
617+ distroseries_bug.bugtasks[1], distro_milestone)
618+
619+ # One Distribution bugtask;
620+ distro_bug = self.factory.makeBug(
621+ distribution=distro_milestone.distribution)
622+ self._assignBugTaskToTeamOwnerAndSetMilestone(
623+ distro_bug.bugtasks[0], distro_milestone)
624+
625+ # One SourcePackage bugtask;
626+ distroseries = distro_milestone.distroseries
627+ sourcepackagename = self.factory.makeSourcePackageName()
628+ self.factory.makeSourcePackagePublishingHistory(
629+ distroseries=distroseries,
630+ sourcepackagename=sourcepackagename)
631+ sourcepackage_bug = self.factory.makeBug(
632+ sourcepackagename=sourcepackagename, distribution=distro)
633+ self._assignBugTaskToTeamOwnerAndSetMilestone(
634+ sourcepackage_bug.bugtasks[0], distro_milestone)
635+
636+ flush_database_updates()
637+ flush_database_caches()
638+ with StormStatementRecorder() as recorder:
639+ tasks = list(self.team.getAssignedBugTasksDueBefore(
640+ self.today + timedelta(days=1), user=None))
641+ for task in tasks:
642+ task.bug
643+ task.target
644+ task.milestone
645+ task.assignee
646+ self.assertEqual(5, len(tasks))
647+ # 1. One query to get all team members;
648+ # 2. One to get all BugTasks;
649+ # 3. One to get all assignees;
650+ # 4. One to get all milestones;
651+ # 5. One to get all products;
652+ # 6. One to get all productseries;
653+ # 7. One to get all distributions;
654+ # 8. One to get all distroseries;
655+ # 9. One to get all sourcepackagenames;
656+ # 10. One to get all distroseries of a bug's distro. (See comment on
657+ # getAssignedBugTasksDueBefore() to understand why it's needed)
658+ self.assertThat(recorder, HasQueryCount(Equals(10)))
659
660=== modified file 'lib/lp/testing/factory.py'
661--- lib/lp/testing/factory.py 2012-03-26 07:05:16 +0000
662+++ lib/lp/testing/factory.py 2012-03-27 19:49:28 +0000
663@@ -844,23 +844,26 @@
664 return getUtility(ITranslatorSet).new(group, language, person)
665
666 def makeMilestone(self, product=None, distribution=None,
667- productseries=None, name=None, active=True):
668- if product is None and distribution is None and productseries is None:
669+ productseries=None, name=None, active=True,
670+ dateexpected=None, distroseries=None):
671+ if (product is None and distribution is None and productseries is None
672+ and distroseries is None):
673 product = self.makeProduct()
674- if distribution is None:
675+ if distribution is None and distroseries is None:
676 if productseries is not None:
677 product = productseries.product
678 else:
679 productseries = self.makeProductSeries(product=product)
680- distroseries = None
681+ elif distroseries is None:
682+ distroseries = self.makeDistroSeries(distribution=distribution)
683 else:
684- distroseries = self.makeDistroSeries(distribution=distribution)
685+ distribution = distroseries.distribution
686 if name is None:
687 name = self.getUniqueString()
688 return ProxyFactory(
689 Milestone(product=product, distribution=distribution,
690 productseries=productseries, distroseries=distroseries,
691- name=name, active=active))
692+ name=name, active=active, dateexpected=dateexpected))
693
694 def makeProcessor(self, family=None, name=None, title=None,
695 description=None):
696@@ -1656,7 +1659,7 @@
697 or distribution parameters, or the those parameters must be None.
698 :param series: If set, the series.product must match the product
699 parameter, or the series.distribution must match the distribution
700- parameter, or the those parameters must be None.
701+ parameter, or those parameters must be None.
702 :param tags: If set, the tags to be added with the bug.
703 :param distribution: If set, the sourcepackagename is used as the
704 default bug target.