← 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:
  Canonical Launchpad Engineering (launchpad)

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

= 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/87484
Your team Launchpad Yellow Squad is subscribed to branch lp:~yellow/launchpad/bug-904335-devel-base.
=== modified file 'database/sampledata/current-dev.sql'
--- database/sampledata/current-dev.sql	2011-12-13 15:21:25 +0000
+++ database/sampledata/current-dev.sql	2012-01-04 14:49:20 +0000
@@ -1,6 +1,6 @@
 -- Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 -- GNU Affero General Public License version 3 (see the file LICENSE).
--- Created using pg_dump (PostgreSQL) 8.4.8
+-- Created using pg_dump (PostgreSQL) 8.4.9
 
 SET check_function_bodies = false;
 SET client_encoding = 'UTF8';
@@ -5752,6 +5752,13 @@
 ALTER TABLE messagechunk ENABLE TRIGGER ALL;
 
 
+ALTER TABLE milestonetag DISABLE TRIGGER ALL;
+
+
+
+ALTER TABLE milestonetag ENABLE TRIGGER ALL;
+
+
 ALTER TABLE mirror DISABLE TRIGGER ALL;
 
 

=== modified file 'database/sampledata/current.sql'
--- database/sampledata/current.sql	2011-12-13 15:21:25 +0000
+++ database/sampledata/current.sql	2012-01-04 14:49:20 +0000
@@ -1,6 +1,6 @@
 -- Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 -- GNU Affero General Public License version 3 (see the file LICENSE).
--- Created using pg_dump (PostgreSQL) 8.4.8
+-- Created using pg_dump (PostgreSQL) 8.4.9
 
 SET check_function_bodies = false;
 SET client_encoding = 'UTF8';
@@ -5682,6 +5682,13 @@
 ALTER TABLE messagechunk ENABLE TRIGGER ALL;
 
 
+ALTER TABLE milestonetag DISABLE TRIGGER ALL;
+
+
+
+ALTER TABLE milestonetag ENABLE TRIGGER ALL;
+
+
 ALTER TABLE mirror DISABLE TRIGGER ALL;
 
 

=== modified file 'database/schema/comments.sql'
--- database/schema/comments.sql	2011-11-17 13:06:30 +0000
+++ database/schema/comments.sql	2012-01-04 14:49:20 +0000
@@ -2425,3 +2425,8 @@
 'OpenId Identifiers that can be used to log into an Account.';
 COMMENT ON COLUMN OpenIdIdentifier.identifier IS
 'OpenId Identifier. This should be a URL, but is currently just a token that can be used to generate the Identity URL for the Canonical SSO OpenId Provider.';
+
+-- MilestoneTag
+COMMENT ON TABLE milestonetag IS 'Attaches simple text tags to a milestone.';
+COMMENT ON COLUMN milestonetag.milestone IS 'The milestone the tag is attached to.';
+COMMENT ON COLUMN milestonetag.tag IS 'The text representation of the tag.';

=== added file 'database/schema/patch-2209-00-3.sql'
--- database/schema/patch-2209-00-3.sql	1970-01-01 00:00:00 +0000
+++ database/schema/patch-2209-00-3.sql	2012-01-04 14:49:20 +0000
@@ -0,0 +1,22 @@
+-- Copyright 2011 Canonical Ltd.  This software is licensed under the
+-- GNU Affero General Public License version 3 (see the file LICENSE).
+
+SET client_min_messages=ERROR;
+
+CREATE TABLE milestonetag (
+    id SERIAL PRIMARY KEY,
+    milestone integer NOT NULL REFERENCES milestone ON DELETE CASCADE,
+    tag text NOT NULL,
+    date_created timestamp without time zone DEFAULT
+        timezone('UTC'::text, now()) NOT NULL,
+    created_by integer NOT NULL REFERENCES person,
+    CONSTRAINT valid_tag CHECK (valid_name(tag))
+);
+
+ALTER TABLE ONLY milestonetag
+    ADD CONSTRAINT milestonetag__tag__milestone__key UNIQUE (tag, milestone);
+
+CREATE INDEX milestonetag__milestones_idx
+    ON milestonetag USING btree (milestone);
+
+INSERT INTO LaunchpadDatabaseRevision VALUES (2209, 0, 3);

