← Back to team overview

yellow team mailing list archive

[Merge] lp:~frankban/launchpad/bug-904335-get-tags into lp:launchpad

 

Francesco Banconi has proposed merging lp:~frankban/launchpad/bug-904335-get-tags into lp:launchpad with lp:~yellow/launchpad/bug-904335-devel-base as a prerequisite.

Requested reviews:
  Gary Poster (gary)

For more details, see:
https://code.launchpad.net/~frankban/launchpad/bug-904335-get-tags/+merge/87489

= Summary =

Project groups needs a way to aggregate milestones, and introducing milestone
tags seems a way to do that in a flexible manner.


== Proposed fix ==

A Project Group Milestone Tag object is needed to aggregate milestones 
inside a project group. This patch introduces a convenient interface and
its implementation: the behaviour of the new model must be similar to what
is already present in Project Group Milestones. Above all we need the 
ability to retrieve bugtasks and specifications associated with a milestone
tag.


== Pre-implementation notes ==

The concept of milestone tag was controversed but appears to be a good 
tradeoff to meet Linaro needs. A db-devel change is needed to get milestone
tags work. That change is present in 
lp:~frankban/launchpad/db-milestonetags-480123


== Implementation details ==

A milestone interfaces refactoring is present in this patch.

IMilestoneData is now a base interface for any other milestone-like 
interface, and IAbstractMilestone is introduced as an intermediate interface 
for milestone. 
Both IMilestone and IProjectGroupMilestone are IAbstractMilestone subclasses.
Both IMilestone and IProjectGroupMilestoneTag (a marker interface) are
IMilestoneData subclasses.

The milestone model now has getter and setter methods for tags.

A MilestoneTag model is introduced as a storm interface to the new 
milestonetag table.
A ProjectGroupMilestoneTag model is used to retrieve bugtasks 
and specifications.


== Tests ==

bin/test -vv lp.registry.tests.test_milestone
bin/test -vv lp.registry.tests.test_milestonetag
bin/test -vv -t lp.registry.stories.webservice.xx-project-registry


== Demo and Q/A ==

The changes are not visible in the web ui.
However, due to milestone refactoring, checking the various milestone
pages may be a good idea.

-- 
https://code.launchpad.net/~frankban/launchpad/bug-904335-get-tags/+merge/87489
Your team Launchpad Yellow Squad is subscribed to branch lp:~yellow/launchpad/bug-904335-devel-base.
=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2011-12-30 08:03:42 +0000
+++ lib/lp/bugs/browser/bugtask.py	2012-01-04 15:38:28 +0000
@@ -3504,7 +3504,7 @@
               IDistributionSourcePackage.providedBy(self.context)):
             search_params.setSourcePackage(self.context)
         else:
-            raise AssertionError('Uknown context type: %s' % self.context)
+            raise AssertionError('Unknown context type: %s' % self.context)
 
         return u"".join("%d\n" % bug_id for bug_id in
             getUtility(IBugTaskSet).searchBugIds(search_params))

=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
--- lib/lp/bugs/interfaces/bugtask.py	2012-01-01 02:58:52 +0000
+++ lib/lp/bugs/interfaces/bugtask.py	2012-01-04 15:38:28 +0000
@@ -1193,9 +1193,9 @@
 
     def __init__(self, user, bug=None, searchtext=None, fast_searchtext=None,
                  status=None, importance=None, milestone=None,
-                 assignee=None, sourcepackagename=None, owner=None,
-                 attachmenttype=None, orderby=None, omit_dupes=False,
-                 subscriber=None, component=None,
+                 milestone_tag=None, assignee=None, sourcepackagename=None,
+                 owner=None, attachmenttype=None, orderby=None,
+                 omit_dupes=False, subscriber=None, component=None,
                  pending_bugwatch_elsewhere=False, resolved_upstream=False,
                  open_upstream=False, has_no_upstream_bugtask=False, tag=None,
                  has_cve=False, bug_supervisor=None, bug_reporter=None,
@@ -1218,6 +1218,7 @@
         self.status = status
         self.importance = importance
         self.milestone = milestone
+        self.milestone_tag = milestone_tag
         self.assignee = assignee
         self.sourcepackagename = sourcepackagename
         self.owner = owner

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2011-12-30 06:14:56 +0000
+++ lib/lp/bugs/model/bugtask.py	2012-01-04 15:38:28 +0000
@@ -117,6 +117,7 @@
     IMilestoneSet,
     IProjectGroupMilestone,
     )
