← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Guilherme Salgado has proposed merging lp:~linaro-infrastructure/launchpad/team-engineering-view-ui into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~linaro-infrastructure/launchpad/team-engineering-view-ui/+merge/100707

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.
-- 
https://code.launchpad.net/~linaro-infrastructure/launchpad/team-engineering-view-ui/+merge/100707
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~linaro-infrastructure/launchpad/team-engineering-view-ui into lp:launchpad.
=== modified file 'lib/lp/blueprints/interfaces/specificationworkitem.py'
--- lib/lp/blueprints/interfaces/specificationworkitem.py	2012-02-07 01:53:23 +0000
+++ lib/lp/blueprints/interfaces/specificationworkitem.py	2012-04-03 22:19:27 +0000
@@ -74,3 +74,9 @@
         required=True, description=_(
             "The sequence in which the work items are to be displayed in the "
             "UI."))
+
+    is_complete = Bool(
+        readonly=True,
+        description=_(
+            "True or False depending on whether or not there is more "
+            "work required on this work item."))

=== modified file 'lib/lp/blueprints/model/specificationworkitem.py'
--- lib/lp/blueprints/model/specificationworkitem.py	2012-03-09 19:27:35 +0000
+++ lib/lp/blueprints/model/specificationworkitem.py	2012-04-03 22:19:27 +0000
@@ -59,3 +59,8 @@
         self.assignee=assignee
         self.milestone=milestone
         self.sequence=sequence
+
+    @property
+    def is_complete(self):
+        """See `ISpecificationWorkItem`."""
+        return self.status == SpecificationWorkItemStatus.DONE

=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml	2012-03-31 11:32:15 +0000
+++ lib/lp/registry/browser/configure.zcml	2012-04-03 22:19:27 +0000
@@ -1084,6 +1084,12 @@
             name="+mugshots"
             template="../templates/team-mugshots.pt"
             class="lp.registry.browser.team.TeamMugshotView"/>
+      <browser:page
+            for="lp.registry.interfaces.person.ITeam"
+            class="lp.registry.browser.team.TeamUpcomingWorkView"
+            permission="zope.Public"
+            name="+upcomingwork"
+            template="../templates/team-upcomingwork.pt"/>
         <browser:page
             for="lp.registry.interfaces.person.ITeam"
             class="lp.registry.browser.team.TeamIndexView"

=== modified file 'lib/lp/registry/browser/team.py'
--- lib/lp/registry/browser/team.py	2012-03-14 21:05:57 +0000
+++ lib/lp/registry/browser/team.py	2012-04-03 22:19:27 +0000
@@ -28,6 +28,7 @@
     'TeamOverviewNavigationMenu',
     'TeamPrivacyAdapter',
     'TeamReassignmentView',
+    'TeamUpcomingWorkView',
     ]
 
 
@@ -37,6 +38,10 @@
     timedelta,
     )
 import math
+from operator import (
+    attrgetter,
+    itemgetter,
+    )
 from urllib import unquote
 
 from lazr.restful.interface import copy_field
@@ -78,7 +83,11 @@
     custom_widget,
     LaunchpadFormView,
     )
-from lp.app.browser.tales import PersonFormatterAPI
+from lp.app.browser.stringformatter import FormattersAPI
+from lp.app.browser.tales import (
+    format_link,
+    PersonFormatterAPI,
+    )
 from lp.app.errors import UnexpectedFormData
 from lp.app.validators import LaunchpadValidationError
 from lp.app.validators.validation import validate_new_team_email
@@ -89,6 +98,7 @@
     )
 from lp.app.widgets.owner import HiddenUserWidget
 from lp.app.widgets.popup import PersonPickerWidget
+from lp.blueprints.enums import SpecificationWorkItemStatus
 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.registry.browser.branding import BrandingChangeView
 from lp.registry.browser.mailinglists import enabled_with_active_mailing_list
@@ -142,6 +152,7 @@
     )
 from lp.security import ModerateByRegistryExpertsOrAdmins
 from lp.services.config import config
+from lp.services.features import getFeatureFlag
 from lp.services.fields import PublicPersonChoice
 from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
 from lp.services.privacy.interfaces import IObjectPrivacy
@@ -1589,6 +1600,14 @@
         icon = 'add'
         return Link(target, text, icon=icon, enabled=enabled)
 
