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

Proposed by Guilherme Salgado
Status: Merged
Approved by: Richard Harding
Approved revision: no longer in the source branch.
Merge reported by: Данило Шеган
Merged at revision: not available
Proposed branch: lp:~linaro-infrastructure/launchpad/team-engineering-view-ui
Merge into: lp:launchpad
Diff against target: 905 lines (+744/-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 (+289/-3)
lib/lp/registry/browser/tests/test_team_upcomingwork.py (+304/-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 (+112/-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
j.c.sackett (community) Approve
Richard Harding (community) code* Approve
Steve Kowalik code Pending
Review via email: mp+100956@code.launchpad.net

This proposal supersedes a proposal from 2012-04-03.

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.
Revision history for this message
Steve Kowalik (stevenk) wrote : Posted in a previous version of this proposal

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)
Revision history for this message
Guilherme Salgado (salgado) wrote : Posted in a previous version of this proposal

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.

Revision history for this message
Mattias Backman (mabac) wrote : Posted in a previous version of this proposal

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.

Revision history for this message
Guilherme Salgado (salgado) wrote : Posted in a previous version of this proposal

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.

Revision history for this message
Richard Harding (rharding) wrote :

Thanks. the XSS fix looks good, appreciate the update.

review: Approve (code*)
Revision history for this message
j.c.sackett (jcsackett) wrote :

This looks alright; one quibble, though. On line 783 I see:

+ <!-- TODO: Once this page is done and no longer guarded with a feature
+ flag, move this to the appropriate css files. -->

I would ask that you convert this into an XXX comment per our XXXPolicy (https://dev.launchpad.net/PolicyandProcess/XXXPolicy).

+ <tal:XXX replace="nothing">20120405 salgado: [COMMENT TEXT]</tal:XXX>

As this is a fairly minor change, I'm marking approved with the understanding this will be taken care of.

Thanks!

review: Approve
Revision history for this message
Deepti B. Kalakeri (deeptik) wrote :

The changes in revision 14961 looks good, except am not sure if we should put salgado's name in there as he is no longer with the team and would not be there to address the things in future?

Looks good otherwise +1.

Thanks!!!
Deepti.

Revision history for this message
Paul Sokolovsky (pfalcon) wrote :

Well, salgado is back to Canonical, so he'll be closer to this code than before ;-). Actually, I guess we should ping him to merge this code as apart from him and danilo nobody in Linaro Infra team can do that.

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