launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06055
[Merge] lp:~bac/launchpad/904335-milestone-edit into lp:launchpad
Brad Crittenden has proposed merging lp:~bac/launchpad/904335-milestone-edit into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #904335 in Launchpad itself: "Project groups need a way to aggregate project milestones"
https://bugs.launchpad.net/launchpad/+bug/904335
For more details, see:
https://code.launchpad.net/~bac/launchpad/904335-milestone-edit/+merge/87794
Add the tags field to the milestone +edit page.
If there is a cleaner way to insert the tags field into the form I'm
open for suggestions.
Tests:
bin/test -vv lp.registry.browser.tests.test_milestone TestMilestoneEditView
No lint.
--
https://code.launchpad.net/~bac/launchpad/904335-milestone-edit/+merge/87794
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~bac/launchpad/904335-milestone-edit into lp:launchpad.
=== 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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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-05 21:38:46 +0000
+++ database/schema/security.cfg 2012-01-06 18:30:42 +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
@@ -592,6 +595,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
@@ -650,6 +654,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
@@ -863,6 +868,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
@@ -1302,6 +1308,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
@@ -1405,6 +1412,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
@@ -1503,6 +1511,7 @@
public.message = SELECT, INSERT
public.messagechunk = SELECT, INSERT
public.milestone = SELECT
+public.milestonetag = SELECT
public.person = SELECT
public.personlanguage = SELECT
public.personsettings = SELECT
@@ -1707,6 +1716,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
@@ -1947,6 +1957,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
@@ -2045,6 +2056,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
@@ -2148,6 +2160,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 2012-01-05 18:12:05 +0000
+++ lib/lp/bugs/browser/bugtask.py 2012-01-06 18:30:42 +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-03 15:56:55 +0000
+++ lib/lp/bugs/interfaces/bugtask.py 2012-01-06 18:30:42 +0000
@@ -918,8 +918,7 @@
"""
def userHasBugSupervisorPrivileges(user):
- """Is the user a privledged one, allowed to changed details on a
- bug?
+ """Is the user privileged and allowed to change details on a bug?
:return: A boolean.
"""
@@ -1193,9 +1192,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 +1217,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/javascript/bug_tags_entry.js'
--- lib/lp/bugs/javascript/bug_tags_entry.js 2011-08-09 14:18:02 +0000
+++ lib/lp/bugs/javascript/bug_tags_entry.js 2012-01-06 18:30:42 +0000
@@ -46,9 +46,9 @@
tag_list_span.all(A).each(function(anchor) {
tags.push(anchor.get(INNER_HTML));
});
-
+
var tag_list = tags.join(' ');
- /* If there are tags then add a space to the end of the string so the user
+ /* If there are tags then add a space to the end of the string so the user
doesn't have to type one. */
if (tag_list != "") {
tag_list += ' ';
@@ -61,6 +61,13 @@
*/
var base_url = window.location.href.split('/+bug')[0] + '/+bugs?field.tag=';
+namespace.parse_tags = function(tag_string) {
+ var tags = Y.Array(
+ Y.Lang.trim(tag_string).split(new RegExp('\\s+'))).filter(
+ function(elem) { return elem !== ''; });
+ return tags;
+};
+
/**
* Save the currently entered tags and switch inline editing off.
*
@@ -68,9 +75,7 @@
*/
var save_tags = function() {
var lp_client = new Y.lp.client.Launchpad();
- var tags = Y.Array(
- Y.Lang.trim(tag_input.get(VALUE)).split(new RegExp('\\s+'))).filter(
- function(elem) { return elem !== ''; });
+ var tags = namespace.parse_tags(tag_input.get(VALUE));
var bug = new Y.lp.client.Entry(
lp_client, LP.cache[BUG], LP.cache[BUG].self_link);
bug.removeAttr('http_etag');
@@ -243,4 +248,3 @@
}, "0.1", {"requires": ["base", "io-base", "node", "substitute", "node-menunav",
"lazr.base", "lp.anim", "lazr.autocomplete",
"lp.client"]});
-
=== added file 'lib/lp/bugs/javascript/tests/test_bug_tags_entry.html'
--- lib/lp/bugs/javascript/tests/test_bug_tags_entry.html 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_tags_entry.html 2012-01-06 18:30:42 +0000
@@ -0,0 +1,25 @@
+<html>
+ <head>
+ <title>Launchpad bug tags entry</title>
+ <!-- YUI and test setup -->
+ <script type="text/javascript"
+ src="../../../../canonical/launchpad/icing/yui/yui/yui.js">
+ </script>
+ <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
+ <script type="text/javascript"
+ src="../../../app/javascript/testing/testrunner.js"></script>
+
+ <!-- Other dependencies -->
+ <script type="text/javascript" src="../../../app/javascript/lp.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/client.js"></script>
+ <script type="text/javascript" src="../../../app/javascript/testing/mockio.js"></script>
+
+ <!-- The module under test -->
+ <script type="text/javascript" src="../bug_tags_entry.js"></script>
+
+ <!-- The test suite -->
+ <script type="text/javascript" src="test_bug_tags_entry.js"></script>
+ </head>
+ <body class="yui3-skin-sam">
+ </body>
+</html>
=== added file 'lib/lp/bugs/javascript/tests/test_bug_tags_entry.js'
--- lib/lp/bugs/javascript/tests/test_bug_tags_entry.js 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/javascript/tests/test_bug_tags_entry.js 2012-01-06 18:30:42 +0000
@@ -0,0 +1,44 @@
+YUI().use('lp.testing.runner', 'lp.testing.mockio', 'test', 'console',
+ 'lp.client', 'node-event-simulate', 'lp.bugs.bug_tags_entry',
+ function(Y) {
+
+ var module = Y.lp.bugs.bug_tags_entry;
+ var suite = new Y.Test.Suite("Bug tags entry Tests");
+
+ suite.add(new Y.Test.Case({
+ name: 'Tags parsing',
+
+ test_empty_string: function() {
+ var tag_string = '';
+ var results = module.parse_tags(tag_string);
+ Y.ArrayAssert.itemsAreEqual([], results);
+ },
+
+ test_one_item: function() {
+ var tag_string = 'cow';
+ var results = module.parse_tags(tag_string);
+ Y.ArrayAssert.itemsAreEqual(['cow'], results);
+ },
+
+ test_two_items: function() {
+ var tag_string = 'cow pig';
+ var results = module.parse_tags(tag_string);
+ Y.ArrayAssert.itemsAreEqual(['cow', 'pig'], results);
+ },
+
+ test_spaces: function() {
+ var tag_string = ' ';
+ var results = module.parse_tags(tag_string);
+ Y.ArrayAssert.itemsAreEqual([], results);
+ },
+
+ test_items_with_spaces: function() {
+ var tag_string = ' cow pig chicken ';
+ var results = module.parse_tags(tag_string);
+ Y.ArrayAssert.itemsAreEqual(['cow', 'pig', 'chicken'], results);
+ }
+
+ }));
+
+ Y.lp.testing.Runner.run(suite);
+});
=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py 2012-01-02 17:10:14 +0000
+++ lib/lp/bugs/model/bugtask.py 2012-01-06 18:30:42 +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.
@@ -2872,12 +2899,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 2012-01-04 23:49:46 +0000
+++ lib/lp/hardwaredb/doc/hwdb.txt 2012-01-06 18:30:42 +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-06 18:30:42 +0000
@@ -1279,17 +1279,22 @@
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"/>
+ <browser:url
+ for="lp.registry.interfaces.milestonetag.IProjectGroupMilestoneTag"
+ path_expression="string:+tags/${name}"
+ rootsite="mainsite"
+ attribute_to_parent="target"/>
<browser:page
for="*"
name="+milestone-macros"
@@ -1297,7 +1302,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
@@ -1322,6 +1327,20 @@
class="lp.registry.browser.milestone.MilestoneWithoutCountsView"
permission="zope.Public"
template="../templates/productseries-milestone-table-row.pt"/>
+ <browser:pages
+ for="lp.registry.interfaces.milestonetag.IProjectGroupMilestoneTag"
+ class="lp.registry.browser.milestone.MilestoneTagView"
+ permission="zope.Public">
+ <browser:page
+ name="+index"
+ template="../templates/milestone-index.pt"/>
+
+ <!-- Project Group Milestone Tag Portlets -->
+
+ <browser:page
+ name="+portlet-milestone-tag-search"
+ template="../templates/milestone-tag-search.pt"/>
+ </browser:pages>
<browser:page
name="+edit"
for="lp.registry.interfaces.milestone.IMilestone"
=== 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-06 18:30:42 +0000
@@ -6,6 +6,7 @@
__metaclass__ = type
__all__ = [
+ 'ISearchMilestoneTagsForm',
'MilestoneAddView',
'MilestoneBreadcrumb',
'MilestoneContextMenu',
@@ -15,9 +16,12 @@
'MilestoneNavigation',
'MilestoneOverviewNavigationMenu',
'MilestoneSetNavigation',
+ 'MilestoneTagView',
'MilestoneWithoutCountsView',
'MilestoneView',
+ 'MilestoneViewMixin',
'ObjectMilestonesView',
+ 'validate_tags',
]
@@ -27,7 +31,10 @@
implements,
Interface,
)
-from zope.schema import Choice
+from zope.schema import (
+ Choice,
+ TextLine,
+ )
from lp import _
from lp.app.browser.launchpadform import (
@@ -35,7 +42,9 @@
custom_widget,
LaunchpadEditFormView,
LaunchpadFormView,
+ safe_action,
)
+from lp.app.validators.name import valid_name
from lp.app.widgets.date import DateWidget
from lp.bugs.browser.bugtask import BugTaskListingItem
from lp.bugs.browser.structuralsubscription import (
@@ -52,12 +61,16 @@
from lp.registry.browser.product import ProductDownloadFileMixin
from lp.registry.interfaces.distroseries import IDistroSeries
from lp.registry.interfaces.milestone import (
+ IAbstractMilestone,
IMilestone,
+ IMilestoneData,
IMilestoneSet,
IProjectGroupMilestone,
)
+from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
from lp.registry.interfaces.person import IPersonSet
from lp.registry.interfaces.product import IProduct
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
from lp.services.propertycache import cachedproperty
from lp.services.webapp import (
canonical_url,
@@ -84,16 +97,16 @@
class MilestoneNavigation(Navigation,
StructuralSubscriptionTargetTraversalMixin):
"""The navigation to traverse to a milestone."""
- usedfor = IMilestone
+ usedfor = IMilestoneData
class MilestoneBreadcrumb(Breadcrumb):
- """The Breadcrumb for an `IMilestone`."""
+ """The Breadcrumb for an `IMilestoneData`."""
@property
def text(self):
- milestone = IMilestone(self.context)
- if milestone.code_name:
+ milestone = IMilestoneData(self.context)
+ if hasattr(milestone, 'code_name') and milestone.code_name:
return '%s "%s"' % (milestone.name, milestone.code_name)
else:
return milestone.name
@@ -139,7 +152,7 @@
class MilestoneContextMenu(ContextMenu, MilestoneLinkMixin):
"""The menu for this milestone."""
- usedfor = IMilestone
+ usedfor = IMilestoneData
@cachedproperty
def links(self):
@@ -150,8 +163,8 @@
class MilestoneOverviewNavigationMenu(NavigationMenu, MilestoneLinkMixin):
- """Overview navigation menu for `IMilestone` objects."""
- usedfor = IMilestone
+ """Overview navigation menu for `IAbstractMilestone` objects."""
+ usedfor = IAbstractMilestone
facet = 'overview'
@cachedproperty
@@ -165,7 +178,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 = IMilestoneData
facet = 'overview'
links = ('edit', 'create_release')
@@ -181,38 +194,8 @@
links = ('edit', )
-class MilestoneView(LaunchpadView, ProductDownloadFileMixin):
- """A View for listing milestones and releases."""
- # XXX sinzui 2009-05-29 bug=381672: Extract the BugTaskListingItem rules
- # to a mixin so that MilestoneView and others can use it.
- implements(IMilestoneInline)
- show_series_context = False
-
- def __init__(self, context, request):
- """See `LaunchpadView`.
-
- This view may be used with a milestone or a release. The milestone
- and release (if it exists) are accessible as attributes. The context
- attribute will always be the milestone.
-
- :param context: `IMilestone` or `IProductRelease`.
- :param request: `ILaunchpadRequest`.
- """
- super(MilestoneView, self).__init__(context, request)
- if IMilestone.providedBy(context):
- self.milestone = context
- self.release = context.product_release
- else:
- self.milestone = context.milestone
- self.release = context
- self.context = self.milestone
-
- def initialize(self):
- """See `LaunchpadView`."""
- self.form = self.request.form
- self.processDeleteFiles()
- expose_structural_subscription_data_to_js(
- self.context, self.request, self.user)
+class MilestoneViewMixin(object):
+ """Common methods shared between MilestoneView and MilestoneTagView."""
@property
def expire_cache_minutes(self):
@@ -232,38 +215,19 @@
"""Return the HTML page title."""
return self.context.title
- def getReleases(self):
- """See `ProductDownloadFileMixin`."""
- return set([self.release])
-
- @cachedproperty
- def download_files(self):
- """The release's files as DownloadFiles."""
- if self.release is None or self.release.files.count() == 0:
- return None
- return list(self.release.files)
-
- # Listify and cache the specifications, ProductReleaseFiles and bugtasks
- # to avoid making the same query over and over again when evaluating in
- # the template.
+ # Listify and cache the specifications and bugtasks to avoid making
+ # the same query over and over again when evaluating in the template.
@cachedproperty
def specifications(self):
"""The list of specifications targeted to this milestone."""
return list(self.context.specifications)
@cachedproperty
- def product_release_files(self):
- """Files associated with this milestone."""
- return list(self.release.files)
-
- @cachedproperty
def _bugtasks(self):
"""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
@@ -359,6 +323,83 @@
return all_assignments
return all_assignments
+ @property
+ def is_project_milestone_tag(self):
+ """Check, if the current milestone is a project milestone tag.
+
+ Return true, if the current milestone is a project milestone tag,
+ else return False."""
+ return IProjectGroupMilestoneTag.providedBy(self.context)
+
+ @property
+ def is_project_milestone(self):
+ """Check, if the current milestone is a project milestone.
+
+ Return true, if the current milestone is a project milestone or
+ a project milestone tag, else return False."""
+ return (
+ IProjectGroupMilestone.providedBy(self.context) or
+ self.is_project_milestone_tag
+ )
+
+ @property
+ def has_bugs_or_specs(self):
+ """Does the milestone have any bugtasks and specifications?"""
+ return len(self.bugtasks) > 0 or len(self.specifications) > 0
+
+
+class MilestoneView(
+ LaunchpadView, MilestoneViewMixin, ProductDownloadFileMixin):
+ """A View for listing milestones and releases."""
+ # XXX sinzui 2009-05-29 bug=381672: Extract the BugTaskListingItem rules
+ # to a mixin so that MilestoneView and others can use it.
+ implements(IMilestoneInline)
+ show_series_context = False
+
+ def __init__(self, context, request):
+ """See `LaunchpadView`.
+
+ This view may be used with a milestone or a release. The milestone
+ and release (if it exists) are accessible as attributes. The context
+ attribute will always be the milestone.
+
+ :param context: `IMilestone` or `IProductRelease`.
+ :param request: `ILaunchpadRequest`.
+ """
+ super(MilestoneView, self).__init__(context, request)
+ if IMilestoneData.providedBy(context):
+ self.milestone = context
+ self.release = context.product_release
+ else:
+ self.milestone = context.milestone
+ self.release = context
+ self.context = self.milestone
+
+ def initialize(self):
+ """See `LaunchpadView`."""
+ self.form = self.request.form
+ self.processDeleteFiles()
+ expose_structural_subscription_data_to_js(
+ self.context, self.request, self.user)
+
+ def getReleases(self):
+ """See `ProductDownloadFileMixin`."""
+ return set([self.release])
+
+ @cachedproperty
+ def download_files(self):
+ """The release's files as DownloadFiles."""
+ if self.release is None or self.release.files.count() == 0:
+ return None
+ return list(self.release.files)
+
+ # Listify and cache ProductReleaseFiles to avoid making the same query
+ # over and over again when evaluating in the template.
+ @cachedproperty
+ def product_release_files(self):
+ """Files associated with this milestone."""
+ return list(self.release.files)
+
@cachedproperty
def total_downloads(self):
"""Total downloads of files associated with this milestone."""
@@ -373,19 +414,6 @@
"""
return IDistroSeries.providedBy(self.context.series_target)
- @property
- def is_project_milestone(self):
- """Check, if the current milestone is a project milestone.
-
- Return true, if the current milestone is a project milestone,
- else return False."""
- return IProjectGroupMilestone.providedBy(self.context)
-
- @property
- def has_bugs_or_specs(self):
- """Does the milestone have any bugtasks and specifications?"""
- return len(self.bugtasks) > 0 or len(self.specifications) > 0
-
class MilestoneWithoutCountsView(MilestoneView):
"""Show a milestone in a list of milestones."""
@@ -403,14 +431,35 @@
custom_widget('dateexpected', DateWidget)
+ def extendFields(self):
+ """See `LaunchpadFormView`.
+
+ Add a text-entry widget for milestone tags since there is not property
+ on the interface.
+ """
+ tag_entry = TextLine(
+ __name__='tags',
+ title=u'Tags',
+ required=False)
+ self.form_fields += form.Fields(
+ tag_entry, render_context=self.render_context)
+ # Make an instance attribute to avoid mutating the class attribute.
+ self.field_names = self.field_names[:]
+ # Insert the tags field before the summary.
+ summary_index = self.field_names.index('summary')
+ self.field_names[summary_index:summary_index] = [tag_entry.__name__]
+
@action(_('Register Milestone'), name='register')
def register_action(self, action, data):
"""Use the newMilestone method on the context to make a milestone."""
- self.context.newMilestone(
+ milestone = self.context.newMilestone(
name=data.get('name'),
code_name=data.get('code_name'),
dateexpected=data.get('dateexpected'),
summary=data.get('summary'))
+ tags = data.get('tags')
+ if tags:
+ milestone.setTags(tags.lower().split(), self.user)
self.next_url = canonical_url(self.context)
@property
@@ -443,7 +492,7 @@
return canonical_url(self.context)
@property
- def field_names(self):
+ def _field_names(self):
"""See `LaunchpadFormView`.
There are two series fields, one for for product milestones and the
@@ -459,6 +508,31 @@
names.append('productseries')
return names
+ @property
+ def initial_values(self):
+ tags = self.context.getTags()
+ tagstring = ' '.join(tags)
+ return dict(tags=tagstring)
+
+ def extendFields(self):
+ """See `LaunchpadFormView`.
+
+ Add a text-entry widget for milestone tags since there is not property
+ on the interface.
+ """
+ tag_entry = TextLine(
+ __name__='tags',
+ title=u'Tags',
+ required=False)
+ self.form_fields += form.Fields(
+ tag_entry, render_context=self.render_context)
+
+ # Make an instance attribute to avoid mutating the class attribute.
+ self.field_names = self._field_names[:]
+ # Insert the tags field before the summary.
+ summary_index = self.field_names.index('summary')
+ self.field_names[summary_index:summary_index] = [tag_entry.__name__]
+
def setUpFields(self):
"""See `LaunchpadFormView`.
@@ -483,7 +557,9 @@
@action(_('Update'), name='update')
def update_action(self, action, data):
"""Update the milestone."""
+ tags = data.pop('tags') or u''
self.updateContextFromData(data)
+ self.context.setTags(tags.lower().split(), self.user)
self.next_url = canonical_url(self.context)
@@ -533,6 +609,57 @@
self.next_url = canonical_url(series)
+def validate_tags(tags):
+ """Check that `separator` separated `tags` are valid tag names."""
+ return (
+ all(valid_name(tag) for tag in tags) and
+ len(set(tags)) == len(tags)
+ )
+
+
+class ISearchMilestoneTagsForm(Interface):
+ """Schema for the search milestone tags form."""
+
+ tags = TextLine(
+ title=_('Search by tags'),
+ description=_('Insert space separated tag names'),
+ required=True, min_length=2, max_length=64,
+ constraint=lambda value: validate_tags(value.split()))
+
+
+class MilestoneTagView(
+ LaunchpadFormView, MilestoneViewMixin, ProductDownloadFileMixin):
+ """A View for listing bugtasks and specification for milestone tags."""
+ schema = ISearchMilestoneTagsForm
+
+ def __init__(self, context, request):
+ """See `LaunchpadView`.
+
+ :param context: `IProjectGroupMilestoneTag`
+ :param request: `ILaunchpadRequest`.
+ """
+ super(MilestoneTagView, self).__init__(context, request)
+ self.context = self.milestone = context
+ self.release = None
+
+ @property
+ def tags(self):
+ """Return a list of tag names associated with current milestonetag."""
+ return self.context.name.split(u',')
+
+ @property
+ def initial_values(self):
+ """Set the initial value of the search tags field."""
+ return {'tags': u' '.join(self.tags)}
+
+ @safe_action
+ @action(u'Search Milestone Tags', name='search')
+ def search_by_tags(self, action, data):
+ tags = data['tags'].split()
+ milestone_tag = ProjectGroupMilestoneTag(self.context.target, tags)
+ self.next_url = canonical_url(milestone_tag, request=self.request)
+
+
class ObjectMilestonesView(LaunchpadView):
"""A view for listing the milestones for any `IHasMilestones` object"""
=== modified file 'lib/lp/registry/browser/project.py'
--- lib/lp/registry/browser/project.py 2012-01-01 02:58:52 +0000
+++ lib/lp/registry/browser/project.py 2012-01-06 18:30:42 +0000
@@ -30,6 +30,7 @@
'ProjectView',
]
+
from z3c.ptcompat import ViewPageTemplateFile
from zope.app.form.browser import TextWidget
from zope.component import getUtility
@@ -75,6 +76,7 @@
IRegistryCollectionNavigationMenu,
RegistryCollectionActionMenuBase,
)
+from lp.registry.browser.milestone import validate_tags
from lp.registry.browser.objectreassignment import ObjectReassignmentView
from lp.registry.browser.product import (
ProductAddView,
@@ -87,6 +89,7 @@
IProjectGroupSeries,
IProjectGroupSet,
)
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
from lp.services.feeds.browser import FeedsMixin
from lp.services.fields import (
PillarAliases,
@@ -130,6 +133,12 @@
def traverse_series(self, series_name):
return self.context.getSeries(series_name)
+ @stepthrough('+tags')
+ def traverse_tags(self, name):
+ tags = name.split(u',')
+ if validate_tags(tags):
+ return ProjectGroupMilestoneTag(self.context, tags)
+
class ProjectSetNavigation(Navigation):
@@ -388,6 +397,11 @@
"""
return self.context.products.count() > 10
+ @property
+ def project_group_milestone_tag(self):
+ """Return a ProjectGroupMilestoneTag based on this project."""
+ return ProjectGroupMilestoneTag(self.context, [])
+
class ProjectEditView(LaunchpadEditFormView):
"""View class that lets you edit a Project object."""
=== modified file 'lib/lp/registry/browser/tests/milestone-views.txt'
--- lib/lp/registry/browser/tests/milestone-views.txt 2011-12-30 08:13:14 +0000
+++ lib/lp/registry/browser/tests/milestone-views.txt 2012-01-06 18:30:42 +0000
@@ -508,12 +508,12 @@
>>> owner = firefox.owner
>>> login_person(owner)
- >>> view = create_view(firefox_1_0, '+addmilestone')
+ >>> view = create_initialized_view(firefox_1_0, '+addmilestone')
>>> print view.label
Register a new milestone
>>> view.field_names
- ['name', 'code_name', 'dateexpected', 'summary']
+ ['name', 'code_name', 'dateexpected', 'tags', 'summary']
The view provides an action_url and cancel_url properties that form
submitting the form or aborting the action.
=== modified file 'lib/lp/registry/browser/tests/test_milestone.py'
--- lib/lp/registry/browser/tests/test_milestone.py 2012-01-01 02:58:52 +0000
+++ lib/lp/registry/browser/tests/test_milestone.py 2012-01-06 18:30:42 +0000
@@ -13,6 +13,7 @@
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.bugs.interfaces.bugtask import IBugTaskSet
from lp.registry.interfaces.person import TeamSubscriptionPolicy
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
from lp.services.config import config
from lp.services.webapp import canonical_url
from lp.testing import (
@@ -44,8 +45,8 @@
self.product = self.factory.makeProduct()
self.series = (
self.factory.makeProductSeries(product=self.product))
- owner = self.product.owner
- login_person(owner)
+ self.owner = self.product.owner
+ login_person(self.owner)
def test_add_milestone(self):
form = {
@@ -64,7 +65,7 @@
}
view = create_initialized_view(
self.series, '+addmilestone', form=form)
- # It's important to make sure no errors occured, but
+ # It's important to make sure no errors occured,
# but also confirm that the milestone was created.
self.assertEqual([], view.errors)
self.assertEqual('1.1', self.product.milestones[0].name)
@@ -83,6 +84,63 @@
"like YYYY-MM-DD format. The year must be after 1900.")
self.assertEqual(expected_msg, error_msg)
+ def test_add_milestone_with_tags(self):
+ tags = u'zed alpha'
+ form = {
+ 'field.name': '1.1',
+ 'field.tags': tags,
+ 'field.actions.register': 'Register Milestone',
+ }
+ view = create_initialized_view(
+ self.series, '+addmilestone', form=form)
+ self.assertEqual([], view.errors)
+ expected = sorted(tags.split())
+ self.assertEqual(expected, list(self.product.milestones[0].getTags()))
+
+
+class TestMilestoneEditView(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ TestCaseWithFactory.setUp(self)
+ self.product = self.factory.makeProduct()
+ self.milestone = self.factory.makeMilestone(
+ name='orig-name', product=self.product)
+ self.owner = self.product.owner
+ login_person(self.owner)
+
+ def test_edit_milestone_with_tags(self):
+ orig_tags = u'b a c'
+ self.milestone.setTags(orig_tags.split(), self.owner)
+ new_tags = u'z a B'
+ form = {
+ 'field.name': 'new-name',
+ 'field.tags': new_tags,
+ 'field.actions.update': 'Update',
+ }
+ view = create_initialized_view(
+ self.milestone, '+edit', form=form)
+ self.assertEqual([], view.errors)
+ self.assertEqual('new-name', self.milestone.name)
+ expected = sorted(new_tags.lower().split())
+ self.assertEqual(expected, list(self.milestone.getTags()))
+
+ def test_edit_milestone_clear_tags(self):
+ orig_tags = u'b a c'
+ self.milestone.setTags(orig_tags.split(), self.owner)
+ form = {
+ 'field.name': 'new-name',
+ 'field.tags': '',
+ 'field.actions.update': 'Update',
+ }
+ view = create_initialized_view(
+ self.milestone, '+edit', form=form)
+ self.assertEqual([], view.errors)
+ self.assertEqual('new-name', self.milestone.name)
+ expected = []
+ self.assertEqual(expected, list(self.milestone.getTags()))
+
class TestMilestoneMemcache(MemcacheTestCase):
@@ -430,3 +488,73 @@
self.assertThat(self.milestone, browses_under_limit)
self.add_bug(10)
self.assertThat(self.milestone, browses_under_limit)
+
+
+class TestMilestoneTagView(TestQueryCountBase):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestMilestoneTagView, self).setUp()
+ self.tags = [u'tag1']
+ 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)
+ with person_logged_in(self.owner):
+ self.milestone.setTags(self.tags, self.owner)
+ self.milestonetag = ProjectGroupMilestoneTag(
+ target=self.project_group, tags=self.tags)
+
+ def add_bug(self, count):
+ with person_logged_in(self.owner):
+ for n in range(count):
+ self.factory.makeBug(
+ product=self.product, owner=self.owner,
+ milestone=self.milestone)
+
+ def _make_form(self, tags):
+ return {
+ u'field.actions.search': u'Search',
+ u'field.tags': u' '.join(tags),
+ }
+
+ def _url_tail(self, url, separator='/'):
+ return url.rsplit(separator, 1)[1],
+
+ def test_view_properties(self):
+ # Ensure that the view is correctly initialized.
+ view = create_initialized_view(self.milestonetag, '+index')
+ self.assertEqual(self.milestonetag, view.context)
+ self.assertEqual(self.milestonetag.title, view.page_title)
+ self.assertContentEqual(self.tags, view.tags)
+
+ def test_view_form_redirect(self):
+ # Ensure a correct redirection is performed when tags are searched.
+ tags = [u'tag1', u'tag2']
+ form = self._make_form(tags)
+ view = create_initialized_view(self.milestonetag, '+index', form=form)
+ self.assertEqual(302, view.request.response.getStatus())
+ new_milestonetag = ProjectGroupMilestoneTag(
+ target=self.project_group, tags=tags)
+ self.assertEqual(
+ self._url_tail(canonical_url(new_milestonetag)),
+ self._url_tail(view.request.response.getHeader('Location')))
+
+ def test_view_form_error(self):
+ # Ensure the form correctly handles invalid submissions.
+ tags = [u'tag1', u't'] # One char tag is not valid.
+ form = self._make_form(tags)
+ view = create_initialized_view(self.milestonetag, '+index', form=form)
+ self.assertEqual(1, len(view.errors))
+ self.assertEqual('tags', view.errors[0].field_name)
+
+ def test_bugtask_query_count(self):
+ # Ensure that a correct number of queries is executed for
+ # bugtasks retrieval.
+ bugtask_count = 10
+ self.assert_bugtasks_query_count(
+ self.milestonetag, bugtask_count, query_limit=11)
=== 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-06 18:30:42 +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"/>
@@ -1052,6 +1060,11 @@
<allow
interface="lp.registry.interfaces.milestone.IProjectGroupMilestone"/>
</class>
+ <class
+ class="lp.registry.model.milestonetag.ProjectGroupMilestoneTag">
+ <allow
+ interface="lp.registry.interfaces.milestonetag.IProjectGroupMilestoneTag"/>
+ </class>
<subscriber
for="lp.registry.interfaces.product.IProduct zope.lifecycleevent.interfaces.IObjectModifiedEvent"
handler="lp.registry.subscribers.product_modified"/>
@@ -1245,7 +1258,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"/>
=== added file 'lib/lp/registry/help/milestone-tags.html'
--- lib/lp/registry/help/milestone-tags.html 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/help/milestone-tags.html 2012-01-06 18:30:42 +0000
@@ -0,0 +1,20 @@
+<html>
+ <head>
+ <title>What is a milestone tag?</title>
+ <link rel="stylesheet" type="text/css"
+ href="/+icing/yui/cssreset/reset.css" />
+ <link rel="stylesheet" type="text/css"
+ href="/+icing/yui/cssfonts/fonts.css" />
+ <link rel="stylesheet" type="text/css"
+ href="/+icing/yui/cssbase/base.css" />
+ </head>
+ <body>
+ <h1>What is a milestone tag?</h1>
+
+ <p>
+ Milestone tags are a way to group milestones within a project group.
+ You can filter based on one or more tags to see the bugs and blueprints
+ assigned to all milestones that have the given tags.
+ </p>
+ </body>
+</html>
=== 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-06 18:30:42 +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',
]
@@ -23,6 +25,7 @@
export_factory_operation,
export_operation_as,
export_read_operation,
+ export_write_operation,
exported,
operation_for_version,
operation_parameters,
@@ -42,6 +45,7 @@
Bool,
Choice,
Int,
+ List,
TextLine,
)
@@ -97,20 +101,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 +162,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 +224,34 @@
release.
"""
+
+class IMilestone(IAbstractMilestone):
+ """Actual interface for milestones."""
+
+ export_as_webservice_entry()
+
+ @operation_parameters(
+ tags=List(
+ title=_("Tags for this milestone"),
+ description=_("Space-separated keywords for classifying "
+ "this milestone."),
+ value_type=TextLine()))
+ @call_with(user=REQUEST_USER)
+ @export_write_operation()
+ @operation_for_version('devel')
+ 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.
+ """
+
+ @export_read_operation()
+ @operation_for_version('devel')
+ def getTags():
+ """Return the milestone tags in alphabetical order."""
+
+
# Avoid circular imports
IBugTask['milestone'].schema = IMilestone
patch_plain_parameter_type(
@@ -249,8 +290,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-06 18:30:42 +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.
+ """
=== added file 'lib/lp/registry/javascript/__init__.py'
--- lib/lp/registry/javascript/__init__.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/__init__.py 2012-01-06 18:30:42 +0000
@@ -0,0 +1,1 @@
+"""Tests for lp.registry.javascript.tests module."""
=== modified file 'lib/lp/registry/javascript/milestoneoverlay.js'
--- lib/lp/registry/javascript/milestoneoverlay.js 2011-08-09 14:18:02 +0000
+++ lib/lp/registry/javascript/milestoneoverlay.js 2012-01-06 18:30:42 +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).
*
* A milestone form overlay that can create a milestone within any page.
@@ -14,6 +14,7 @@
var milestone_form_uri;
var series_uri;
var next_step;
+ var client_sync = false;
var save_new_milestone = function(data) {
@@ -33,14 +34,43 @@
milestone_form.hide();
// Reset the HTML form inside the widget.
milestone_form.get('contentBox').one('form').reset();
- next_step(parameters);
- };
-
- var client = new Y.lp.client.Launchpad();
+ if (Y.Lang.isValue(next_step)) {
+ next_step(parameters);
+ }
+ };
+
+ var set_milestone_tags = function(milestone) {
+ var tagstring = data['field.tags'][0].toLowerCase();
+ var tags = Y.lp.bugs.bug_tags_entry.parse_tags(tagstring);
+
+ if (tags.length === 0) {
+ return finish_new_milestone();
+ }
+
+ var parameters = {
+ tags: tags
+ };
+ milestone.named_post('setTags', {
+ parameters: parameters,
+ on: {
+ success: finish_new_milestone,
+ failure: function (ignore, response, args) {
+ var error_box = Y.one('#milestone-error');
+ var error_message = '<strong>' + response.statusText +
+ '</strong><p>' +
+ response.responseText +
+ '</p>';
+ milestone_form.showError(error_message);
+ }
+ }
+ });
+ };
+
+ var client = new Y.lp.client.Launchpad({sync: client_sync});
client.named_post(series_uri, 'newMilestone', {
parameters: parameters,
on: {
- success: finish_new_milestone,
+ success: set_milestone_tags,
failure: function (ignore, response, args) {
var error_box = Y.one('#milestone-error');
var error_message = '<strong>' + response.statusText +
@@ -52,7 +82,7 @@
}
});
};
-
+ module.save_new_milestone = save_new_milestone;
var setup_milestone_form = function () {
var form_submit_button = Y.Node.create(
@@ -70,6 +100,7 @@
Y.lp.app.calendar.add_calendar_widgets();
milestone_form.show();
};
+ module.setup_milestone_form = setup_milestone_form;
var show_milestone_form = function(e) {
e.preventDefault();
@@ -82,6 +113,29 @@
}
};
+ var configure = function(config) {
+ if (config === undefined) {
+ throw new Error(
+ "Missing attach_widget config for milestoneoverlay.");
+ }
+ if (config.milestone_form_uri === undefined ||
+ config.series_uri === undefined) {
+ throw new Error(
+ "attach_widget config for milestoneoverlay has " +
+ "undefined properties.");
+ }
+ milestone_form_uri = config.milestone_form_uri;
+ series_uri = config.series_uri;
+ // For testing purposes next_step may be undefined.
+ if (Y.Lang.isValue(config.next_step)) {
+ next_step = config.next_step;
+ }
+ if (Y.Lang.isValue(config.sync)) {
+ client_sync = config.sync;
+ }
+ };
+ module.configure = configure;
+
/**
* Attaches a milestone form overlay widget to an element.
*
@@ -100,20 +154,7 @@
if (Y.UA.ie) {
return;
}
- if (config === undefined) {
- throw new Error(
- "Missing attach_widget config for milestoneoverlay.");
- }
- if (config.milestone_form_uri === undefined ||
- config.series_uri === undefined ||
- config.next_step === undefined) {
- throw new Error(
- "attach_widget config for milestoneoverlay has " +
- "undefined properties.");
- }
- milestone_form_uri = config.milestone_form_uri;
- series_uri = config.series_uri;
- next_step = config.next_step;
+ configure(config);
config.activate_node.on('click', show_milestone_form);
};
@@ -123,5 +164,6 @@
"lp.anim",
"lazr.formoverlay",
"lp.app.calendar",
- "lp.client"
+ "lp.client",
+ "lp.bugs.bug_tags_entry"
]});
=== added file 'lib/lp/registry/javascript/tests/__init__.py'
--- lib/lp/registry/javascript/tests/__init__.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/__init__.py 2012-01-06 18:30:42 +0000
@@ -0,0 +1,1 @@
+"""Tests for lp.registry.javascript.tests module."""
=== added file 'lib/lp/registry/javascript/tests/test_milestone_creation.js'
--- lib/lp/registry/javascript/tests/test_milestone_creation.js 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/test_milestone_creation.js 2012-01-06 18:30:42 +0000
@@ -0,0 +1,172 @@
+YUI({
+ base: '/+icing/yui/',
+ filter: 'raw', combine: false, fetchCSS: false
+}).use('test',
+ 'lp.client',
+ 'lp.testing.serverfixture',
+ 'lp.registry.milestonetable',
+ 'lp.registry.milestoneoverlay',
+ function(Y) {
+
+/**
+ * Integration tests for milestoneoverlay.
+ */
+var suite = new Y.Test.Suite("lp.registry.javascript.milestoneoverlay Tests");
+var serverfixture = Y.lp.testing.serverfixture;
+// Define the module-under-test.
+var the_module = Y.lp.registry.milestoneoverlay;
+
+var makeTestConfig = function(config) {
+ if (Y.Lang.isUndefined(config)) {
+ config = {};
+ }
+ config.on = Y.merge(
+ {
+ success: function(result) {
+ config.successful = true;
+ config.result = result;
+ },
+ failure: function(tid, response, args) {
+ config.successful = false;
+ config.result = {tid: tid, response: response, args: args};
+ }
+ },
+ config.on);
+ return config;
+};
+
+/**
+ * Test milestoneoverlay interaction via the API, such as creating
+ * milestones and adding tags.
+ */
+suite.add(new Y.Test.Case({
+ name: 'Milestone creation tests',
+
+ tearDown: function() {
+ // Always do this.
+ serverfixture.teardown(this);
+ },
+
+ test_configure: function() {
+ // Ensure configuring the module works as it will be needed in
+ // subsequent tests.
+ var config = {
+ milestone_form_uri: 'a',
+ series_uri: 'b',
+ next_step: 'c'
+ };
+ the_module.configure(config);
+ },
+
+ test_milestone_test_fixture_setup: function() {
+ // Setup the fixture, retrieving the objects we need for the test.
+ var data = serverfixture.setup(this, 'setup');
+ var client = new Y.lp.client.Launchpad({sync: true});
+ var config = makeTestConfig();
+ var product = new Y.lp.client.Entry(
+ client, data.product, data.product.self_link);
+ var product_name = product.get('name');
+ Y.Assert.areEqual('my-test-project', product_name);
+ },
+
+ test_milestone_creation_no_tags: function() {
+ // Setup the fixture, retrieving the objects we need for the test.
+ var data = serverfixture.setup(this, 'setup');
+
+ // Initialize the milestoneoverlay module.
+ var milestone_table = Y.lp.registry.milestonetable;
+ var config = {
+ milestone_form_uri: data.milestone_form_uri,
+ series_uri: data.series_uri,
+ //next_step: milestone_table.get_milestone_row,
+ sync: true
+ };
+ the_module.configure(config);
+ the_module.setup_milestone_form();
+
+ var milestone_name = 'new-milestone';
+ var code_name = 'new-codename';
+ var params = {
+ 'field.name': [milestone_name],
+ 'field.code_name': [code_name],
+ 'field.dateexpected': [''],
+ 'field.tags': [''],
+ 'field.summary': ['']
+ };
+
+ // Test the creation of the new milestone.
+ the_module.save_new_milestone(params);
+
+ // Verify the milestone was created.
+ var client = new Y.lp.client.Launchpad({sync: true});
+ var product = new Y.lp.client.Entry(
+ client, data.product, data.product.self_link);
+ config = makeTestConfig({parameters: {name: milestone_name}});
+ // Get the new milestone.
+ product.named_get('getMilestone', config);
+ Y.Assert.isTrue(config.successful, 'Getting milestone failed');
+ var milestone = config.result;
+ Y.Assert.isInstanceOf(Y.lp.client.Entry, milestone);
+ Y.Assert.areEqual(milestone_name, milestone.get('name'));
+ Y.Assert.areEqual(code_name, milestone.get('code_name'));
+ // Ensure no tags are created.
+ config = makeTestConfig({parameters: {}});
+ milestone.named_get('getTags', config);
+ Y.Assert.isTrue(config.successful, 'call to getTags failed');
+ var expected = [];
+ Y.ArrayAssert.itemsAreEqual(expected, config.result);
+ },
+
+ test_milestone_creation_with_tags: function() {
+ // Setup the fixture, retrieving the objects we need for the test.
+ var data = serverfixture.setup(this, 'setup');
+
+ // Initialize the milestoneoverlay module.
+ var milestone_table = Y.lp.registry.milestonetable;
+ var config = {
+ milestone_form_uri: data.milestone_form_uri,
+ series_uri: data.series_uri,
+ //next_step: milestone_table.get_milestone_row,
+ sync: true
+ };
+ the_module.configure(config);
+ the_module.setup_milestone_form();
+
+ var milestone_name = 'new-milestone';
+ var code_name = 'new-codename';
+ var tags = ['zeta alpha beta'];
+ var params = {
+ 'field.name': [milestone_name],
+ 'field.code_name': [code_name],
+ 'field.dateexpected': [''],
+ 'field.tags': tags,
+ 'field.summary': ['']
+ };
+
+ // Test the creation of the new milestone.
+ the_module.save_new_milestone(params);
+
+ // Verify the milestone was created.
+ var client = new Y.lp.client.Launchpad({sync: true});
+ var product = new Y.lp.client.Entry(
+ client, data.product, data.product.self_link);
+ config = makeTestConfig({parameters: {name: milestone_name}});
+ // Get the new milestone.
+ product.named_get('getMilestone', config);
+ Y.Assert.isTrue(config.successful, 'Getting milestone failed');
+ var milestone = config.result;
+ Y.Assert.isInstanceOf(Y.lp.client.Entry, milestone);
+ Y.Assert.areEqual(milestone_name, milestone.get('name'));
+ Y.Assert.areEqual(code_name, milestone.get('code_name'));
+ // Ensure the tags are created.
+ config = makeTestConfig({parameters: {}});
+ milestone.named_get('getTags', config);
+ Y.Assert.isTrue(config.successful, 'call to getTags failed');
+ var expected = ["alpha", "beta", "zeta"];
+ Y.ArrayAssert.itemsAreEqual(expected, config.result);
+ }
+}));
+
+// The last line is necessary. Include it.
+serverfixture.run(suite);
+});
=== added file 'lib/lp/registry/javascript/tests/test_milestone_creation.py'
--- lib/lp/registry/javascript/tests/test_milestone_creation.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/tests/test_milestone_creation.py 2012-01-06 18:30:42 +0000
@@ -0,0 +1,39 @@
+# Copyright 2011-2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Support for the lp.registry.javascript.milestoneoverlay YUIXHR tests.
+"""
+
+__metaclass__ = type
+__all__ = []
+
+from lp.services.webapp.publisher import canonical_url
+from lp.testing import person_logged_in
+from lp.testing.factory import LaunchpadObjectFactory
+from lp.testing.yuixhr import (
+ login_as_person,
+ make_suite,
+ setup,
+ )
+
+
+factory = LaunchpadObjectFactory()
+
+
+@setup
+def setup(request, data):
+ owner = factory.makePerson()
+ with person_logged_in(owner):
+ product = factory.makeProduct(name="my-test-project", owner=owner)
+ product_series = factory.makeProductSeries(
+ name="new-series", product=product)
+ data['product'] = product
+ data['series_uri'] = canonical_url(
+ product_series, path_only_if_possible=True)
+ data['milestone_form_uri'] = (
+ canonical_url(product_series) + '/+addmilestone/++form++')
+ login_as_person(owner)
+
+
+def test_suite():
+ return make_suite(__name__)
=== modified file 'lib/lp/registry/model/distroseries.py'
--- lib/lp/registry/model/distroseries.py 2012-01-03 05:05:39 +0000
+++ lib/lp/registry/model/distroseries.py 2012-01-06 18:30:42 +0000
@@ -1408,12 +1408,15 @@
return distroarchseries
def newMilestone(self, name, dateexpected=None, summary=None,
- code_name=None):
+ code_name=None, tags=None):
"""See `IDistroSeries`."""
- return Milestone(
+ milestone = Milestone(
name=name, code_name=code_name,
dateexpected=dateexpected, summary=summary,
distribution=self.distribution, distroseries=self)
+ if tags:
+ milestone.setTags(tags.split())
+ return milestone
def getLatestUploads(self):
"""See `IDistroSeries`."""
=== 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-06 18:30:42 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2012 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 list(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-06 18:30:42 +0000
@@ -0,0 +1,97 @@
+# 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
+ self.active = True
+ self.dateexpected = None
+
+ @property
+ def name(self):
+ return u','.join(self.tags)
+
+ @property
+ def displayname(self):
+ """See IMilestone."""
+ return "%s %s" % (self.target.displayname, 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/product.py'
--- lib/lp/registry/model/product.py 2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/product.py 2012-01-06 18:30:42 +0000
@@ -926,10 +926,11 @@
def getMilestone(self, name):
"""See `IProduct`."""
- return Milestone.selectOne("""
+ results = Milestone.selectOne("""
product = %s AND
name = %s
""" % sqlvalues(self.id, name))
+ return results
def createBug(self, bug_params):
"""See `IBugTarget`."""
=== modified file 'lib/lp/registry/model/productseries.py'
--- lib/lp/registry/model/productseries.py 2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/productseries.py 2012-01-06 18:30:42 +0000
@@ -541,11 +541,14 @@
return history
def newMilestone(self, name, dateexpected=None, summary=None,
- code_name=None):
+ code_name=None, tags=None):
"""See IProductSeries."""
- return Milestone(
+ milestone = Milestone(
name=name, dateexpected=dateexpected, summary=summary,
product=self.product, productseries=self, code_name=code_name)
+ if tags:
+ milestone.setTags(tags.split())
+ return milestone
def getTemplatesCollection(self):
"""See `IHasTranslationTemplates`."""
=== 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-06 18:30:42 +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/stories/project/xx-project-index.txt'
--- lib/lp/registry/stories/project/xx-project-index.txt 2011-12-08 19:18:27 +0000
+++ lib/lp/registry/stories/project/xx-project-index.txt 2012-01-06 18:30:42 +0000
@@ -34,7 +34,6 @@
Gnome Applets
NetApplet
gnomebaker
- ...
The projects are linked.
=== modified file 'lib/lp/registry/templates/milestone-index.pt'
--- lib/lp/registry/templates/milestone-index.pt 2011-10-12 00:21:07 +0000
+++ lib/lp/registry/templates/milestone-index.pt 2012-01-06 18:30:42 +0000
@@ -39,11 +39,15 @@
milestone_menu view/milestone/menu:overview;
has_edit context/required:launchpad.Edit">
+ <tal:search_by_tags_form
+ content="structure context/@@+portlet-milestone-tag-search"
+ condition="view/is_project_milestone_tag"/>
+
<div class="top-portlet">
- <div id="description" tal:condition="view/milestone/summary">
+ <div id="description" tal:condition="view/milestone/summary|nothing">
<p class="documentDescription"
tal:content="structure view/milestone/summary/fmt:text-to-html">
- Milestone.summary.
+ Milestone summary.
</p>
</div>
</div>
@@ -67,11 +71,12 @@
</dl>
<dl id="version">
- <dt>Version:</dt>
+ <dt tal:condition="view/is_project_milestone_tag">Tags:</dt>
+ <dt tal:condition="not: view/is_project_milestone_tag">Version:</dt>
<dd><tal:version replace="context/name" /></dd>
</dl>
- <dl id="code-name">
+ <dl id="code-name" tal:condition="view/milestone/code_name|nothing">
<dt>Code name:</dt>
<dd>
<tal:code-name replace="view/milestone/code_name" />
@@ -80,40 +85,42 @@
</dd>
</dl>
- <dl tal:condition="not: view/release">
- <dt>Expected:</dt>
- <dd><span
- tal:attributes="title context/dateexpected/fmt:datetime"
- tal:content="context/dateexpected/fmt:approximatedate" />
-
- <a tal:replace="structure milestone_menu/create_release/fmt:icon-link" />
- </dd>
- </dl>
-
- <tal:has_release condition="view/release">
- <dl>
- <dt>Released:</dt>
+ <tal:is_not_project_milestone_tag condition="not: view/is_project_milestone_tag">
+ <dl tal:condition="not: view/release">
+ <dt>Expected:</dt>
<dd><span
- tal:attributes="title view/release/datereleased/fmt:datetime"
- tal:content="view/release/datereleased/fmt:approximatedate" />
+ tal:attributes="title context/dateexpected/fmt:datetime"
+ tal:content="context/dateexpected/fmt:approximatedate" />
- <a tal:replace="structure release_menu/delete/fmt:icon" />
- <a tal:replace="structure release_menu/edit/fmt:icon" />
- </dd>
- </dl>
-
- <dl>
- <dt>Registrant:</dt>
- <dd><tal:registrant replace="structure view/release/owner/fmt:link"/></dd>
- </dl>
-
- <dl>
- <dt>Release registered:</dt>
- <dd><span
- tal:attributes="title view/release/datecreated/fmt:datetime"
- tal:content="view/release/datecreated/fmt:approximatedate" /></dd>
- </dl>
- </tal:has_release>
+ <a tal:replace="structure milestone_menu/create_release/fmt:icon-link" />
+ </dd>
+ </dl>
+
+ <tal:has_release condition="view/release">
+ <dl>
+ <dt>Released:</dt>
+ <dd><span
+ tal:attributes="title view/release/datereleased/fmt:datetime"
+ tal:content="view/release/datereleased/fmt:approximatedate" />
+
+ <a tal:replace="structure release_menu/delete/fmt:icon" />
+ <a tal:replace="structure release_menu/edit/fmt:icon" />
+ </dd>
+ </dl>
+
+ <dl>
+ <dt>Registrant:</dt>
+ <dd><tal:registrant replace="structure view/release/owner/fmt:link"/></dd>
+ </dl>
+
+ <dl>
+ <dt>Release registered:</dt>
+ <dd><span
+ tal:attributes="title view/release/datecreated/fmt:datetime"
+ tal:content="view/release/datecreated/fmt:approximatedate" /></dd>
+ </dl>
+ </tal:has_release>
+ </tal:is_not_project_milestone_tag>
</div>
<dl tal:condition="not: view/is_project_milestone">
@@ -310,7 +317,7 @@
milestones for
<tal:project replace="view/milestone/target/project/displayname" /></a>
</li>
- <li tal:condition="view/milestone/series_target">
+ <li tal:condition="view/milestone/series_target|nothing">
<a class="sprite info"
tal:attributes="href view/milestone/series_target/fmt:url">View
releases for the
=== added file 'lib/lp/registry/templates/milestone-tag-search.pt'
--- lib/lp/registry/templates/milestone-tag-search.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/templates/milestone-tag-search.pt 2012-01-06 18:30:42 +0000
@@ -0,0 +1,40 @@
+<tal:root
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ omit-tag="">
+
+ <form
+ action="."
+ tal:attributes="action view/action_url"
+ name="launchpadform"
+ method="get"
+ accept-charset="UTF-8">
+
+ <p class="error message"
+ tal:condition="view/errors"
+ tal:content="view/error_count" />
+
+ <p class="error message"
+ tal:repeat="form_wide_error view/form_wide_errors"
+ tal:content="structure form_wide_error">
+ Schema validation errors.
+ </p>
+
+ <p>
+ <tal:tags replace="structure view/widgets/tags" />
+ <input tal:replace="structure view/search_by_tags/render" />
+ <a target="help" href="/+help-registry/milestone-tags.html" class="sprite maybe">
+ <span class="invisible-link">Milestone Tags help</span>
+ </a>
+ </p>
+
+ <div class="error" tal:condition="view/errors">
+ <div class="message">Invalid tag name.</div>
+ </div>
+
+ <p class="formHelp" tal:content="view/widgets/tags/hint">Some Help Text</p>
+
+ </form>
+
+</tal:root>
=== modified file 'lib/lp/registry/templates/project-index.pt'
--- lib/lp/registry/templates/project-index.pt 2011-06-16 13:50:58 +0000
+++ lib/lp/registry/templates/project-index.pt 2012-01-06 18:30:42 +0000
@@ -109,6 +109,15 @@
<tal:has-many-project condition="view/has_many_projects">
<tal:sprints content="structure context/@@+portlet-coming-sprints" />
</tal:has-many-project>
+
+ <h2>Milestones</h2>
+ <ul>
+ <li>
+ <a tal:replace="structure context/menu:overview/milestones/fmt:link" />
+ </li>
+ </ul>
+ <tal:search_by_tags_form
+ content="structure view/project_group_milestone_tag/@@+portlet-milestone-tag-search" />
</div>
<div class="yui-u" id="products">
@@ -128,9 +137,6 @@
<li tal:condition="context/menu:overview/new_product/enabled">
<a tal:replace="structure context/menu:overview/new_product/fmt:link" />
</li>
- <li>
- <a tal:replace="structure context/menu:overview/milestones/fmt:link" />
- </li>
</ul>
</div>
=== 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-06 18:30:42 +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-06 18:30:42 +0000
@@ -0,0 +1,208 @@
+# Copyright 2011-2012 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
+
+import transaction
+
+from lp.testing.layers import (
+ AppServerLayer,
+ DatabaseFunctionalLayer,
+ )
+from lp.registry.model.milestonetag import ProjectGroupMilestoneTag
+from lp.testing import (
+ person_logged_in,
+ TestCaseWithFactory,
+ ws_object,
+ )
+
+
+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([], 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), 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), 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([], 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)
+
+
+class MilestoneTagWebserviceTest(TestCaseWithFactory):
+ """Test the getter and setter for milestonetags."""
+
+ layer = AppServerLayer
+
+ def setUp(self):
+ super(MilestoneTagWebserviceTest, self).setUp()
+ self.owner = self.factory.makePerson()
+ self.product = self.factory.makeProduct(owner=self.owner)
+ self.milestone = self.factory.makeMilestone(product=self.product)
+ transaction.commit()
+ self.service = self.factory.makeLaunchpadService(self.owner)
+ self.ws_milestone = ws_object(
+ self.service, self.milestone)
+
+ def test_get_tags_none(self):
+ self.assertEqual([], self.ws_milestone.getTags())
+
+ def test_get_tags(self):
+ tags = [u'zeta', u'alpha', u'beta']
+ self.milestone.setTags(tags, self.owner)
+ self.assertEqual(sorted(tags), self.ws_milestone.getTags())
+
+ def test_set_tags_initial(self):
+ tags = [u'zeta', u'alpha', u'beta']
+ self.ws_milestone.setTags(tags=tags)
+ self.ws_milestone.lp_save()
+ transaction.begin()
+ self.assertEqual(sorted(tags), self.milestone.getTags())
+
+ def test_set_tags_replace(self):
+ tags1 = [u'zeta', u'alpha', u'beta']
+ self.milestone.setTags(tags1, self.owner)
+ tags2 = [u'delta', u'alpha', u'gamma']
+ self.ws_milestone.setTags(tags=tags2)
+ self.ws_milestone.lp_save()
+ transaction.begin()
+ self.assertEqual(sorted(tags2), self.milestone.getTags())
=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py 2012-01-05 03:11:37 +0000
+++ lib/lp/registry/tests/test_person.py 2012-01-06 18:30:42 +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-06 18:30:42 +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-06 18:30:42 +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.
-