+    def upcomingwork(self):
+        target = '+upcomingwork'
+        text = 'Upcoming work for this team'
+        enabled = False
+        if getFeatureFlag('registry.upcoming_work_view.enabled'):
+            enabled = True
+        return Link(target, text, icon='team', enabled=enabled)
+
 
 class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin):
 
@@ -1622,6 +1641,7 @@
         'view_recipes',
         'subscriptions',
         'structural_subscriptions',
+        'upcomingwork',
         ]
 
 
@@ -2089,6 +2109,10 @@
     """A marker interface for the edit navigation menu."""
 
 
+classImplements(TeamIndexView, ITeamIndexMenu)
+classImplements(TeamEditView, ITeamEditMenu)
+
+
 class TeamNavigationMenuBase(NavigationMenu, TeamMenuMixin):
 
     @property
@@ -2135,5 +2159,275 @@
         return batch_nav
 
 
-classImplements(TeamIndexView, ITeamIndexMenu)
-classImplements(TeamEditView, ITeamEditMenu)
+class TeamUpcomingWorkView(LaunchpadView):
+    """This view displays work items and bugtasks that are due within 60 days
+    and are assigned to members of a team.
+    """
+
+    # We'll show bugs and work items targeted to milestones with a due date up
+    # to DAYS from now.
+    DAYS = 180
+
+    def initialize(self):
+        super(TeamUpcomingWorkView, self).initialize()
+        self.workitem_counts = {}
+        self.bugtask_counts = {}
+        self.milestones_per_date = {}
+        for date, containers in self.work_item_containers:
+            milestones = set()
+            self.bugtask_counts[date] = 0
+            self.workitem_counts[date] = 0
+            for container in containers:
+                if isinstance(container, AggregatedBugsContainer):
+                    self.bugtask_counts[date] += len(container.items)
+                else:
+                    self.workitem_counts[date] += len(container.items)
+                for item in container.items:
+                    milestones.add(item.milestone)
+            self.milestones_per_date[date] = sorted(
+                milestones, key=attrgetter('displayname'))
+
+    @property
+    def label(self):
+        return self.page_title
+
+    @property
+    def page_title(self):
+        return "Upcoming work for %s" % self.context.displayname
+
+    @cachedproperty
+    def work_item_containers(self):
+        cutoff_date = datetime.today().date() + timedelta(days=self.DAYS)
+        result = getWorkItemsDueBefore(self.context, cutoff_date, self.user)
+        return sorted(result.items(), key=itemgetter(0))
+
+
+class WorkItemContainer:
+    """A container of work items, assigned to members of a team, whose
+    milestone is due on a certain date.
+    """
+
+    def __init__(self):
+        self._items = []
+
+    @property
+    def html_link(self):
+        raise NotImplementedError("Must be implemented in subclasses")
+
+    @property
+    def priority_title(self):
+        raise NotImplementedError("Must be implemented in subclasses")
+
+    @property
+    def target_link(self):
+        raise NotImplementedError("Must be implemented in subclasses")
+
+    @property
+    def assignee_link(self):
+        raise NotImplementedError("Must be implemented in subclasses")
+
+    @property
+    def items(self):
+        raise NotImplementedError("Must be implemented in subclasses")
+
+    @property
+    def progress_text(self):
+        done_items = [item for item in self._items if item.is_complete]
+        return '{0:.0f}%'.format(100.0 * len(done_items) / len(self._items))
+
+    def append(self, item):
+        self._items.append(item)
+
+
+class SpecWorkItemContainer(WorkItemContainer):
+    """A container of SpecificationWorkItems wrapped with GenericWorkItem."""
+
+    def __init__(self, spec):
+        super(SpecWorkItemContainer, self).__init__()
+        self.spec = spec
+        self.priority = spec.priority
+        self.target = spec.target
+        self.assignee = spec.assignee
+
+    @property
+    def html_link(self):
+        return format_link(self.spec)
+
+    @property
+    def priority_title(self):
+        return self.priority.title
+
+    @property
+    def target_link(self):
+        return format_link(self.target)
+
+    @property
+    def assignee_link(self):
+        if self.assignee is None:
+            return 'Nobody'
+        return format_link(self.assignee)
+
+    @property
+    def items(self):
+        # Sort the work items by status only because they all have the same
+        # priority.
+        def sort_key(item):
+            status_order = {
+                SpecificationWorkItemStatus.POSTPONED: 5,
+                SpecificationWorkItemStatus.DONE: 4,
+                SpecificationWorkItemStatus.INPROGRESS: 3,
+                SpecificationWorkItemStatus.TODO: 2,
+                SpecificationWorkItemStatus.BLOCKED: 1,
+                }
+            return status_order[item.status]
+        return sorted(self._items, key=sort_key)
+
+
+class AggregatedBugsContainer(WorkItemContainer):
+    """A container of BugTasks wrapped with GenericWorkItem."""
+
+    @property
+    def html_link(self):
+        return 'Bugs targeted to a milestone on this date'
+
+    @property
+    def assignee_link(self):
+        return 'N/A'
+
+    @property
+    def target_link(self):
+        return 'N/A'
+
+    @property
+    def priority_title(self):
+        return 'N/A'
+
+    @property
+    def items(self):
+        def sort_key(item):
+            return (item.status.value, item.priority.value)
+        # Sort by (status, priority) in reverse order because the biggest the
+        # status/priority the more interesting it is to us.
+        return sorted(self._items, key=sort_key, reverse=True)
+
+
+class GenericWorkItem:
+    """A generic piece of work; either a BugTask or a SpecificationWorkItem.
+
+    This class wraps a BugTask or a SpecificationWorkItem to provide a
+    common API so that the template doesn't have to worry about what kind of
+    work item it's dealing with.
+    """
+
+    def __init__(self, assignee, status, priority, target, title,
+                 bugtask=None, work_item=None):
+        self.assignee = assignee
+        self.status = status
+        self.priority = priority
+        self.target = target
+        self.title = title
+        self._bugtask = bugtask
+        self._work_item = work_item
+
+    @classmethod
+    def from_bugtask(cls, bugtask):
+        return cls(
+            bugtask.assignee, bugtask.status, bugtask.importance,
+            bugtask.target, bugtask.bug.description, bugtask=bugtask)
+
+    @classmethod
+    def from_workitem(cls, work_item):
+        assignee = work_item.assignee
+        if assignee is None:
+            assignee = work_item.specification.assignee
+        return cls(
+            assignee, work_item.status, work_item.specification.priority,
+            work_item.specification.target, work_item.title,
+            work_item=work_item)
+
+    @property
+    def display_title(self):
+        if self._work_item is not None:
+            return FormattersAPI(self.title).shorten(120)
+        else:
+            return format_link(self._bugtask)
+
+    @property
+    def milestone(self):
+        milestone = self.actual_workitem.milestone
+        if milestone is None:
+            assert self._work_item is not None, (
+                "BugTaks without a milestone must not be here.")
+            milestone = self._work_item.specification.milestone
+        return milestone
+
+    @property
+    def actual_workitem(self):
+        """Return the actual work item that we are wrapping.
+
+        This may be either an IBugTask or an ISpecificationWorkItem.
+        """
+        if self._work_item is not None:
+            return self._work_item
+        else:
+            return self._bugtask
+
+    @property
+    def is_complete(self):
+        return self.actual_workitem.is_complete
+
+
+def getWorkItemsDueBefore(team, cutoff_date, user):
+    """Return a dict mapping dates to lists of WorkItemContainers.
+
+    This is a grouping, by milestone due date, of all work items
+    (SpecificationWorkItems/BugTasks) assigned to any member of this
+    team.
+
+    Only work items whose milestone have a due date between today and the
+    given cut-off date are included in the results.
+    """
+    workitems = team.getAssignedSpecificationWorkItemsDueBefore(cutoff_date)
+    # For every specification that has work items in the list above, create
+    # one SpecWorkItemContainer holding the work items from that spec that are
+    # targeted to the same milestone and assigned to members of the given team.
+    containers_by_date = {}
+    containers_by_spec = {}
+    for workitem in workitems:
+        spec = workitem.specification
+        milestone = workitem.milestone
+        if milestone is None:
+            milestone = spec.milestone
+        if milestone.dateexpected not in containers_by_date:
+            containers_by_date[milestone.dateexpected] = []
+        container = containers_by_spec.get(spec)
+        if container is None:
+            container = SpecWorkItemContainer(spec)
+            containers_by_spec[spec] = container
+            containers_by_date[milestone.dateexpected].append(container)
+        container.append(GenericWorkItem.from_workitem(workitem))
+
+    # Sort our containers by priority.
+    for date in containers_by_date:
+        containers_by_date[date].sort(
+            key=attrgetter('priority'), reverse=True)
+
+    bugtasks = team.getAssignedBugTasksDueBefore(cutoff_date, user)
+    bug_containers_by_date = {}
+    # For every milestone due date, create an AggregatedBugsContainer with all
+    # the bugtasks targeted to a milestone on that date and assigned to
+    # members of this team.
+    for task in bugtasks:
+        dateexpected = task.milestone.dateexpected
+        container = bug_containers_by_date.get(dateexpected)
+        if container is None:
+            container = AggregatedBugsContainer()
+            bug_containers_by_date[dateexpected] = container
+            # Also append our new container to the dictionary we're going
+            # to return.
+            if dateexpected not in containers_by_date:
+                containers_by_date[dateexpected] = []
+            containers_by_date[dateexpected].append(container)
+        container.append(GenericWorkItem.from_bugtask(task))
+
+    return containers_by_date

