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

Proposed by Guilherme Salgado on 2012-04-03
Status: Superseded
Proposed branch: lp:~linaro-infrastructure/launchpad/team-engineering-view-ui
Merge into: lp:launchpad
Diff against target: 890 lines (+729/-6)
10 files modified
lib/lp/blueprints/interfaces/specificationworkitem.py (+6/-0)
lib/lp/blueprints/model/specificationworkitem.py (+5/-0)
lib/lp/registry/browser/configure.zcml (+6/-0)
lib/lp/registry/browser/team.py (+297/-3)
lib/lp/registry/browser/tests/test_team_upcomingwork.py (+288/-0)
lib/lp/registry/model/person.py (+2/-2)
lib/lp/registry/templates/team-index.pt (+7/-0)
lib/lp/registry/templates/team-upcomingwork.pt (+105/-0)
lib/lp/services/features/flags.py (+6/-0)
lib/lp/testing/factory.py (+7/-1)
To merge this branch: bzr merge lp:~linaro-infrastructure/launchpad/team-engineering-view-ui
Reviewer Review Type Date Requested Status
Steve Kowalik (community) code 2012-04-03 Approve on 2012-04-03
Review via email: mp+100707@code.launchpad.net

This proposal has been superseded by a proposal from 2012-04-05.

Commit Message

View for upcoming blueprints and bugs assigned to a team.

Description of the Change

This branch starts the implementation of the new team page showing all
upcoming work (up to 6 months, really) assigned to members of the team.

It is part of https://dev.launchpad.net/Projects/WorkItems and a screenshot of
what it currently looks like is at
http://people.canonical.com/~salgado/upcoming-work.png

It is protected with a feature flag because it is not ready for general
consumption yet. We plan to make it visible only to Linaro until we implement
the remaining bits and polish the UI, but if there's interest it can be made
available to other teams as well.

To post a comment you must log in.
Steve Kowalik (stevenk) wrote :

Broadly this looks good. I think you should run format-imports on the files you've changed or added, some of them are not quite right.

I'm not really happy with "registry.upcoming_work_view.enabled" as a feature flag name -- perhaps it should move to blueprints.

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

Thanks for the review, Steve.

On 03/04/12 20:59, Steve Kowalik wrote:
> Review: Approve code
>
> Broadly this looks good. I think you should run format-imports on the files you've changed or added, some of them are not quite right.

Cool, I'll do that.

>
> I'm not really happy with "registry.upcoming_work_view.enabled" as a feature flag name -- perhaps it should move to blueprints.

The problem is that the new page actually spans across blueprints and
bugs; that's why it is on the default layer and not blueprints.

Mattias Backman (mabac) wrote :

On Wed, Apr 4, 2012 at 3:25 AM, Guilherme Salgado
<email address hidden> wrote:
> Thanks for the review, Steve.
>
> On 03/04/12 20:59, Steve Kowalik wrote:
>> Review: Approve code
>>
>> Broadly this looks good. I think you should run format-imports on the files you've changed or added, some of them are not quite right.
>
> Cool, I'll do that.

That bit has been fixed.

>
>>
>> I'm not really happy with "registry.upcoming_work_view.enabled" as a feature flag name -- perhaps it should move to blueprints.
>
> The problem is that the new page actually spans across blueprints and
> bugs; that's why it is on the default layer and not blueprints.
>
>
> --
> https://code.launchpad.net/~linaro-infrastructure/launchpad/team-engineering-view-ui/+merge/100707
> Your team Linaro Infrastructure is subscribed to branch lp:~linaro-infrastructure/launchpad/team-engineering-view-ui.

Guilherme Salgado (salgado) wrote :

I've changed it back to needs-review as these changes have been reverted because there was a XSS hole in the new page. My last commit here (r14958) fixes that.

14958. By Guilherme Salgado on 2012-04-05

merge devel

14959. By Guilherme Salgado on 2012-04-05

revert the revision that reverted all our changes

14960. By Guilherme Salgado on 2012-04-05

Fix XSS hole when rendering workitem titles in the new +upcomingwork page

14961. By Mattias Backman on 2012-04-05

Replace TODO comment with XXX tal comment.

Unmerged revisions

14961. By Mattias Backman on 2012-04-05