=== added file 'database/schema/patch-2209-00-4.sql'
--- database/schema/patch-2209-00-4.sql	1970-01-01 00:00:00 +0000
+++ database/schema/patch-2209-00-4.sql	2012-01-04 14:49:20 +0000
@@ -0,0 +1,57 @@
+SET client_min_messages=ERROR;
+
+CREATE OR REPLACE FUNCTION check_email_address_person_account(
+    person integer, account integer)
+    RETURNS boolean
+    LANGUAGE plpythonu IMMUTABLE RETURNS NULL ON NULL INPUT AS
+$$
+    # It's possible for an EmailAddress to be created without an
+    # account. If that happens, and this function is called, we return
+    # True so as to avoid breakages.
+    if account is None:
+        return True
+    results = plpy.execute("""
+        SELECT account FROM Person WHERE id = %s""" % person)
+    # If there are no accounts with that Person in the DB, or the Person
+    # is new and hasn't yet been linked to an account, return success
+    # anyway. This helps avoid the PGRestore breaking (and referential
+    # integrity will prevent this from causing bugs later.
+    if results.nrows() == 0 or results[0]['account'] is None:
+        return True
+    return results[0]['account'] == account
+$$;
+
+COMMENT ON FUNCTION check_email_address_person_account(integer, integer) IS
+'Check that the person to which an email address is linked has the same account as that email address.';
+
+CREATE OR REPLACE FUNCTION check_person_email_address_account(
+    person integer, account integer)
+    RETURNS boolean
+    LANGUAGE plpythonu IMMUTABLE RETURNS NULL ON NULL INPUT AS
+$$
+    # It's possible for a Person to be created without an account. If
+    # that happens, return True so that things don't break.
+    if account is None:
+        return True
+    email_address_accounts = plpy.execute("""
+        SELECT account FROM EmailAddress WHERE
+            person = %s AND account IS NOT NULL""" % person)
+    # If there are no email address accounts to check, we're done.
+    if email_address_accounts.nrows() == 0:
+        return True
+    for email_account_row in email_address_accounts:
+        email_account = email_account_row['account']
+        if email_account is not None and email_account != account:
+            return False
+    return True
+$$;
+
+COMMENT ON FUNCTION check_person_email_address_account(integer, integer) IS
+'Check that the email addresses linked to a person have the same account ID as that person.';
+
+ALTER TABLE EmailAddress ADD CONSTRAINT valid_account_for_person
+    CHECK (check_email_address_person_account(person, account));
+ALTER TABLE Person ADD CONSTRAINT valid_account_for_emailaddresses
+    CHECK (check_person_email_address_account(id, account));
+
+INSERT INTO LaunchpadDatabaseRevision VALUES (2209, 00, 4);

=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2012-01-03 01:40:09 +0000
+++ database/schema/security.cfg	2012-01-04 14:49:20 +0000
@@ -18,6 +18,8 @@
 public.bug_update_latest_patch_uploaded(integer) =
 public.bugnotificationarchive              =
 public.calculate_bug_heat(integer)         = EXECUTE
+public.check_email_address_person_account(integer, integer) = EXECUTE
+public.check_person_email_address_account(integer, integer) = EXECUTE
 public.cursor_fetch(refcursor, integer)    = EXECUTE
 public.databasediskutilization             =
 public.debversion(character)                           = EXECUTE
@@ -221,6 +223,7 @@
 public.messageapproval                  = SELECT, INSERT, UPDATE, DELETE
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT, INSERT, UPDATE, DELETE
+public.milestonetag                     = SELECT, INSERT, UPDATE, DELETE
 public.mirrorcdimagedistroseries        = SELECT, INSERT, DELETE
 public.mirrordistroarchseries           = SELECT, INSERT, DELETE, UPDATE
 public.mirrordistroseriessource         = SELECT, INSERT, UPDATE, DELETE