=== added file 'lib/lp/registry/browser/tests/test_team_upcomingwork.py'
--- lib/lp/registry/browser/tests/test_team_upcomingwork.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/tests/test_team_upcomingwork.py	2012-04-03 22:19:27 +0000
@@ -0,0 +1,289 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+from operator import attrgetter
+
+from zope.security.proxy import removeSecurityProxy
+
+from lp.registry.browser.team import (
+    GenericWorkItem,
+    getWorkItemsDueBefore,
+    WorkItemContainer,
+    )
+
+from lp.testing import (
+    anonymous_logged_in,
+    BrowserTestCase,
+    TestCase,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.pages import (
+    extract_text,
+    find_tags_by_class,
+    )
+from lp.testing.views import create_initialized_view
+
+
+class Test_getWorkItemsDueBefore(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(Test_getWorkItemsDueBefore, self).setUp()
+        self.today = datetime.today().date()
+        current_milestone = self.factory.makeMilestone(
+            dateexpected=self.today)
+        self.current_milestone = current_milestone
+        self.future_milestone = self.factory.makeMilestone(
+            product=current_milestone.product,
+            dateexpected=datetime(2060, 1, 1))
+        self.team = self.factory.makeTeam()
+
+    def test_basic(self):
+        spec = self.factory.makeSpecification(
+            product=self.current_milestone.product,
+            assignee=self.team.teamowner, milestone=self.current_milestone)
+        workitem = self.factory.makeSpecificationWorkItem(
+            title=u'workitem 1', specification=spec)
+        bugtask = self.factory.makeBug(
+            milestone=self.current_milestone).bugtasks[0]
+        removeSecurityProxy(bugtask).assignee = self.team.teamowner
+
+        workitems = getWorkItemsDueBefore(
+            self.team, self.current_milestone.dateexpected, user=None)
+
+        self.assertEqual(
+            [self.current_milestone.dateexpected], workitems.keys())
+        containers = workitems[self.current_milestone.dateexpected]
+        # We have one container for the work item from the spec and another
+        # one for the bugtask.
+        self.assertEqual(2, len(containers))
+        [workitem_container, bugtask_container] = containers
+
+        self.assertEqual(1, len(bugtask_container.items))
+        self.assertEqual(bugtask, bugtask_container.items[0].actual_workitem)
+
+        self.assertEqual(1, len(workitem_container.items))
+        self.assertEqual(
+            workitem, workitem_container.items[0].actual_workitem)
+
+    def test_foreign_container(self):
+        # This spec is targeted to a person who's not a member of our team, so
+        # only those workitems that are explicitly assigned to a member of our
+        # team will be returned.
+        spec = self.factory.makeSpecification(
+            product=self.current_milestone.product,
+            milestone=self.current_milestone,
+            assignee=self.factory.makePerson())
+        self.factory.makeSpecificationWorkItem(
+            title=u'workitem 1', specification=spec)
+        workitem = self.factory.makeSpecificationWorkItem(
+            title=u'workitem 2', specification=spec,
+            assignee=self.team.teamowner)
+
+        workitems = getWorkItemsDueBefore(
+            self.team, self.current_milestone.dateexpected, user=None)
+
+        self.assertEqual(
+            [self.current_milestone.dateexpected], workitems.keys())
+        containers = workitems[self.current_milestone.dateexpected]
+        self.assertEqual(1, len(containers))
+        [container] = containers
+        self.assertEqual(1, len(container.items))
+        self.assertEqual(workitem, container.items[0].actual_workitem)
+
+    def test_future_container(self):
+        spec = self.factory.makeSpecification(
+            product=self.current_milestone.product,
+            assignee=self.team.teamowner)
+        # This workitem is targeted to a future milestone so it won't be in
+        # our results below.
+        self.factory.makeSpecificationWorkItem(
+            title=u'workitem 1', specification=spec,
+            milestone=self.future_milestone)
+        current_wi = self.factory.makeSpecificationWorkItem(
+            title=u'workitem 2', specification=spec,
+            milestone=self.current_milestone)
+
+        workitems = getWorkItemsDueBefore(
+            self.team, self.current_milestone.dateexpected, user=None)
+
+        self.assertEqual(
+            [self.current_milestone.dateexpected], workitems.keys())
+        containers = workitems[self.current_milestone.dateexpected]
+        self.assertEqual(1, len(containers))
+        [container] = containers
+        self.assertEqual(1, len(container.items))
+        self.assertEqual(current_wi, container.items[0].actual_workitem)
+
+
+class TestGenericWorkItem(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGenericWorkItem, self).setUp()
+        today = datetime.today().date()
+        self.milestone = self.factory.makeMilestone(dateexpected=today)
+
+    def test_from_bugtask(self):
+        bugtask = self.factory.makeBug(milestone=self.milestone).bugtasks[0]
+        workitem = GenericWorkItem.from_bugtask(bugtask)
+        self.assertEqual(workitem.assignee, bugtask.assignee)
+        self.assertEqual(workitem.status, bugtask.status)
+        self.assertEqual(workitem.priority, bugtask.importance)
+        self.assertEqual(workitem.target, bugtask.target)
+        self.assertEqual(workitem.title, bugtask.bug.description)
+        self.assertEqual(workitem.actual_workitem, bugtask)
+
+    def test_from_workitem(self):
+        workitem = self.factory.makeSpecificationWorkItem(
+            milestone=self.milestone)
+        generic_wi = GenericWorkItem.from_workitem(workitem)
+        self.assertEqual(generic_wi.assignee, workitem.assignee)
+        self.assertEqual(generic_wi.status, workitem.status)
+        self.assertEqual(generic_wi.priority, workitem.specification.priority)
+        self.assertEqual(generic_wi.target, workitem.specification.target)
+        self.assertEqual(generic_wi.title, workitem.title)
+        self.assertEqual(generic_wi.actual_workitem, workitem)
+
+
+class TestWorkItemContainer(TestCase):
+
+    class MockWorkItem:
+
+        def __init__(self, is_complete):
+            self.is_complete = is_complete
+
+    def test_progress_text(self):
+        container = WorkItemContainer()
+        container.append(self.MockWorkItem(True))
+        container.append(self.MockWorkItem(False))
+        container.append(self.MockWorkItem(True))
+        self.assertEqual('67%', container.progress_text)
+
+
+class TestTeamUpcomingWork(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestTeamUpcomingWork, self).setUp()
+        self.today = datetime.today().date()
+        self.tomorrow = self.today + timedelta(days=1)
+        self.today_milestone = self.factory.makeMilestone(
+            dateexpected=self.today)
+        self.tomorrow_milestone = self.factory.makeMilestone(
+            dateexpected=self.tomorrow)
+        self.team = self.factory.makeTeam()
+
+    def test_basic(self):
+        workitem1 = self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.today_milestone)
+        workitem2 = self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
+        bugtask1 = self.factory.makeBug(
+            milestone=self.today_milestone).bugtasks[0]
+        bugtask2 = self.factory.makeBug(
+            milestone=self.tomorrow_milestone).bugtasks[0]
+        for bugtask in [bugtask1, bugtask2]:
+            removeSecurityProxy(bugtask).assignee = self.team.teamowner
+
+        browser = self.getViewBrowser(
+            self.team, view_name='+upcomingwork', no_login=True)
+
+        groups = find_tags_by_class(browser.contents, 'workitems-group')
+        self.assertEqual(2, len(groups))
+        todays_group = extract_text(groups[0])
+        tomorrows_group = extract_text(groups[1])
+        self.assertStartsWith(
+            todays_group, 'Work items due in %s' % self.today)
+        self.assertIn(workitem1.title, todays_group)
+        with anonymous_logged_in():
+            self.assertIn(bugtask1.bug.title, todays_group)
+
+        self.assertStartsWith(
+            tomorrows_group, 'Work items due in %s' % self.tomorrow)
+        self.assertIn(workitem2.title, tomorrows_group)
+        with anonymous_logged_in():
+            self.assertIn(bugtask2.bug.title, tomorrows_group)
+
+
+class TestTeamUpcomingWorkView(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestTeamUpcomingWorkView, self).setUp()
+        self.today = datetime.today().date()
+        self.tomorrow = self.today + timedelta(days=1)
+        self.today_milestone = self.factory.makeMilestone(
+            dateexpected=self.today)
+        self.tomorrow_milestone = self.factory.makeMilestone(
+            dateexpected=self.tomorrow)
+        self.team = self.factory.makeTeam()
+
+    def test_workitem_counts(self):
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.today_milestone)
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.today_milestone)
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
+
+        view = create_initialized_view(self.team, '+upcomingwork')
+        self.assertEqual(2, view.workitem_counts[self.today])
+        self.assertEqual(1, view.workitem_counts[self.tomorrow])
+
+    def test_bugtask_counts(self):
+        bugtask1 = self.factory.makeBug(
+            milestone=self.today_milestone).bugtasks[0]
+        bugtask2 = self.factory.makeBug(
+            milestone=self.tomorrow_milestone).bugtasks[0]
+        bugtask3 = self.factory.makeBug(
+            milestone=self.tomorrow_milestone).bugtasks[0]
+        for bugtask in [bugtask1, bugtask2, bugtask3]:
+            removeSecurityProxy(bugtask).assignee = self.team.teamowner
+
+        view = create_initialized_view(self.team, '+upcomingwork')
+        self.assertEqual(1, view.bugtask_counts[self.today])
+        self.assertEqual(2, view.bugtask_counts[self.tomorrow])
+
+    def test_milestones_per_date(self):
+        another_milestone_due_today = self.factory.makeMilestone(
+            dateexpected=self.today)
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.today_milestone)
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner,
+            milestone=another_milestone_due_today)
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
+
+        view = create_initialized_view(self.team, '+upcomingwork')
+        self.assertEqual(
+            sorted([self.today_milestone, another_milestone_due_today],
+                   key=attrgetter('displayname')),
+            view.milestones_per_date[self.today])
+        self.assertEqual(
+            [self.tomorrow_milestone],
+            view.milestones_per_date[self.tomorrow])
+
+    def test_work_item_containers_are_sorted_by_date(self):
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.today_milestone)
+        self.factory.makeSpecificationWorkItem(
+            assignee=self.team.teamowner, milestone=self.tomorrow_milestone)
+
+        view = create_initialized_view(self.team, '+upcomingwork')
+        self.assertEqual(2, len(view.work_item_containers))
+        self.assertEqual(
+            [self.today, self.tomorrow],
+            [date for date, containers in view.work_item_containers])

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2012-03-29 06:02:46 +0000
+++ lib/lp/registry/model/person.py	2012-04-03 22:19:27 +0000
@@ -1535,7 +1535,7 @@
             milestone_dateexpected_after=today)
 
         # Cast to a list to avoid DecoratedResultSet running pre_iter_hook
