launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #09605
[Merge] lp:~danilo/launchpad/bug-1013321 into lp:launchpad
Данило Шеган has proposed merging lp:~danilo/launchpad/bug-1013321 into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1013321 in Launchpad itself: "blueprint workitem code belong in lp.blueprints domain"
https://bugs.launchpad.net/launchpad/+bug/1013321
For more details, see:
https://code.launchpad.net/~danilo/launchpad/bug-1013321/+merge/113560
= Bug #1013321: move workitems-related code to lib/lp/blueprints =
== LOC Rationale ==
This is a tech-debt branch basically moving all the code to a different place.
== Implementation details ==
Some bits like links to the upcoming work page are left over in team.pt and browser/team.py. I am not planning to move those until that's easier to do.
== Tests ==
bin/test -cvvt person_upcomingwork
== Demo and Q/A ==
Create a project and a milestone with a date in the future (next 6 months).
Create a blueprint on this project and target it to this milestone, assign it to yourself.
Add a workitem (eg. "Something to do: TODO").
File a bug on the project, assign it to yourself and target it to the milestone.
Visit /people/+me/+upcomingwork to ensure both the blueprint/workitem and bug show up.
= Launchpad lint =
Checking for conflicts and issues in changed files.
Linting changed files:
lib/lp/registry/browser/configure.zcml
lib/lp/blueprints/browser/tests/test_person_upcomingwork.py
lib/lp/blueprints/browser/person_upcomingwork.py
lib/lp/blueprints/browser/configure.zcml
lib/lp/registry/browser/person.py
--
https://code.launchpad.net/~danilo/launchpad/bug-1013321/+merge/113560
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/bug-1013321 into lp:launchpad.
=== modified file 'lib/lp/blueprints/browser/configure.zcml'
--- lib/lp/blueprints/browser/configure.zcml 2012-05-17 07:46:56 +0000
+++ lib/lp/blueprints/browser/configure.zcml 2012-07-05 12:01:24 +0000
@@ -609,6 +609,13 @@
template="../../app/templates/generic-edit.pt"/>
</facet>
+ <browser:page
+ for="lp.registry.interfaces.person.IPerson"
+ class="lp.blueprints.browser.person_upcomingwork.PersonUpcomingWorkView"
+ permission="zope.Public"
+ name="+upcomingwork"
+ template="../templates/person-upcomingwork.pt"/>
+
<adapter
factory="lp.blueprints.browser.specification.starter_xhtml_representation"
name="starter"/>
=== added file 'lib/lp/blueprints/browser/person_upcomingwork.py'
--- lib/lp/blueprints/browser/person_upcomingwork.py 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/browser/person_upcomingwork.py 2012-07-05 12:01:24 +0000
@@ -0,0 +1,324 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Person upcoming view showing workitems and bugs for a person."""
+
+__meta__ = type
+__all__ = [
+ 'PersonUpcomingWorkView',
+ ]
+
+from datetime import (
+ datetime,
+ timedelta,
+ )
+from operator import (
+ attrgetter,
+ itemgetter,
+ )
+
+from lp.app.browser.tales import format_link
+from lp.blueprints.enums import SpecificationWorkItemStatus
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp.publisher import LaunchpadView
+
+
+class PersonUpcomingWorkView(LaunchpadView):
+ """This view displays work items and bugtasks that are due within 60 days
+ and are assigned to a person (or participants of 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(PersonUpcomingWorkView, self).initialize()
+ self.workitem_counts = {}
+ self.bugtask_counts = {}
+ self.milestones_per_date = {}
+ self.progress_per_date = {}
+ for date, containers in self.work_item_containers:
+ total_items = 0
+ total_done = 0
+ total_postponed = 0
+ milestones = set()
+ self.bugtask_counts[date] = 0
+ self.workitem_counts[date] = 0
+ for container in containers:
+ total_items += len(container.items)
+ total_done += len(container.done_items)
+ total_postponed += len(container.postponed_items)
+ 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'))
+
+ percent_done = 0
+ if total_items > 0:
+ done_or_postponed = total_done + total_postponed
+ percent_done = 100.0 * done_or_postponed / total_items
+ self.progress_per_date[date] = '{0:.0f}'.format(percent_done)
+
+ @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(object):
+ """A container of work items, assigned to a person (or a team's
+ participatns), 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 done_items(self):
+ return [item for item in self._items if item.is_complete]
+
+ @property
+ def postponed_items(self):
+ return [item for item in self._items
+ if item.status == SpecificationWorkItemStatus.POSTPONED]
+
+ @property
+ def percent_done_or_postponed(self):
+ """Returns % of work items to be worked on."""
+ percent_done = 0
+ if len(self._items) > 0:
+ done_or_postponed = (len(self.done_items) +
+ len(self.postponed_items))
+ percent_done = 100.0 * done_or_postponed / len(self._items)
+ return '{0:.0f}'.format(percent_done)
+
+ @property
+ def has_incomplete_work(self):
+ """Return True if there are incomplete work items."""
+ return (len(self.done_items) + len(self.postponed_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 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(person, 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 this person (or any of its
+ participants, in case it's a team).
+
+ Only work items whose milestone have a due date between today and the
+ given cut-off date are included in the results.
+ """
+ workitems = person.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 this person (or its
+ # participants, in case it's a 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.setdefault(milestone, {}).get(spec)
+ if container is None:
+ container = SpecWorkItemContainer(spec)
+ containers_by_spec[milestone][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 = person.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
+ # this person (or its participants, in case it's a 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
=== renamed file 'lib/lp/registry/browser/tests/test_person_upcomingwork.py' => 'lib/lp/blueprints/browser/tests/test_person_upcomingwork.py'
--- lib/lp/registry/browser/tests/test_person_upcomingwork.py 2012-05-24 12:05:42 +0000
+++ lib/lp/blueprints/browser/tests/test_person_upcomingwork.py 2012-07-05 12:01:24 +0000
@@ -15,7 +15,7 @@
SpecificationPriority,
SpecificationWorkItemStatus,
)
-from lp.registry.browser.person import (
+from lp.blueprints.browser.person_upcomingwork import (
GenericWorkItem,
getWorkItemsDueBefore,
WorkItemContainer,
=== renamed file 'lib/lp/registry/templates/person-upcomingwork.pt' => 'lib/lp/blueprints/templates/person-upcomingwork.pt'
=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml 2012-07-02 06:42:52 +0000
+++ lib/lp/registry/browser/configure.zcml 2012-07-05 12:01:24 +0000
@@ -1077,12 +1077,6 @@
name="+mugshots"
template="../templates/team-mugshots.pt"
class="lp.registry.browser.team.TeamMugshotView"/>
- <browser:page
- for="lp.registry.interfaces.person.IPerson"
- class="lp.registry.browser.person.PersonUpcomingWorkView"
- permission="zope.Public"
- name="+upcomingwork"
- template="../templates/person-upcomingwork.pt"/>
<browser:page
for="lp.registry.interfaces.person.ITeam"
class="lp.registry.browser.team.TeamIndexView"
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2012-06-29 08:40:05 +0000
+++ lib/lp/registry/browser/person.py 2012-07-05 12:01:24 +0000
@@ -46,7 +46,6 @@
'PersonSpecWorkloadTableView',
'PersonSpecWorkloadView',
'PersonSpecsMenu',
- 'PersonUpcomingWorkView',
'PersonView',
'PersonVouchersView',
'PPANavigationMenuMixIn',
@@ -63,10 +62,7 @@
import cgi
-from datetime import (
- datetime,
- timedelta,
- )
+from datetime import datetime
import itertools
from itertools import chain
from operator import (
@@ -132,7 +128,6 @@
from lp.app.browser.stringformatter import FormattersAPI
from lp.app.browser.tales import (
DateTimeFormatterAPI,
- format_link,
PersonFormatterAPI,
)
from lp.app.errors import (
@@ -147,7 +142,6 @@
LaunchpadRadioWidget,
LaunchpadRadioWidgetWithDescription,
)
-from lp.blueprints.enums import SpecificationWorkItemStatus
from lp.bugs.interfaces.bugtask import (
BugTaskSearchParams,
BugTaskStatus,
@@ -4408,304 +4402,3 @@
def __call__(self):
"""Render `Person` as XHTML using the webservice."""
return PersonFormatterAPI(self.person).link(None)
-
-
-class PersonUpcomingWorkView(LaunchpadView):
- """This view displays work items and bugtasks that are due within 60 days
- and are assigned to a person (or participants of 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(PersonUpcomingWorkView, self).initialize()
- self.workitem_counts = {}
- self.bugtask_counts = {}
- self.milestones_per_date = {}
- self.progress_per_date = {}
- for date, containers in self.work_item_containers:
- total_items = 0
- total_done = 0
- total_postponed = 0
- milestones = set()
- self.bugtask_counts[date] = 0
- self.workitem_counts[date] = 0
- for container in containers:
- total_items += len(container.items)
- total_done += len(container.done_items)
- total_postponed += len(container.postponed_items)
- 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'))
-
- percent_done = 0
- if total_items > 0:
- done_or_postponed = total_done + total_postponed
- percent_done = 100.0 * done_or_postponed / total_items
- self.progress_per_date[date] = '{0:.0f}'.format(percent_done)
-
- @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 a person (or a team's
- participatns), 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 done_items(self):
- return [item for item in self._items if item.is_complete]
-
- @property
- def postponed_items(self):
- return [item for item in self._items
- if item.status == SpecificationWorkItemStatus.POSTPONED]
-
- @property
- def percent_done_or_postponed(self):
- """Returns % of work items to be worked on."""
- percent_done = 0
- if len(self._items) > 0:
- done_or_postponed = (len(self.done_items) +
- len(self.postponed_items))
- percent_done = 100.0 * done_or_postponed / len(self._items)
- return '{0:.0f}'.format(percent_done)
-
- @property
- def has_incomplete_work(self):
- """Return True if there are incomplete work items."""
- return (len(self.done_items) + len(self.postponed_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 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(person, 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 this person (or any of its
- participants, in case it's a team).
-
- Only work items whose milestone have a due date between today and the
- given cut-off date are included in the results.
- """
- workitems = person.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 this person (or its
- # participants, in case it's a 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.setdefault(milestone, {}).get(spec)
- if container is None:
- container = SpecWorkItemContainer(spec)
- containers_by_spec[milestone][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 = person.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
- # this person (or its participants, in case it's a 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
Follow ups