@@ -591,6 +594,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.person                           = SELECT, INSERT, UPDATE
 public.personlanguage                   = SELECT
 public.personsettings                   = SELECT, INSERT
@@ -649,6 +653,7 @@
 public.karmaaction                        = SELECT
 public.message                            = SELECT, INSERT
 public.messagechunk                       = SELECT, INSERT
+public.milestonetag                       = SELECT
 public.person                             = SELECT
 public.revision                           = SELECT, INSERT, UPDATE
 public.revisionauthor                     = SELECT, INSERT, UPDATE
@@ -862,6 +867,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.packagecopyjob                   = SELECT, INSERT, DELETE
 public.packagecopyrequest               = SELECT, INSERT, UPDATE
 public.packagediff                      = SELECT, INSERT, UPDATE
@@ -1301,6 +1307,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.packagebuild                     = SELECT, INSERT, UPDATE
 public.packagecopyjob                   = SELECT, INSERT
 public.packagediff                      = SELECT, INSERT, UPDATE, DELETE
@@ -1404,6 +1411,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.packagebuild                     = SELECT, INSERT, UPDATE
 public.packagecopyjob                   = SELECT, INSERT, UPDATE
 public.packagediff                      = SELECT, UPDATE
@@ -1502,6 +1510,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT
 public.person                           = SELECT
 public.personlanguage                   = SELECT
 public.personsettings                   = SELECT
@@ -1706,6 +1715,7 @@
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
+public.milestonetag                     = SELECT, INSERT, DELETE
 public.packageset                       = SELECT
 public.packagesetgroup                  = SELECT
 public.packagesetinclusion              = SELECT
@@ -1946,6 +1956,7 @@
 public.job                              = SELECT, INSERT, UPDATE
 public.message                          = SELECT, INSERT
 public.messagechunk                     = SELECT, INSERT
+public.milestonetag                     = SELECT
 public.person                           = SELECT, INSERT
 public.personsettings                   = SELECT, INSERT
 public.product                          = SELECT, INSERT, UPDATE
@@ -2044,6 +2055,7 @@
 public.message                          = SELECT, UPDATE
 public.messageapproval                  = SELECT, UPDATE
 public.milestone                        = SELECT, UPDATE
+public.milestonetag                     = SELECT, INSERT, UPDATE, DELETE
 public.mirror                           = SELECT, UPDATE
 public.nameblacklist                    = SELECT, UPDATE
 public.oauthaccesstoken                 = SELECT, UPDATE
@@ -2146,6 +2158,7 @@
 public.job                              = SELECT, INSERT, DELETE
 public.logintoken                       = SELECT, DELETE
 public.mailinglistsubscription          = SELECT, DELETE
+public.milestonetag                     = SELECT
 public.oauthnonce                       = SELECT, DELETE
 public.openidconsumerassociation        = SELECT, DELETE
 public.openidconsumernonce              = SELECT, DELETE

=== 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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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 14:49:20 +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	2011-12-30 06:14:56 +0000
+++ lib/lp/registry/tests/test_person.py	2012-01-04 14:49:20 +0000
@@ -55,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,
@@ -847,8 +847,17 @@
         with celebrity_logged_in('admin'):
             email = from_person.preferredemail
             email.status = EmailAddressStatus.NEW
-            email.person = to_person
-            email.account = to_person.account
+            store = IMasterStore(EmailAddress)
+            # EmailAddress.acount and .person need to be updated at the
+            # same time to prevent the constraints on the account field
+            # from kicking the change out.
+            store.execute("""
+                UPDATE EmailAddress SET
+                    person = %s,
+                    account = %s
+                WHERE id = %s
+                """ % sqlvalues(
+                to_person.id, to_person.accountID, email.id))
         transaction.commit()
 
     def _do_merge(self, from_person, to_person, reviewer=None):

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2011-12-30 06:14:56 +0000
+++ lib/lp/security.py	2012-01-04 14:49:20 +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 14:49:20 +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.
-