+from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
 from lp.registry.interfaces.person import (
     IPerson,
     validate_person,
@@ -2052,6 +2053,13 @@
         if params.status is not None:
             extra_clauses.append(self._buildStatusClause(params.status))
 
+        if (params.exclude_conjoined_tasks and
+            not (params.milestone or params.milestone_tag)):
+            raise ValueError(
+                "BugTaskSearchParam.exclude_conjoined cannot be True if "
+                "BugTaskSearchParam.milestone or "
+                "BugTaskSearchParam.milestone_tag is not set")
+
         if params.milestone:
             if IProjectGroupMilestone.providedBy(params.milestone):
                 where_cond = """
@@ -2071,10 +2079,29 @@
                     params.milestone)
                 join_tables += tables
                 extra_clauses += clauses
-        elif params.exclude_conjoined_tasks:
-            raise ValueError(
-                "BugTaskSearchParam.exclude_conjoined cannot be True if "
-                "BugTaskSearchParam.milestone is not set")
+
+        if params.milestone_tag:
+            where_cond = """
+                IN (SELECT Milestone.id
+                    FROM Milestone, Product, MilestoneTag
+                    WHERE Milestone.product = Product.id
+                        AND Product.project = %s
+                        AND MilestoneTag.milestone = Milestone.id
+                        AND MilestoneTag.tag IN %s
+                    GROUP BY Milestone.id
+                    HAVING COUNT(Milestone.id) = %s)
+            """ % sqlvalues(params.milestone_tag.target,
+                            params.milestone_tag.tags,
+                            len(params.milestone_tag.tags))
+            extra_clauses.append("BugTask.milestone %s" % where_cond)
+
+            # XXX frankban 2011-12-16 further investigation needed
+            # to make sure we can skip the _buildExcludeConjoinedClause call
+            # if params.exclude_conjoined_tasks:
+            #     tables, clauses = self._buildExcludeConjoinedClause(
+            #         params.milestone_tag)
+            #     join_tables += tables
+            #     extra_clauses += clauses
 
         if params.project:
             # Prevent circular import problems.
@@ -2870,12 +2897,18 @@
             result[row[:-1]] = row[-1]
         return result
 
-    def getPrecachedNonConjoinedBugTasks(self, user, milestone):
+    def getPrecachedNonConjoinedBugTasks(self, user, milestone_data):
         """See `IBugTaskSet`."""
-        params = BugTaskSearchParams(
-            user, milestone=milestone,
-            orderby=['status', '-importance', 'id'],
-            omit_dupes=True, exclude_conjoined_tasks=True)
+        kwargs = {
+            'orderby': ['status', '-importance', 'id'],
+            'omit_dupes': True,
+            'exclude_conjoined_tasks': True,
+            }
+        if IProjectGroupMilestoneTag.providedBy(milestone_data):
+            kwargs['milestone_tag'] = milestone_data
+        else:
+            kwargs['milestone'] = milestone_data
+        params = BugTaskSearchParams(user, **kwargs)
         return self.search(params)
 
     def createTask(self, bug, owner, target,

=== modified file 'lib/lp/hardwaredb/doc/hwdb.txt'
--- lib/lp/hardwaredb/doc/hwdb.txt	2011-12-18 13:45:20 +0000
+++ lib/lp/hardwaredb/doc/hwdb.txt	2012-01-04 15:38:28 +0000
@@ -109,7 +109,7 @@
 Limitations:
   * "No name" products like mainboards from companies like ASRock
      or Asus that are directly sold to end users have fingerprints like
-     "American Megatrends Inc. Uknown 1.0".
+     "American Megatrends Inc. Unknown 1.0".
   * A manufacturer may erroneously assign identical DMI values for product
     and vendor to different systems.
   * submissions for "counterfeit systems".
@@ -743,4 +743,3 @@
     >>> set(submission.owner.name for submission
     ...     in owner.hardware_submissions)
     set([u'name12'])
-

=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml	2011-12-24 17:49:30 +0000
+++ lib/lp/registry/browser/configure.zcml	2012-01-04 15:38:28 +0000
@@ -1279,14 +1279,14 @@
             MilestoneNavigation"/>
     <adapter
         provides="lp.services.webapp.interfaces.IBreadcrumb"
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         factory="lp.registry.browser.milestone.MilestoneBreadcrumb"
         permission="zope.Public"/>
     <browser:defaultView
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         name="+index"/>
     <browser:url
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         path_expression="string:+milestone/${name}"
         rootsite="mainsite"
         attribute_to_parent="target"/>
@@ -1297,7 +1297,7 @@
         template="../templates/milestone-macros.pt"
         class="lp.app.browser.launchpad.Macro"/>
     <browser:pages
-        for="lp.registry.interfaces.milestone.IMilestone"
+        for="lp.registry.interfaces.milestone.IMilestoneData"
         class="lp.registry.browser.milestone.MilestoneView"
         permission="zope.Public">
         <browser:page

=== modified file 'lib/lp/registry/browser/milestone.py'
--- lib/lp/registry/browser/milestone.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/browser/milestone.py	2012-01-04 15:38:28 +0000
@@ -52,6 +52,7 @@
 from lp.registry.browser.product import ProductDownloadFileMixin
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.milestone import (
+    IAbstractMilestone,
     IMilestone,
     IMilestoneSet,
     IProjectGroupMilestone,
@@ -84,15 +85,15 @@
 class MilestoneNavigation(Navigation,
     StructuralSubscriptionTargetTraversalMixin):
     """The navigation to traverse to a milestone."""
-    usedfor = IMilestone
+    usedfor = IAbstractMilestone
 
 
 class MilestoneBreadcrumb(Breadcrumb):
-    """The Breadcrumb for an `IMilestone`."""
+    """The Breadcrumb for an `IAbstractMilestone`."""
 
     @property
     def text(self):
-        milestone = IMilestone(self.context)
+        milestone = IAbstractMilestone(self.context)
         if milestone.code_name:
             return '%s "%s"' % (milestone.name, milestone.code_name)
         else:
@@ -139,7 +140,7 @@
 
 class MilestoneContextMenu(ContextMenu, MilestoneLinkMixin):
     """The menu for this milestone."""
-    usedfor = IMilestone
+    usedfor = IAbstractMilestone
 
     @cachedproperty
     def links(self):
@@ -151,7 +152,7 @@
 
 class MilestoneOverviewNavigationMenu(NavigationMenu, MilestoneLinkMixin):
     """Overview navigation menu for `IMilestone` objects."""
-    usedfor = IMilestone
+    usedfor = IAbstractMilestone
     facet = 'overview'
 
     @cachedproperty
@@ -165,7 +166,7 @@
     """Overview  menus for `IMilestone` objects."""
     # This menu must not contain 'subscribe' because the link state is too
     # costly to calculate when this menu is used with a list of milestones.
-    usedfor = IMilestone
+    usedfor = IAbstractMilestone
     facet = 'overview'
     links = ('edit', 'create_release')
 
@@ -199,7 +200,7 @@
         :param request: `ILaunchpadRequest`.
         """
         super(MilestoneView, self).__init__(context, request)
-        if IMilestone.providedBy(context):
+        if IAbstractMilestone.providedBy(context):
             self.milestone = context
             self.release = context.product_release
         else:
@@ -261,9 +262,7 @@
         """The list of non-conjoined bugtasks targeted to this milestone."""
         # Put the results in a list so that iterating over it multiple
         # times in this method does not make multiple queries.
-        non_conjoined_slaves = list(
-            getUtility(IBugTaskSet).getPrecachedNonConjoinedBugTasks(
-                self.user, self.context))
+        non_conjoined_slaves = self.context.bugtasks(self.user)
         # Checking bug permissions is expensive. We know from the query that
         # the user has at least launchpad.View on the bugtasks and their bugs.
         # NB: this is in principle unneeded due to injection of permission in

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2011-12-30 08:03:42 +0000
+++ lib/lp/registry/configure.zcml	2012-01-04 15:38:28 +0000
@@ -404,8 +404,8 @@
                 setAliases"/>
 
         <!-- IProjectGroupModerate -->
-	<allow
-	    interface="lp.registry.interfaces.projectgroup.IProjectGroupModerate"/>
+        <allow
+            interface="lp.registry.interfaces.projectgroup.IProjectGroupModerate"/>
         <require
             permission="launchpad.Moderate"
             set_schema="lp.registry.interfaces.projectgroup.IProjectGroupModerate"/>
@@ -1006,6 +1006,7 @@
                 series_target
                 displayname
                 title
+                bugtasks
                 specifications
                 product_release"/>
         <require
@@ -1013,7 +1014,14 @@
             attributes="
                 createProductRelease
                 closeBugsAndBlueprints
-                destroySelf"/>
+                destroySelf
+		        setTags
+		        "/>
+        <require
+            permission="zope.Public"
+            attributes="
+                getTags
+                "/>
         <allow interface="lp.bugs.interfaces.bugsummary.IBugSummaryDimension"/>
         <allow
             interface="lp.bugs.interfaces.bugtarget.IHasBugs"/>
@@ -1245,7 +1253,7 @@
         <!-- https://lists.ubuntu.com/mailman/private/launchpad/2007-April/015189.html
                         for further discussion - stub 20070411 -->
 
-	<!-- Per bug 588773, changing to launchpad.Moderate to allow Registry Experts (~registry) -->
+        <!-- Per bug 588773, changing to launchpad.Moderate to allow Registry Experts (~registry) -->
         <require
             permission="launchpad.Moderate"
             set_attributes="name autoupdate registrant"/>

=== modified file 'lib/lp/registry/interfaces/milestone.py'
--- lib/lp/registry/interfaces/milestone.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/interfaces/milestone.py	2012-01-04 15:38:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0211,E0213
@@ -8,9 +8,11 @@
 __metaclass__ = type
 
 __all__ = [
+    'IAbstractMilestone',
     'ICanGetMilestonesDirectly',
     'IHasMilestones',
     'IMilestone',
+    'IMilestoneData',
     'IMilestoneSet',
     'IProjectGroupMilestone',
     ]
@@ -97,20 +99,52 @@
         return milestone
 
 
-class IMilestone(IHasBugs, IStructuralSubscriptionTarget,
-                 IHasOfficialBugTags):
-    """A milestone, or a targeting point for bugs and other
-    release-management items that need coordination.
+class IMilestoneData(IHasBugs, IStructuralSubscriptionTarget,
+                     IHasOfficialBugTags):
+    """Interface containing the data for milestones.
+
+    To be registered for views but not instantiated.
     """
-    export_as_webservice_entry()
-
     id = Int(title=_("Id"))
+
     name = exported(
         MilestoneNameField(
             title=_("Name"),
             description=_(
                 "Only letters, numbers, and simple punctuation are allowed."),
             constraint=name_validator))
+    target = exported(
+        Reference(
+            schema=Interface,  # IHasMilestones
+            title=_(
+                "The product, distribution, or project group for this "
+                "milestone."),
+            required=False))
+    specifications = Attribute(
+        "A list of specifications targeted to this object.")
+    dateexpected = exported(
+        FormattableDate(title=_("Date Targeted"), required=False,
+             description=_("Example: 2005-11-24")),
+        exported_as='date_targeted')
+    active = exported(
+        Bool(
+            title=_("Active"),
+            description=_("Whether or not this object should be shown "
+                          "in web forms for targeting.")),
+        exported_as='is_active')
+    displayname = Attribute("A displayname constructed from the name.")
+    title = exported(
+        TextLine(title=_("A context title for pages."),
+                 readonly=True))
+
+    def bugtasks(user):
+        """Get a list of non-conjoined bugtasks visible to this user."""
+
+
+class IAbstractMilestone(IMilestoneData):
+    """An intermediate interface for milestone, or a targeting point for bugs
+    and other release-management items that need coordination.
+    """
     code_name = exported(
         NoneableTextLine(
             title=u'Code name', required=False,
@@ -126,47 +160,24 @@
         title=_("Product Series"),
         description=_("The product series for which this is a milestone."),
         vocabulary="FilteredProductSeries",
-        required=False) # for now
+        required=False)  # for now
     distroseries = Choice(
         title=_("Distro Series"),
         description=_(
             "The distribution series for which this is a milestone."),
         vocabulary="FilteredDistroSeries",
-        required=False) # for now
-    dateexpected = exported(
-        FormattableDate(title=_("Date Targeted"), required=False,
-             description=_("Example: 2005-11-24")),
-        exported_as='date_targeted')
-    active = exported(
-        Bool(
-            title=_("Active"),
-            description=_("Whether or not this milestone should be shown "
-                          "in web forms for bug targeting.")),
-        exported_as='is_active')
+        required=False)  # for now
     summary = exported(
         NoneableDescription(
             title=_("Summary"),
             required=False,
             description=_(
                 "A summary of the features and status of this milestone.")))
-    target = exported(
-        Reference(
-            schema=Interface, # IHasMilestones
-            title=_("The product or distribution of this milestone."),
-            required=False))
     series_target = exported(
         Reference(
-            schema=Interface, # IHasMilestones
+            schema=Interface,  # IHasMilestones
             title=_("The productseries or distroseries of this milestone."),
             required=False))
-    displayname = Attribute("A displayname for this milestone, constructed "
-        "from the milestone name.")
-    title = exported(
-        TextLine(title=_("A milestone context title for pages."),
-                 readonly=True))
-    specifications = Attribute("A list of the specifications targeted to "
-        "this milestone.")
-
     product_release = exported(
         Reference(
             schema=IProductRelease,
@@ -211,6 +222,23 @@
         release.
         """
 
+
+class IMilestone(IAbstractMilestone):
+    """Actual interface for milestones."""
+
+    export_as_webservice_entry()
+
+    def setTags(tags, user):
+        """Set the milestone tags.
+
+        :param: tags The list of tags to be associated with milestone.
+        :param: user The user who is updating tags for this milestone.
+        """
+
+    def getTags():
+        """Return the milestone tags in alphabetical order."""
+
+
 # Avoid circular imports
 IBugTask['milestone'].schema = IMilestone
 patch_plain_parameter_type(
@@ -249,8 +277,9 @@
         """Return all visible milestones."""
 
 
-class IProjectGroupMilestone(IMilestone):
+class IProjectGroupMilestone(IAbstractMilestone):
     """A marker interface for milestones related to a project"""
+    export_as_webservice_entry()
 
 
 class IHasMilestones(Interface):

=== added file 'lib/lp/registry/interfaces/milestonetag.py'
--- lib/lp/registry/interfaces/milestonetag.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/interfaces/milestonetag.py	2012-01-04 15:38:28 +0000
@@ -0,0 +1,20 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""MilestoneTag interfaces."""
+
+__metaclass__ = type
+__all__ = [
+    'IProjectGroupMilestoneTag',
+    ]
+
+
+from lp.registry.interfaces.milestone import IMilestoneData
+
+
+class IProjectGroupMilestoneTag(IMilestoneData):
+    """An IProjectGroupMilestoneTag is a tag aggretating milestones for the
+    ProjectGroup with a given tag or tags.
+
+    This interface is just a marker.
+    """

=== modified file 'lib/lp/registry/model/milestone.py'
--- lib/lp/registry/model/milestone.py	2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/milestone.py	2012-01-04 15:38:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0611,W0212
@@ -8,6 +8,7 @@
 __all__ = [
     'HasMilestonesMixin',
     'Milestone',
+    'MilestoneData',
     'MilestoneSet',
     'ProjectMilestone',
     'milestone_sort_key',
@@ -49,6 +50,7 @@
 from lp.registry.interfaces.milestone import (
     IHasMilestones,
     IMilestone,
+    IMilestoneData,
     IMilestoneSet,
     IProjectGroupMilestone,
     )
@@ -129,11 +131,47 @@
         super(MultipleProductReleases, self).__init__(msg)
 
 
-class Milestone(SQLBase, StructuralSubscriptionTargetMixin, HasBugsBase):
+class MilestoneData:
+    implements(IMilestoneData)
+
+    @property
+    def displayname(self):
+        """See IMilestone."""
+        return "%s %s" % (self.target.displayname, self.name)
+
+    @property
+    def title(self):
+        raise NotImplementedError
+
+    @property
+    def specifications(self):
+        raise NotImplementedError
+
+    def bugtasks(self, user):
+        """The list of non-conjoined bugtasks targeted to this milestone."""
+        # Put the results in a list so that iterating over it multiple
+        # times in this method does not make multiple queries.
+        non_conjoined_slaves = list(
+            getUtility(IBugTaskSet).getPrecachedNonConjoinedBugTasks(
+                user, self))
+        return non_conjoined_slaves
+
+
+class Milestone(SQLBase, MilestoneData, StructuralSubscriptionTargetMixin,
+                HasBugsBase):
     implements(IHasBugs, IMilestone, IBugSummaryDimension)
 
+    active = BoolCol(notNull=True, default=True)
+
+    # XXX: EdwinGrubbs 2009-02-06 bug=326384:
+    # The Milestone.dateexpected should be changed into a date column,
+    # since the class defines the field as a DateCol, so that a list of
+    # milestones can't have some dateexpected attributes that are
+    # datetimes and others that are dates, which can't be compared.
+    dateexpected = DateCol(notNull=False, default=None)
+
     # XXX: Guilherme Salgado 2007-03-27 bug=40978:
-    # Milestones should be associated with productseries/distroseriess
+    # Milestones should be associated with productseries/distroseries
     # so these columns are not needed.
     product = ForeignKey(dbName='product',
         foreignKey='Product', default=None)
@@ -145,23 +183,23 @@
     distroseries = ForeignKey(dbName='distroseries',
         foreignKey='DistroSeries', default=None)
     name = StringCol(notNull=True)
-    # XXX: EdwinGrubbs 2009-02-06 bug=326384:
-    # The Milestone.dateexpected should be changed into a date column,
-    # since the class defines the field as a DateCol, so that a list of
-    # milestones can't have some dateexpected attributes that are
-    # datetimes and others that are dates, which can't be compared.
-    dateexpected = DateCol(notNull=False, default=None)
-    active = BoolCol(notNull=True, default=True)
     summary = StringCol(notNull=False, default=None)
     code_name = StringCol(dbName='codename', notNull=False, default=None)
 
-    # joins
     specifications = SQLMultipleJoin('Specification', joinColumn='milestone',
         orderBy=['-priority', 'definition_status',
                  'implementation_status', 'title'],
         prejoins=['assignee'])
 
     @property
+    def target(self):
+        """See IMilestone."""
+        if self.product:
+            return self.product
+        elif self.distribution:
+            return self.distribution
+
+    @property
     def product_release(self):
         store = Store.of(self)
         result = store.find(ProductRelease,
@@ -173,14 +211,6 @@
             return releases[0]
 
     @property
-    def target(self):
-        """See IMilestone."""
-        if self.product:
-            return self.product
-        elif self.distribution:
-            return self.distribution
-
-    @property
     def series_target(self):
         """See IMilestone."""
         if self.productseries:
@@ -189,11 +219,6 @@
             return self.distroseries
 
     @property
-    def displayname(self):
-        """See IMilestone."""
-        return "%s %s" % (self.target.displayname, self.name)
-
-    @property
     def title(self):
         """See IMilestone."""
         if not self.code_name:
@@ -255,6 +280,39 @@
         from lp.bugs.model.bugsummary import BugSummary
         return BugSummary.milestone_id == self.id
 
+    def setTags(self, tags, user):
+        """See IMilestone."""
+        # Circular reference prevention.
+        from lp.registry.model.milestonetag import MilestoneTag
+        store = Store.of(self)
+        if tags:
+            current_tags = set(self.getTags())
+            new_tags = set(tags)
+            if new_tags == current_tags:
+                return
+            # Removing deleted tags.
+            to_remove = current_tags.difference(new_tags)
+            if to_remove:
+                store.find(
+                    MilestoneTag, MilestoneTag.tag.is_in(to_remove)).remove()
+            # Adding new tags.
+            for tag in new_tags.difference(current_tags):
+                store.add(MilestoneTag(self, tag, user))
+        else:
+            store.find(
+                MilestoneTag, MilestoneTag.milestone_id == self.id).remove()
+        store.commit()
+
+    def getTags(self):
+        """See IMilestone."""
+        # Circular reference prevention.
+        from lp.registry.model.milestonetag import MilestoneTag
+        store = Store.of(self)
+        return store.find(
+            MilestoneTag, MilestoneTag.milestone_id == self.id
+            ).order_by(MilestoneTag.tag
+            ).values(MilestoneTag.tag)
+
 
 class MilestoneSet:
     implements(IMilestoneSet)
@@ -300,7 +358,7 @@
         return Milestone.selectBy(active=True, orderBy='id')
 
 
-class ProjectMilestone(HasBugsBase):
+class ProjectMilestone(MilestoneData, HasBugsBase):
     """A virtual milestone implementation for project.
 
     The current database schema has no formal concept of milestones related to
@@ -315,12 +373,13 @@
     implements(IProjectGroupMilestone)
 
     def __init__(self, target, name, dateexpected, active):
-        self.name = name
         self.code_name = None
         # The id is necessary for generating a unique memcache key
         # in a page template loop. The ProjectMilestone.id is passed
         # in as the third argument to the "cache" TALes.
         self.id = 'ProjectGroup:%s/Milestone:%s' % (target.name, name)
+        self.name = name
+        self.target = target
         self.code_name = None
         self.product = None
         self.distribution = None
@@ -329,7 +388,6 @@
         self.product_release = None
         self.dateexpected = dateexpected
         self.active = active
-        self.target = target
         self.series_target = None
         self.summary = None
 

=== added file 'lib/lp/registry/model/milestonetag.py'
--- lib/lp/registry/model/milestonetag.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/model/milestonetag.py	2012-01-04 15:38:28 +0000
@@ -0,0 +1,90 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Milestonetag model class."""
+
+__metaclass__ = type
+__all__ = [
+    'MilestoneTag',
+    'ProjectGroupMilestoneTag',
+    ]
+
+
+from zope.interface import implements
+from zope.component import getUtility
+
+from lp.services.webapp.interfaces import (
+    IStoreSelector,
+    MAIN_STORE,
+    DEFAULT_FLAVOR,
+    )
+
+from lp.blueprints.model.specification import Specification
+from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
+from lp.registry.model.milestone import MilestoneData, Milestone
+from lp.registry.model.product import Product
+from storm.locals import (
+    DateTime,
+    Int,
+    Unicode,
+    Reference,
+    )
+
+
+class MilestoneTag(object):
+    """A tag belonging to a milestone."""
+
+    __storm_table__ = 'milestonetag'
+
+    id = Int(primary=True)
+    milestone_id = Int(name='milestone', allow_none=False)
+    milestone = Reference(milestone_id, 'milestone.id')
+    tag = Unicode(allow_none=False)
+    created_by_id = Int(name='created_by', allow_none=False)
+    created_by = Reference(created_by_id, 'person.id')
+    date_created = DateTime(allow_none=False)
+
+    def __init__(self, milestone, tag, created_by, date_created=None):
+        self.milestone_id = milestone.id
+        self.tag = tag
+        self.created_by_id = created_by.id
+        if date_created is not None:
+            self.date_created = date_created
+
+
+class ProjectGroupMilestoneTag(MilestoneData):
+
+    implements(IProjectGroupMilestoneTag)
+
+    def __init__(self, target, tags):
+        self.target = target
+        # Tags is a sequence of Unicode strings.
+        self.tags = tags
+
+    @property
+    def name(self):
+        return u", ".join(self.tags)
+
+    @property
+    def title(self):
+        """See IMilestoneData."""
+        return self.displayname
+
+    @property
+    def specifications(self):
+        """See IMilestoneData."""
+        store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
+        results = []
+        for tag in self.tags:
+            result = store.find(
+                Specification,
+                Specification.milestone == Milestone.id,
+                Milestone.product == Product.id,
+                Product.project == self.target,
+                MilestoneTag.milestone_id == Milestone.id,
+                MilestoneTag.tag == tag)
+            results.append(result)
+        result = results.pop()
+        for i in results:
+            result = result.intersection(i)
+        return result

=== modified file 'lib/lp/registry/model/projectgroup.py'
--- lib/lp/registry/model/projectgroup.py	2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/projectgroup.py	2012-01-04 15:38:28 +0000
@@ -464,19 +464,19 @@
     @property
     def milestones(self):
         """See `IProjectGroup`."""
-        return self._getMilestones(True)
+        return self._getMilestones(only_active=True)
 
     @property
     def product_milestones(self):
         """Hack to avoid the ProjectMilestone in MilestoneVocabulary."""
         # XXX: bug=644977 Robert Collins - this is a workaround for
-        # insconsistency in project group milestone use.
+        # inconsistency in project group milestone use.
         return self._get_milestones()
 
     @property
     def all_milestones(self):
         """See `IProjectGroup`."""
-        return self._getMilestones(False)
+        return self._getMilestones(only_active=False)
 
     def getMilestone(self, name):
         """See `IProjectGroup`."""

=== modified file 'lib/lp/registry/tests/test_milestone.py'
--- lib/lp/registry/tests/test_milestone.py	2012-01-01 02:58:52 +0000
+++ lib/lp/registry/tests/test_milestone.py	2012-01-04 15:38:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Milestone related test helper."""
@@ -18,6 +18,7 @@
     )
 from lp.registry.interfaces.product import IProductSet
 from lp.testing import (
+    person_logged_in,
     ANONYMOUS,
     login,
     logout,
@@ -128,3 +129,64 @@
     def test_projectgroup(self):
         projectgroup = self.factory.makeProject()
         self.check_skipped(projectgroup)
+
+
+class MilestoneBugTaskTest(TestCaseWithFactory):
+    """Test cases for retrieving bugtasks for a milestone."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(MilestoneBugTaskTest, self).setUp()
+        self.owner = self.factory.makePerson()
+        self.product = self.factory.makeProduct(name="product1")
+        self.milestone = self.factory.makeMilestone(product=self.product)
+
+    def _create_bugtasks(self, num, milestone=None):
+        bugtasks = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                bugtask = self.factory.makeBugTask(
+                    target=self.product,
+                    owner=self.owner)
+                if milestone:
+                    bugtask.milestone = milestone
+                bugtasks.append(bugtask)
+        return bugtasks
+
+    def test_bugtask_retrieval(self):
+        # Ensure that all bugtasks on a milestone can be retrieved.
+        bugtasks = self._create_bugtasks(5, self.milestone)
+        self.assertContentEqual(
+            bugtasks,
+            self.milestone.bugtasks(self.owner))
+
+
+class MilestoneSpecificationTest(TestCaseWithFactory):
+    """Test cases for retrieving specifications for a milestone."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(MilestoneSpecificationTest, self).setUp()
+        self.owner = self.factory.makePerson()
+        self.product = self.factory.makeProduct(name="product1")
+        self.milestone = self.factory.makeMilestone(product=self.product)
+
+    def _create_specifications(self, num, milestone=None):
+        specifications = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                specification = self.factory.makeSpecification(
+                    product=self.product,
+                    owner=self.owner,
+                    milestone=milestone)
+                specifications.append(specification)
+        return specifications
+
+    def test_specification_retrieval(self):
+        # Ensure that all specifications on a milestone can be retrieved.
+        specifications = self._create_specifications(5, self.milestone)
+        self.assertContentEqual(
+            specifications,
+            self.milestone.specifications)

=== added file 'lib/lp/registry/tests/test_milestonetag.py'
--- lib/lp/registry/tests/test_milestonetag.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/tests/test_milestonetag.py	2012-01-04 15:38:28 +0000
@@ -0,0 +1,164 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Milestone related test helper."""
+
+__metaclass__ = type
+
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    )
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+
+
+class MilestoneTagTest(TestCaseWithFactory):
+    """Test cases for setting and retrieving milestone tags."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(MilestoneTagTest, self).setUp()
+        self.milestone = self.factory.makeMilestone()
+        self.person = self.milestone.target.owner
+        self.tags = [u'tag2', u'tag1', u'tag3']
+
+    def test_no_tags(self):
+        # Ensure a newly created milestone does not have associated tags.
+        self.assertEquals([], list(self.milestone.getTags()))
+
+    def test_tags_setting_and_retrieval(self):
+        # Ensure tags are correctly saved and retrieved from the db.
+        with person_logged_in(self.person):
+            self.milestone.setTags(self.tags, self.person)
+        self.assertEqual(sorted(self.tags), list(self.milestone.getTags()))
+
+    def test_tags_override(self):
+        # Ensure you can override tags already associated with the milestone.
+        with person_logged_in(self.person):
+            self.milestone.setTags(self.tags, self.person)
+            new_tags = [u'tag2', u'tag4', u'tag3']
+            self.milestone.setTags(new_tags, self.person)
+        self.assertEqual(sorted(new_tags), list(self.milestone.getTags()))
+
+    def test_tags_deletion(self):
+        # Ensure passing an empty sequence of tags deletes them all.
+        with person_logged_in(self.person):
+            self.milestone.setTags(self.tags, self.person)
+            self.milestone.setTags([], self.person)
+        self.assertEquals([], list(self.milestone.getTags()))
+
+
+class ProjectGroupMilestoneTagTest(TestCaseWithFactory):
+    """Test cases for retrieving bugtasks for a milestonetag."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(ProjectGroupMilestoneTagTest, self).setUp()
+        self.owner = self.factory.makePerson()
+        self.project_group = self.factory.makeProject(owner=self.owner)
+        self.product = self.factory.makeProduct(
+            name="product1",
+            owner=self.owner,
+            project=self.project_group)
+        self.milestone = self.factory.makeMilestone(product=self.product)
+
+    def _create_bugtasks(self, num, milestone=None):
+        bugtasks = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                bugtask = self.factory.makeBugTask(
+                    target=self.product,
+                    owner=self.owner)
+                if milestone:
+                    bugtask.milestone = milestone
+                bugtasks.append(bugtask)
+        return bugtasks
+
+    def _create_specifications(self, num, milestone=None):
+        specifications = []
+        with person_logged_in(self.owner):
+            for n in xrange(num):
+                specification = self.factory.makeSpecification(
+                    product=self.product,
+                    owner=self.owner,
+                    milestone=milestone)
+                specifications.append(specification)
+        return specifications
+
+    def _create_items_for_retrieval(self, factory, tag=u'tag1'):
+        with person_logged_in(self.owner):
+            self.milestone.setTags([tag], self.owner)
+            items = factory(5, self.milestone)
+            milestonetag = ProjectGroupMilestoneTag(
+                target=self.project_group, tags=[tag])
+        return items, milestonetag
+
+    def _create_items_for_untagged_milestone(self, factory, tag=u'tag1'):
+        new_milestone = self.factory.makeMilestone(product=self.product)
+        with person_logged_in(self.owner):
+            self.milestone.setTags([tag], self.owner)
+            items = factory(5, self.milestone)
+            factory(3, new_milestone)
+            milestonetag = ProjectGroupMilestoneTag(
+                target=self.project_group, tags=[tag])
+        return items, milestonetag
+
+    def _create_items_for_multiple_tags(
+        self, factory, tags=(u'tag1', u'tag2')):
+        new_milestone = self.factory.makeMilestone(product=self.product)
+        with person_logged_in(self.owner):
+            self.milestone.setTags(tags, self.owner)
+            new_milestone.setTags(tags[:1], self.owner)
+            items = factory(5, self.milestone)
+            factory(3, new_milestone)
+            milestonetag = ProjectGroupMilestoneTag(
+                target=self.project_group, tags=tags)
+        return items, milestonetag
+
+    # Add a test similar to TestProjectExcludeConjoinedMasterSearch in
+    # lp.bugs.tests.test_bugsearch_conjoined.
+
+    def test_bugtask_retrieve_single_milestone(self):
+        # Ensure that all bugtasks on a single milestone can be retrieved.
+        bugtasks, milestonetag = self._create_items_for_retrieval(
+            self._create_bugtasks)
+        self.assertContentEqual(bugtasks, milestonetag.bugtasks(self.owner))
+
+    def test_bugtasks_for_untagged_milestone(self):
+        # Ensure that bugtasks for a project group are retrieved
+        # only if associated with milestones having specified tags.
+        bugtasks, milestonetag = self._create_items_for_untagged_milestone(
+            self._create_bugtasks)
+        self.assertContentEqual(bugtasks, milestonetag.bugtasks(self.owner))
+
+    def test_bugtasks_multiple_tags(self):
+        # Ensure that, in presence of multiple tags, only bugtasks
+        # for milestones associated with all the tags are retrieved.
+        bugtasks, milestonetag = self._create_items_for_multiple_tags(
+            self._create_bugtasks)
+        self.assertContentEqual(bugtasks, milestonetag.bugtasks(self.owner))
+
+    def test_specification_retrieval(self):
+        # Ensure that all specifications on a milestone can be retrieved.
+        specs, milestonetag = self._create_items_for_retrieval(
+            self._create_specifications)
+        self.assertContentEqual(specs, milestonetag.specifications)
+
+    def test_specifications_for_untagged_milestone(self):
+        # Ensure that specifications for a project group are retrieved
+        # only if associated with milestones having specified tags.
+        specs, milestonetag = self._create_items_for_untagged_milestone(
+            self._create_specifications)
+        self.assertContentEqual(specs, milestonetag.specifications)
+
+    def test_specifications_multiple_tags(self):
+        # Ensure that, in presence of multiple tags, only specifications
+        # for milestones associated with all the tags are retrieved.
+        specs, milestonetag = self._create_items_for_multiple_tags(
+            self._create_specifications)
+        self.assertContentEqual(specs, milestonetag.specifications)

=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py	2012-01-04 15:38:28 +0000
+++ lib/lp/registry/tests/test_person.py	2012-01-04 15:38:28 +0000
@@ -18,26 +18,6 @@
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
-from canonical.config import config
-from canonical.database.sqlbase import cursor, sqlvalues
-from canonical.launchpad.database.account import Account
-from canonical.launchpad.database.emailaddress import EmailAddress
-from canonical.launchpad.interfaces.account import (
-    AccountCreationRationale,
-    AccountStatus,
-    )
-from canonical.launchpad.interfaces.emailaddress import (
-    EmailAddressAlreadyTaken,
-    EmailAddressStatus,
-    IEmailAddressSet,
-    InvalidEmailAddress,
-    )
-from canonical.launchpad.interfaces.lpstorm import (
-    IMasterStore,
-    IStore,
-    )
-from canonical.launchpad.testing.pages import LaunchpadWebServiceCaller
-from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.answers.model.answercontact import AnswerContact
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.blueprints.model.specification import Specification
@@ -75,7 +55,7 @@
     IMasterStore,
     IStore,
     )
-from lp.services.database.sqlbase import cursor
+from lp.services.database.sqlbase import cursor, sqlvalues
 from lp.services.identity.interfaces.account import (
     AccountCreationRationale,
     AccountStatus,

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2011-12-30 06:14:56 +0000
+++ lib/lp/security.py	2012-01-04 15:38:28 +0000
@@ -107,6 +107,7 @@
 from lp.registry.interfaces.irc import IIrcID
 from lp.registry.interfaces.location import IPersonLocation
 from lp.registry.interfaces.milestone import (
+    IAbstractMilestone,
     IMilestone,
     IProjectGroupMilestone,
     )
@@ -430,8 +431,8 @@
 
 
 class ViewMilestone(AnonymousAuthorization):
-    """Anyone can view an IMilestone."""
-    usedfor = IMilestone
+    """Anyone can view an IMilestone or an IProjectGroupMilestone."""
+    usedfor = IAbstractMilestone
 
 
 class EditSpecificationBranch(AuthorizationBase):

=== modified file 'lib/lp/services/mail/tests/emails/x-unknown-encoding.txt'
--- lib/lp/services/mail/tests/emails/x-unknown-encoding.txt	2009-07-03 09:20:34 +0000
+++ lib/lp/services/mail/tests/emails/x-unknown-encoding.txt	2012-01-04 15:38:28 +0000
@@ -1,7 +1,7 @@
 MIME-Version: 1.0
 Date: Wed, 13 Apr 2005 19:20:48 -0400
 Message-ID: <bbe079320907030136g23826072s7a653e75f8ecdd58@xxxxxxxxxxxxxxxx>
-Subject: Uknown Encoding
+Subject: Unknown Encoding
 From: test@xxxxxxxxxxxxx
 To: someone@xxxxxxxxxxx
 X-Original-To: someone@xxxxxxxxxxx
@@ -9,4 +9,3 @@
 Content-Transfer-Encoding: QUOTED-PRINTABLE
 
 Mysterious, intriguing, the encoding is unknown.
-


Follow ups