Replace TODO comment with XXX tal comment.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/blueprints/interfaces/specificationworkitem.py'
2--- lib/lp/blueprints/interfaces/specificationworkitem.py 2012-02-07 01:53:23 +0000
3+++ lib/lp/blueprints/interfaces/specificationworkitem.py 2012-04-04 08:07:25 +0000
4@@ -74,3 +74,9 @@
5 required=True, description=_(
6 "The sequence in which the work items are to be displayed in the "
7 "UI."))
8+
9+ is_complete = Bool(
10+ readonly=True,
11+ description=_(
12+ "True or False depending on whether or not there is more "
13+ "work required on this work item."))
14
15=== modified file 'lib/lp/blueprints/model/specificationworkitem.py'
16--- lib/lp/blueprints/model/specificationworkitem.py 2012-03-09 19:27:35 +0000
17+++ lib/lp/blueprints/model/specificationworkitem.py 2012-04-04 08:07:25 +0000
18@@ -59,3 +59,8 @@
19 self.assignee=assignee
20 self.milestone=milestone
21 self.sequence=sequence
22+
23+ @property
24+ def is_complete(self):
25+ """See `ISpecificationWorkItem`."""
26+ return self.status == SpecificationWorkItemStatus.DONE
27
28=== modified file 'lib/lp/registry/browser/configure.zcml'
29--- lib/lp/registry/browser/configure.zcml 2012-03-31 11:32:15 +0000
30+++ lib/lp/registry/browser/configure.zcml 2012-04-04 08:07:25 +0000
31@@ -1084,6 +1084,12 @@
32 name="+mugshots"
33 template="../templates/team-mugshots.pt"
34 class="lp.registry.browser.team.TeamMugshotView"/>
35+ <browser:page
36+ for="lp.registry.interfaces.person.ITeam"
37+ class="lp.registry.browser.team.TeamUpcomingWorkView"
38+ permission="zope.Public"
39+ name="+upcomingwork"
40+ template="../templates/team-upcomingwork.pt"/>
41 <browser:page
42 for="lp.registry.interfaces.person.ITeam"
43 class="lp.registry.browser.team.TeamIndexView"
44
45=== modified file 'lib/lp/registry/browser/team.py'
46--- lib/lp/registry/browser/team.py 2012-03-14 21:05:57 +0000
47+++ lib/lp/registry/browser/team.py 2012-04-04 08:07:25 +0000
48@@ -28,6 +28,7 @@
49 'TeamOverviewNavigationMenu',
50 'TeamPrivacyAdapter',
51 'TeamReassignmentView',
52+ 'TeamUpcomingWorkView',
53 ]
54
55
56@@ -37,6 +38,10 @@
57 timedelta,
58 )
59 import math
60+from operator import (
61+ attrgetter,
62+ itemgetter,
63+ )
64 from urllib import unquote
65
66 from lazr.restful.interface import copy_field
67@@ -78,7 +83,11 @@
68 custom_widget,
69 LaunchpadFormView,
70 )
71-from lp.app.browser.tales import PersonFormatterAPI
72+from lp.app.browser.stringformatter import FormattersAPI
73+from lp.app.browser.tales import (
74+ format_link,
75+ PersonFormatterAPI,
76+ )
77 from lp.app.errors import UnexpectedFormData
78 from lp.app.validators import LaunchpadValidationError
79 from lp.app.validators.validation import validate_new_team_email
80@@ -89,6 +98,7 @@
81 )
82 from lp.app.widgets.owner import HiddenUserWidget
83 from lp.app.widgets.popup import PersonPickerWidget
84+from lp.blueprints.enums import SpecificationWorkItemStatus
85 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
86 from lp.registry.browser.branding import BrandingChangeView
87 from lp.registry.browser.mailinglists import enabled_with_active_mailing_list
88@@ -142,6 +152,7 @@
89 )
90 from lp.security import ModerateByRegistryExpertsOrAdmins
91 from lp.services.config import config
92+from lp.services.features import getFeatureFlag
93 from lp.services.fields import PublicPersonChoice
94 from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
95 from lp.services.privacy.interfaces import IObjectPrivacy
96@@ -1589,6 +1600,14 @@
97 icon = 'add'
98 return Link(target, text, icon=icon, enabled=enabled)
99
100+ def upcomingwork(self):
101+ target = '+upcomingwork'
102+ text = 'Upcoming work for this team'
103+ enabled = False
104+ if getFeatureFlag('registry.upcoming_work_view.enabled'):
105+ enabled = True
106+ return Link(target, text, icon='team', enabled=enabled)
107+
108
109 class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin):
110
111@@ -1622,6 +1641,7 @@
112 'view_recipes',
113 'subscriptions',
114 'structural_subscriptions',
115+ 'upcomingwork',
116 ]
117
118
119@@ -2089,6 +2109,10 @@
120 """A marker interface for the edit navigation menu."""
121
122
123+classImplements(TeamIndexView, ITeamIndexMenu)
124+classImplements(TeamEditView, ITeamEditMenu)
125+
126+
127 class TeamNavigationMenuBase(NavigationMenu, TeamMenuMixin):
128
129 @property
130@@ -2135,5 +2159,275 @@
131 return batch_nav
132
133
134-classImplements(TeamIndexView, ITeamIndexMenu)
135-classImplements(TeamEditView, ITeamEditMenu)
136+class TeamUpcomingWorkView(LaunchpadView):
137+ """This view displays work items and bugtasks that are due within 60 days
138+ and are assigned to members of a team.
139+ """
140+
141+ # We'll show bugs and work items targeted to milestones with a due date up
142+ # to DAYS from now.
143+ DAYS = 180
144+
145+ def initialize(self):
146+ super(TeamUpcomingWorkView, self).initialize()
147+ self.workitem_counts = {}
148+ self.bugtask_counts = {}
149+ self.milestones_per_date = {}
150+ for date, containers in self.work_item_containers:
151+ milestones = set()
152+ self.bugtask_counts[date] = 0
153+ self.workitem_counts[date] = 0
154+ for container in containers:
155+ if isinstance(container, AggregatedBugsContainer):
156+ self.bugtask_counts[date] += len(container.items)
157+ else:
158+ self.workitem_counts[date] += len(container.items)
159+ for item in container.items:
160+ milestones.add(item.milestone)
161+ self.milestones_per_date[date] = sorted(
162+ milestones, key=attrgetter('displayname'))
163+
164+ @property
165+ def label(self):
166+ return self.page_title
167+
168+ @property
169+ def page_title(self):
170+ return "Upcoming work for %s" % self.context.displayname
171+
172+ @cachedproperty
173+ def work_item_containers(self):
174+ cutoff_date = datetime.today().date() + timedelta(days=self.DAYS)
175+ result = getWorkItemsDueBefore(self.context, cutoff_date, self.user)
176+ return sorted(result.items(), key=itemgetter(0))
177+
178+
179+class WorkItemContainer:
180+ """A container of work items, assigned to members of a team, whose
181+ milestone is due on a certain date.
182+ """
183+
184+ def __init__(self):
185+ self._items = []
186+
187+ @property
188+ def html_link(self):
189+ raise NotImplementedError("Must be implemented in subclasses")
190+
191+ @property
192+ def priority_title(self):
193+ raise NotImplementedError("Must be implemented in subclasses")
194+
195+ @property
196+ def target_link(self):
197+ raise NotImplementedError("Must be implemented in subclasses")
198+
199+ @property
200+ def assignee_link(self):
201+ raise NotImplementedError("Must be implemented in subclasses")
202+
203+ @property
204+ def items(self):
205+ raise NotImplementedError("Must be implemented in subclasses")
206+
207+ @property
208+ def progress_text(self):
209+ done_items = [item for item in self._items if item.is_complete]
210+ return '{0:.0f}%'.format(100.0 * len(done_items) / len(self._items))
211+
212+ def append(self, item):
213+ self._items.append(item)
214+
215+
216+class SpecWorkItemContainer(WorkItemContainer):
217+ """A container of SpecificationWorkItems wrapped with GenericWorkItem."""
218+
219+ def __init__(self, spec):
220+ super(SpecWorkItemContainer, self).__init__()
221+ self.spec = spec
222+ self.priority = spec.priority
223+ self.target = spec.target
224+ self.assignee = spec.assignee
225+
226+ @property
227+ def html_link(self):
228+ return format_link(self.spec)
229+
230+ @property
231+ def priority_title(self):
232+ return self.priority.title
233+
234+ @property
235+ def target_link(self):
236+ return format_link(self.target)
237+
238+ @property
239+ def assignee_link(self):
240+ if self.assignee is None:
241+ return 'Nobody'
242+ return format_link(self.assignee)
243+
244+ @property
245+ def items(self):
246+ # Sort the work items by status only because they all have the same
247+ # priority.
248+ def sort_key(item):
249+ status_order = {
250+ SpecificationWorkItemStatus.POSTPONED: 5,
251+ SpecificationWorkItemStatus.DONE: 4,
252+ SpecificationWorkItemStatus.INPROGRESS: 3,
253+ SpecificationWorkItemStatus.TODO: 2,
254+ SpecificationWorkItemStatus.BLOCKED: 1,
255+ }
256+ return status_order[item.status]
257+ return sorted(self._items, key=sort_key)
258+
259+
260+class AggregatedBugsContainer(WorkItemContainer):
261+ """A container of BugTasks wrapped with GenericWorkItem."""
262+
263+ @property
264+ def html_link(self):
265+ return 'Bugs targeted to a milestone on this date'
266+
267+ @property
268+ def assignee_link(self):
269+ return 'N/A'
270+
271+ @property
272+ def target_link(self):
273+ return 'N/A'
274+
275+ @property
276+ def priority_title(self):
277+ return 'N/A'
278+
279+ @property
280+ def items(self):
281+ def sort_key(item):
282+ return (item.status.value, item.priority.value)
283+ # Sort by (status, priority) in reverse order because the biggest the
284+ # status/priority the more interesting it is to us.
285+ return sorted(self._items, key=sort_key, reverse=True)
286+
287+
288+class GenericWorkItem:
289+ """A generic piece of work; either a BugTask or a SpecificationWorkItem.
290+
291+ This class wraps a BugTask or a SpecificationWorkItem to provide a
292+ common API so that the template doesn't have to worry about what kind of
293+ work item it's dealing with.
294+ """
295+
296+ def __init__(self, assignee, status, priority, target, title,
297+ bugtask=None, work_item=None):
298+ self.assignee = assignee
299+ self.status = status
300+ self.priority = priority
301+ self.target = target
302+ self.title = title
303+ self._bugtask = bugtask
304+ self._work_item = work_item
305+
306+ @classmethod
307+ def from_bugtask(cls, bugtask):
308+ return cls(
309+ bugtask.assignee, bugtask.status, bugtask.importance,
310+ bugtask.target, bugtask.bug.description, bugtask=bugtask)
311+
312+ @classmethod
313+ def from_workitem(cls, work_item):
314+ assignee = work_item.assignee
315+ if assignee is None:
316+ assignee = work_item.specification.assignee
317+ return cls(
318+ assignee, work_item.status, work_item.specification.priority,
319+ work_item.specification.target, work_item.title,
320+ work_item=work_item)
321+
322+ @property
323+ def display_title(self):
324+ if self._work_item is not None:
325+ return FormattersAPI(self.title).shorten(120)
326+ else:
327+ return format_link(self._bugtask)
328+
329+ @property
330+ def milestone(self):
331+ milestone = self.actual_workitem.milestone
332+ if milestone is None:
333+ assert self._work_item is not None, (
334+ "BugTaks without a milestone must not be here.")
335+ milestone = self._work_item.specification.milestone
336+ return milestone
337+
338+ @property
339+ def actual_workitem(self):
340+ """Return the actual work item that we are wrapping.
341+
342+ This may be either an IBugTask or an ISpecificationWorkItem.
343+ """
344+ if self._work_item is not None:
345+ return self._work_item
346+ else:
347+ return self._bugtask
348+
349+ @property
350+ def is_complete(self):
351+ return self.actual_workitem.is_complete
352+
353+
354+def getWorkItemsDueBefore(team, cutoff_date, user):
355+ """Return a dict mapping dates to lists of WorkItemContainers.
356+
357+ This is a grouping, by milestone due date, of all work items
358+ (SpecificationWorkItems/BugTasks) assigned to any member of this
359+ team.
360+
361+ Only work items whose milestone have a due date between today and the
362+ given cut-off date are included in the results.
363+ """
364+ workitems = team.getAssignedSpecificationWorkItemsDueBefore(cutoff_date)
365+ # For every specification that has work items in the list above, create
366+ # one SpecWorkItemContainer holding the work items from that spec that are
367+ # targeted to the same milestone and assigned to members of the given team.
368+ containers_by_date = {}
369+ containers_by_spec = {}
370+ for workitem in workitems:
371+ spec = workitem.specification
372+ milestone = workitem.milestone
373+ if milestone is None:
374+ milestone = spec.milestone
375+ if milestone.dateexpected not in containers_by_date:
376+ containers_by_date[milestone.dateexpected] = []
377+ container = containers_by_spec.get(spec)
378+ if container is None:
379+ container = SpecWorkItemContainer(spec)
380+ containers_by_spec[spec] = container
381+ containers_by_date[milestone.dateexpected].append(container)
382+ container.append(GenericWorkItem.from_workitem(workitem))
383+
384+ # Sort our containers by priority.
385+ for date in containers_by_date:
386+ containers_by_date[date].sort(
387+ key=attrgetter('priority'), reverse=True)
388+
389+ bugtasks = team.getAssignedBugTasksDueBefore(cutoff_date, user)
390+ bug_containers_by_date = {}
391+ # For every milestone due date, create an AggregatedBugsContainer with all
392+ # the bugtasks targeted to a milestone on that date and assigned to
393+ # members of this team.
394+ for task in bugtasks:
395+ dateexpected = task.milestone.dateexpected
396+ container = bug_containers_by_date.get(dateexpected)
397+ if container is None:
398+ container = AggregatedBugsContainer()
399+ bug_containers_by_date[dateexpected] = container
400+ # Also append our new container to the dictionary we're going
401+ # to return.
402+ if dateexpected not in containers_by_date:
403+ containers_by_date[dateexpected] = []
404+ containers_by_date[dateexpected].append(container)
405+ container.append(GenericWorkItem.from_bugtask(task))
406+
407+ return containers_by_date
408
409=== added file 'lib/lp/registry/browser/tests/test_team_upcomingwork.py'
410--- lib/lp/registry/browser/tests/test_team_upcomingwork.py 1970-01-01 00:00:00 +0000
411+++ lib/lp/registry/browser/tests/test_team_upcomingwork.py 2012-04-04 08:07:25 +0000
412@@ -0,0 +1,288 @@
413+# Copyright 2012 Canonical Ltd. This software is licensed under the
414+# GNU Affero General Public License version 3 (see the file LICENSE).
415+
416+__metaclass__ = type
417+
418+from datetime import (
419+ datetime,
420+ timedelta,
421+ )
422+from operator import attrgetter
423+
424+from zope.security.proxy import removeSecurityProxy
425+
426+from lp.registry.browser.team import (
427+ GenericWorkItem,
428+ getWorkItemsDueBefore,
429+ WorkItemContainer,
430+ )
431+from lp.testing import (
432+ anonymous_logged_in,
433+ BrowserTestCase,
434+ TestCase,
435+ TestCaseWithFactory,
436+ )
437+from lp.testing.layers import DatabaseFunctionalLayer
438+from lp.testing.pages import (
439+ extract_text,
440+ find_tags_by_class,
441+ )
442+from lp.testing.views import create_initialized_view
443+
444+
445+class Test_getWorkItemsDueBefore(TestCaseWithFactory):
446+
447+ layer = DatabaseFunctionalLayer
448+
449+ def setUp(self):
450+ super(Test_getWorkItemsDueBefore, self).setUp()
451+ self.today = datetime.today().date()
452+ current_milestone = self.factory.makeMilestone(
453+ dateexpected=self.today)
454+ self.current_milestone = current_milestone
455+ self.future_milestone = self.factory.makeMilestone(
456+ product=current_milestone.product,
457+ dateexpected=datetime(2060, 1, 1))
458+ self.team = self.factory.makeTeam()
459+
460+ def test_basic(self):
461+ spec = self.factory.makeSpecification(
462+ product=self.current_milestone.product,
463+ assignee=self.team.teamowner, milestone=self.current_milestone)
464+ workitem = self.factory.makeSpecificationWorkItem(
465+ title=u'workitem 1', specification=spec)
466+ bugtask = self.factory.makeBug(
467+ milestone=self.current_milestone).bugtasks[0]
468+ removeSecurityProxy(bugtask).assignee = self.team.teamowner
469+
470+ workitems = getWorkItemsDueBefore(
471+ self.team, self.current_milestone.dateexpected, user=None)
472+
473+ self.assertEqual(
474+ [self.current_milestone.dateexpected], workitems.keys())
475+ containers = workitems[self.current_milestone.dateexpected]
476+ # We have one container for the work item from the spec and another
477+ # one for the bugtask.
478+ self.assertEqual(2, len(containers))
479+ [workitem_container, bugtask_container] = containers
480+
481+ self.assertEqual(1, len(bugtask_container.items))
482+ self.assertEqual(bugtask, bugtask_container.items[0].actual_workitem)
483+
484+ self.assertEqual(1, len(workitem_container.items))
485+ self.assertEqual(
486+ workitem, workitem_container.items[0].actual_workitem)
487+
488+ def test_foreign_container(self):
489+ # This spec is targeted to a person who's not a member of our team, so
490+ # only those workitems that are explicitly assigned to a member of our
491+ # team will be returned.
492+ spec = self.factory.makeSpecification(
493+ product=self.current_milestone.product,
494+ milestone=self.current_milestone,
495+ assignee=self.factory.makePerson())
496+ self.factory.makeSpecificationWorkItem(
497+ title=u'workitem 1', specification=spec)
498+ workitem = self.factory.makeSpecificationWorkItem(
499+ title=u'workitem 2', specification=spec,
500+ assignee=self.team.teamowner)
501+
502+ workitems = getWorkItemsDueBefore(
503+ self.team, self.current_milestone.dateexpected, user=None)
504+
505+ self.assertEqual(
506+ [self.current_milestone.dateexpected], workitems.keys())
507+ containers = workitems[self.current_milestone.dateexpected]
508+ self.assertEqual(1, len(containers))
509+ [container] = containers
510+ self.assertEqual(1, len(container.items))
511+ self.assertEqual(workitem, container.items[0].actual_workitem)
512+
513+ def test_future_container(self):
514+ spec = self.factory.makeSpecification(
515+ product=self.current_milestone.product,
516+ assignee=self.team.teamowner)
517+ # This workitem is targeted to a future milestone so it won't be in
518+ # our results below.
519+ self.factory.makeSpecificationWorkItem(
520+ title=u'workitem 1', specification=spec,
521+ milestone=self.future_milestone)
522+ current_wi = self.factory.makeSpecificationWorkItem(
523+ title=u'workitem 2', specification=spec,
524+ milestone=self.current_milestone)
525+
526+ workitems = getWorkItemsDueBefore(
527+ self.team, self.current_milestone.dateexpected, user=None)
528+
529+ self.assertEqual(
530+ [self.current_milestone.dateexpected], workitems.keys())
531+ containers = workitems[self.current_milestone.dateexpected]
532+ self.assertEqual(1, len(containers))
533+ [container] = containers
534+ self.assertEqual(1, len(container.items))
535+ self.assertEqual(current_wi, container.items[0].actual_workitem)
536+
537+
538+class TestGenericWorkItem(TestCaseWithFactory):
539+
540+ layer = DatabaseFunctionalLayer
541+
542+ def setUp(self):
543+ super(TestGenericWorkItem, self).setUp()
544+ today = datetime.today().date()
545+ self.milestone = self.factory.makeMilestone(dateexpected=today)
546+
547+ def test_from_bugtask(self):
548+ bugtask = self.factory.makeBug(milestone=self.milestone).bugtasks[0]
549+ workitem = GenericWorkItem.from_bugtask(bugtask)
550+ self.assertEqual(workitem.assignee, bugtask.assignee)
551+ self.assertEqual(workitem.status, bugtask.status)
552+ self.assertEqual(workitem.priority, bugtask.importance)
553+ self.assertEqual(workitem.target, bugtask.target)
554+ self.assertEqual(workitem.title, bugtask.bug.description)
555+ self.assertEqual(workitem.actual_workitem, bugtask)
556+
557+ def test_from_workitem(self):
558+ workitem = self.factory.makeSpecificationWorkItem(
559+ milestone=self.milestone)
560+ generic_wi = GenericWorkItem.from_workitem(workitem)
561+ self.assertEqual(generic_wi.assignee, workitem.assignee)
562+ self.assertEqual(generic_wi.status, workitem.status)
563+ self.assertEqual(generic_wi.priority, workitem.specification.priority)
564+ self.assertEqual(generic_wi.target, workitem.specification.target)
565+ self.assertEqual(generic_wi.title, workitem.title)
566+ self.assertEqual(generic_wi.actual_workitem, workitem)
567+
568+
569+class TestWorkItemContainer(TestCase):
570+
571+ class MockWorkItem:
572+
573+ def __init__(self, is_complete):
574+ self.is_complete = is_complete
575+
576+ def test_progress_text(self):
577+ container = WorkItemContainer()
578+ container.append(self.MockWorkItem(True))
579+ container.append(self.MockWorkItem(False))
580+ container.append(self.MockWorkItem(True))
581+ self.assertEqual('67%', container.progress_text)
582+
583+
584+class TestTeamUpcomingWork(BrowserTestCase):
585+
586+ layer = DatabaseFunctionalLayer
587+
588+ def setUp(self):
589+ super(TestTeamUpcomingWork, self).setUp()
590+ self.today = datetime.today().date()
591+ self.tomorrow = self.today + timedelta(days=1)
592+ self.today_milestone = self.factory.makeMilestone(
593+ dateexpected=self.today)
594+ self.tomorrow_milestone = self.factory.makeMilestone(
595+ dateexpected=self.tomorrow)
596+ self.team = self.factory.makeTeam()
597+
598+ def test_basic(self):
599+ workitem1 = self.factory.makeSpecificationWorkItem(
600+ assignee=self.team.teamowner, milestone=self.today_milestone)
601+ workitem2 = self.factory.makeSpecificationWorkItem(
602+ assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
603+ bugtask1 = self.factory.makeBug(
604+ milestone=self.today_milestone).bugtasks[0]
605+ bugtask2 = self.factory.makeBug(
606+ milestone=self.tomorrow_milestone).bugtasks[0]
607+ for bugtask in [bugtask1, bugtask2]:
608+ removeSecurityProxy(bugtask).assignee = self.team.teamowner
609+
610+ browser = self.getViewBrowser(
611+ self.team, view_name='+upcomingwork', no_login=True)
612+
613+ groups = find_tags_by_class(browser.contents, 'workitems-group')
614+ self.assertEqual(2, len(groups))
615+ todays_group = extract_text(groups[0])
616+ tomorrows_group = extract_text(groups[1])
617+ self.assertStartsWith(
618+ todays_group, 'Work items due in %s' % self.today)
619+ self.assertIn(workitem1.title, todays_group)
620+ with anonymous_logged_in():
621+ self.assertIn(bugtask1.bug.title, todays_group)
622+
623+ self.assertStartsWith(
624+ tomorrows_group, 'Work items due in %s' % self.tomorrow)
625+ self.assertIn(workitem2.title, tomorrows_group)
626+ with anonymous_logged_in():
627+ self.assertIn(bugtask2.bug.title, tomorrows_group)
628+
629+
630+class TestTeamUpcomingWorkView(TestCaseWithFactory):
631+
632+ layer = DatabaseFunctionalLayer
633+
634+ def setUp(self):
635+ super(TestTeamUpcomingWorkView, self).setUp()
636+ self.today = datetime.today().date()
637+ self.tomorrow = self.today + timedelta(days=1)
638+ self.today_milestone = self.factory.makeMilestone(
639+ dateexpected=self.today)
640+ self.tomorrow_milestone = self.factory.makeMilestone(
641+ dateexpected=self.tomorrow)
642+ self.team = self.factory.makeTeam()
643+
644+ def test_workitem_counts(self):
645+ self.factory.makeSpecificationWorkItem(
646+ assignee=self.team.teamowner, milestone=self.today_milestone)
647+ self.factory.makeSpecificationWorkItem(
648+ assignee=self.team.teamowner, milestone=self.today_milestone)
649+ self.factory.makeSpecificationWorkItem(
650+ assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
651+
652+ view = create_initialized_view(self.team, '+upcomingwork')
653+ self.assertEqual(2, view.workitem_counts[self.today])
654+ self.assertEqual(1, view.workitem_counts[self.tomorrow])
655+
656+ def test_bugtask_counts(self):
657+ bugtask1 = self.factory.makeBug(
658+ milestone=self.today_milestone).bugtasks[0]
659+ bugtask2 = self.factory.makeBug(
660+ milestone=self.tomorrow_milestone).bugtasks[0]
661+ bugtask3 = self.factory.makeBug(
662+ milestone=self.tomorrow_milestone).bugtasks[0]
663+ for bugtask in [bugtask1, bugtask2, bugtask3]:
664+ removeSecurityProxy(bugtask).assignee = self.team.teamowner
665+
666+ view = create_initialized_view(self.team, '+upcomingwork')
667+ self.assertEqual(1, view.bugtask_counts[self.today])
668+ self.assertEqual(2, view.bugtask_counts[self.tomorrow])
669+
670+ def test_milestones_per_date(self):
671+ another_milestone_due_today = self.factory.makeMilestone(
672+ dateexpected=self.today)
673+ self.factory.makeSpecificationWorkItem(
674+ assignee=self.team.teamowner, milestone=self.today_milestone)
675+ self.factory.makeSpecificationWorkItem(
676+ assignee=self.team.teamowner,
677+ milestone=another_milestone_due_today)
678+ self.factory.makeSpecificationWorkItem(
679+ assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
680+
681+ view = create_initialized_view(self.team, '+upcomingwork')
682+ self.assertEqual(
683+ sorted([self.today_milestone, another_milestone_due_today],
684+ key=attrgetter('displayname')),
685+ view.milestones_per_date[self.today])
686+ self.assertEqual(
687+ [self.tomorrow_milestone],
688+ view.milestones_per_date[self.tomorrow])
689+
690+ def test_work_item_containers_are_sorted_by_date(self):
691+ self.factory.makeSpecificationWorkItem(
692+ assignee=self.team.teamowner, milestone=self.today_milestone)
693+ self.factory.makeSpecificationWorkItem(
694+ assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
695+
696+ view = create_initialized_view(self.team, '+upcomingwork')
697+ self.assertEqual(2, len(view.work_item_containers))
698+ self.assertEqual(
699+ [self.today, self.tomorrow],
700+ [date for date, containers in view.work_item_containers])
701
702=== modified file 'lib/lp/registry/model/person.py'
703--- lib/lp/registry/model/person.py 2012-03-29 06:02:46 +0000
704+++ lib/lp/registry/model/person.py 2012-04-04 08:07:25 +0000
705@@ -128,11 +128,11 @@
706 SpecificationImplementationStatus,
707 SpecificationSort,
708 )
709-from lp.blueprints.model.specificationworkitem import SpecificationWorkItem
710 from lp.blueprints.model.specification import (
711 HasSpecificationsMixin,
712 Specification,
713 )
714+from lp.blueprints.model.specificationworkitem import SpecificationWorkItem
715 from lp.bugs.interfaces.bugtarget import IBugTarget
716 from lp.bugs.interfaces.bugtask import (
717 BugTaskSearchParams,
718@@ -1535,7 +1535,7 @@
719 milestone_dateexpected_after=today)
720
721 # Cast to a list to avoid DecoratedResultSet running pre_iter_hook
722- # multiple times when load_related() iterates over through the tasks.
723+ # multiple times when load_related() iterates over the tasks.
724 tasks = list(getUtility(IBugTaskSet).search(search_params))
725 # Eager load the things we need that are not already eager loaded by
726 # BugTaskSet.search().
727
728=== modified file 'lib/lp/registry/templates/team-index.pt'
729--- lib/lp/registry/templates/team-index.pt 2011-01-04 16:08:57 +0000
730+++ lib/lp/registry/templates/team-index.pt 2012-04-04 08:07:25 +0000
731@@ -64,6 +64,13 @@
732 Related software and packages
733 </a>
734 </li>
735+ <li
736+ tal:define="link context/menu:overview/upcomingwork"
737+ tal:condition="link/enabled">
738+ <a class="sprite info" tal:attributes="href link/fmt:url">
739+ Upcoming work assigned to members of this team
740+ </a>
741+ </li>
742 </ul>
743
744 <div class="yui-g">
745
746=== added file 'lib/lp/registry/templates/team-upcomingwork.pt'
747--- lib/lp/registry/templates/team-upcomingwork.pt 1970-01-01 00:00:00 +0000
748+++ lib/lp/registry/templates/team-upcomingwork.pt 2012-04-04 08:07:25 +0000
749@@ -0,0 +1,105 @@
750+<html
751+ xmlns="http://www.w3.org/1999/xhtml"
752+ xmlns:tal="http://xml.zope.org/namespaces/tal"
753+ xmlns:metal="http://xml.zope.org/namespaces/metal"
754+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
755+ metal:use-macro="view/macro:page/main_only"
756+ i18n:domain="launchpad">
757+<body>
758+
759+<head>
760+ <tal:block metal:fill-slot="head_epilogue">
761+ <script type="text/javascript">
762+ LPJS.use('node', 'event', 'lp.app.widgets.expander', function(Y) {
763+ Y.on('domready', function() {
764+ Y.all('[class=expandable]').each(function(e) {
765+ var expander_icon = e.one('[class=expander]');
766+ // Our parent's first sibling is the tbody we want to collapse.
767+ var widget_body = e.ancestor().next();
768+ var expander = new Y.lp.app.widgets.expander.Expander(
769+ expander_icon, widget_body);
770+ expander.setUp(true);
771+ })
772+ })
773+ });
774+ </script>
775+ <!-- TODO: Once this page is done and no longer guarded with a feature
776+ flag, move this to the appropriate css files. -->
777+ <style type="text/css">
778+ .collapsible-body {
779+ background-color: #eee;
780+ }
781+ tr.padded td {
782+ padding-left: 2em;
783+ }
784+ </style>
785+ </tal:block>
786+</head>
787+
788+<div metal:fill-slot="main">
789+
790+ <div tal:repeat="pair view/work_item_containers" class="workitems-group">
791+ <div tal:define="date python: pair[0]; containers python: pair[1]">
792+ <h2>Work items due in <span tal:replace="date/fmt:date" /></h2>
793+ <p>
794+ From
795+ <tal:milestones repeat="milestone python: view.milestones_per_date[date]">
796+ <a tal:replace="structure milestone/fmt:link"
797+ /><span tal:condition="not: repeat/milestone/end">,</span>
798+ </tal:milestones>
799+ </p>
800+
801+ <p>
802+ There are <span tal:replace="python: view.workitem_counts[date]" />
803+ Blueprint work items and
804+ <span tal:replace="python: view.bugtask_counts[date]" /> Bugs due
805+ in <span tal:content="date/fmt:date" /> which are assigned to members
806+ of this team.
807+ </p>
808+
809+ <table class="listing">
810+ <thead>
811+ <tr>
812+ <th>Blueprint</th>
813+ <th>Target</th>
814+ <th>Assignee</th>
815+ <th>Priority</th>
816+ <th>Progress</th>
817+ </tr>
818+ </thead>
819+ <tal:containers repeat="container containers">
820+ <tbody>
821+ <tr class="expandable">
822+ <td>
823+ <a href="#" class="expander">&nbsp;</a>
824+ <span tal:replace="structure container/html_link" />
825+ </td>
826+ <td tal:content="structure container/target_link" />
827+ <td tal:content="structure container/assignee_link" />
828+ <td tal:content="container/priority_title" />
829+ <td><span tal:replace="container/progress_text" /> done</td>
830+ </tr>
831+ </tbody>
832+ <tbody class="collapsible-body">
833+ <tr tal:repeat="workitem container/items" class="padded">
834+ <td tal:content="structure workitem/display_title" />
835+ <td>
836+ <span tal:condition="not: container/spec|nothing"
837+ tal:replace="structure workitem/target/fmt:link" />
838+ </td>
839+ <td><a tal:replace="structure workitem/assignee/fmt:link" /></td>
840+ <td>
841+ <span tal:condition="not: container/spec|nothing"
842+ tal:replace="workitem/priority/title" />
843+ </td>
844+ <td><span tal:replace="workitem/status/title" /></td>
845+ </tr>
846+ </tbody>
847+ </tal:containers>
848+ </table>
849+ </div>
850+ </div>
851+</div>
852+
853+</body>
854+</html>
855
856=== modified file 'lib/lp/services/features/flags.py'
857--- lib/lp/services/features/flags.py 2012-04-04 05:49:36 +0000
858+++ lib/lp/services/features/flags.py 2012-04-04 08:07:25 +0000
859@@ -315,6 +315,12 @@
860 '',
861 '',
862 ''),
863+ ('registry.upcoming_work_view.enabled',
864+ 'boolean',
865+ ('If true, the new upcoming work view of teams is available.'),
866+ '',
867+ '',
868+ ''),
869 ])
870
871 # The set of all flag names that are documented.
872
873=== modified file 'lib/lp/testing/factory.py'
874--- lib/lp/testing/factory.py 2012-04-04 05:46:26 +0000
875+++ lib/lp/testing/factory.py 2012-04-04 08:07:25 +0000
876@@ -2134,7 +2134,13 @@
877 if title is None:
878 title = self.getUniqueString(u'title')
879 if specification is None:
880- specification = self.makeSpecification()
881+ product = None
882+ distribution = None
883+ if milestone is not None:
884+ product = milestone.product
885+ distribution = milestone.distribution
886+ specification = self.makeSpecification(
887+ product=product, distribution=distribution)
888 if sequence is None:
889 sequence = self.getUniqueInteger()
890 work_item = removeSecurityProxy(specification).newWorkItem(