-        # multiple times when load_related() iterates over through the tasks.
+        # multiple times when load_related() iterates over the tasks.
         tasks = list(getUtility(IBugTaskSet).search(search_params))
         # Eager load the things we need that are not already eager loaded by
         # BugTaskSet.search().

=== modified file 'lib/lp/registry/templates/team-index.pt'
--- lib/lp/registry/templates/team-index.pt	2011-01-04 16:08:57 +0000
+++ lib/lp/registry/templates/team-index.pt	2012-04-03 22:19:27 +0000
@@ -64,6 +64,13 @@
         Related software and packages
       </a>
     </li>
+    <li
+      tal:define="link context/menu:overview/upcomingwork"
+      tal:condition="link/enabled">
+      <a class="sprite info" tal:attributes="href link/fmt:url">
+        Upcoming work assigned to members of this team
+      </a>
+    </li>
   </ul>
 
   <div class="yui-g">

=== added file 'lib/lp/registry/templates/team-upcomingwork.pt'
--- lib/lp/registry/templates/team-upcomingwork.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/team-upcomingwork.pt	2012-04-03 22:19:27 +0000
@@ -0,0 +1,105 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+<body>
+
+<head>
+  <tal:block metal:fill-slot="head_epilogue">
+    <script type="text/javascript">
+      LPJS.use('node', 'event', 'lp.app.widgets.expander', function(Y) {
+          Y.on('domready', function() {
+            Y.all('[class=expandable]').each(function(e) {
+              var expander_icon = e.one('[class=expander]');
+              // Our parent's first sibling is the tbody we want to collapse.
+              var widget_body = e.ancestor().next();
+              var expander = new Y.lp.app.widgets.expander.Expander(
+                  expander_icon, widget_body);
+              expander.setUp(true);
+            })
+          })
+        });
+    </script>
+    <!-- TODO: Once this page is done and no longer guarded with a feature
+    flag, move this to the appropriate css files. -->
+    <style type="text/css">
+      .collapsible-body {
+        background-color: #eee;
+      }
+      tr.padded td {
+        padding-left: 2em;
+      }
+    </style>
+  </tal:block>
+</head>
+
+<div metal:fill-slot="main">
+
+  <div tal:repeat="pair view/work_item_containers" class="workitems-group">
+  <div tal:define="date python: pair[0]; containers python: pair[1]">
+    <h2>Work items due in <span tal:replace="date/fmt:date" /></h2>
+    <p>
+      From
+      <tal:milestones repeat="milestone python: view.milestones_per_date[date]">
+        <a tal:replace="structure milestone/fmt:link"
+          /><span tal:condition="not: repeat/milestone/end">,</span>
+      </tal:milestones>
+    </p>
+
+    <p>
+      There are <span tal:replace="python: view.workitem_counts[date]" />
+      Blueprint work items and
+      <span tal:replace="python: view.bugtask_counts[date]" /> Bugs due
+      in <span tal:content="date/fmt:date" /> which are assigned to members
+      of this team.
+    </p>
+
+    <table class="listing">
+      <thead>
+      <tr>
+        <th>Blueprint</th>
+        <th>Target</th>
+        <th>Assignee</th>
+        <th>Priority</th>
+        <th>Progress</th>
+      </tr>
+      </thead>
+      <tal:containers repeat="container containers">
+      <tbody>
+        <tr class="expandable">
+          <td>
+            <a href="#" class="expander">&nbsp;</a>
+            <span tal:replace="structure container/html_link" />
+          </td>
+          <td tal:content="structure container/target_link" />
+          <td tal:content="structure container/assignee_link" />
+          <td tal:content="container/priority_title" />
+          <td><span tal:replace="container/progress_text" /> done</td>
+        </tr>
+      </tbody>
+      <tbody class="collapsible-body">
+        <tr tal:repeat="workitem container/items" class="padded">
+          <td tal:content="structure workitem/display_title" />
+          <td>
+            <span tal:condition="not: container/spec|nothing"
+                  tal:replace="structure workitem/target/fmt:link" />
+          </td>
+          <td><a tal:replace="structure workitem/assignee/fmt:link" /></td>
+          <td>
+            <span tal:condition="not: container/spec|nothing"
+                  tal:replace="workitem/priority/title" />
+          </td>
+          <td><span tal:replace="workitem/status/title" /></td>
+        </tr>
+      </tbody>
+      </tal:containers>
+    </table>
+  </div>
+  </div>
+</div>
+
+</body>
+</html>

=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py	2012-04-03 16:06:43 +0000
+++ lib/lp/services/features/flags.py	2012-04-03 22:19:27 +0000
@@ -321,6 +321,12 @@
      '',
      '',
      ''),
+    ('registry.upcoming_work_view.enabled',
+     'boolean',
+     ('If true, the new upcoming work view of teams is available.'),
+     '',
+     '',
+     ''),
     ])
 
 # The set of all flag names that are documented.

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2012-03-30 18:13:38 +0000
+++ lib/lp/testing/factory.py	2012-04-03 22:19:27 +0000
@@ -2132,7 +2132,13 @@
         if title is None:
             title = self.getUniqueString(u'title')
         if specification is None:
-            specification = self.makeSpecification()
+            product = None
+            distribution = None
+            if milestone is not None:
+                product = milestone.product
+                distribution = milestone.distribution
+            specification = self.makeSpecification(
+                product=product, distribution=distribution)
         if sequence is None:
             sequence = self.getUniqueInteger()
         work_item = removeSecurityProxy(specification).newWorkItem(