Merge lp:~leonardr/launchpad/anonymous-oauth into lp:launchpad/db-devel

Proposed by Leonard Richardson
Status: Merged
Approved by: Guilherme Salgado
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~leonardr/launchpad/anonymous-oauth
Merge into: lp:launchpad/db-devel
Diff against target: 837 lines (+406/-63)
17 files modified
lib/canonical/launchpad/doc/account.txt (+7/-2)
lib/canonical/launchpad/interfaces/account.py (+8/-8)
lib/canonical/launchpad/pagetests/webservice/xx-service.txt (+72/-0)
lib/canonical/launchpad/security.py (+36/-25)
lib/canonical/launchpad/testing/pages.py (+31/-5)
lib/canonical/launchpad/webapp/servers.py (+35/-3)
lib/canonical/launchpad/zcml/account.zcml (+4/-1)
lib/lp/bugs/scripts/checkwatches.py (+10/-1)
lib/lp/bugs/scripts/tests/test_checkwatches.py (+83/-0)
lib/lp/registry/browser/configure.zcml (+1/-1)
lib/lp/registry/browser/person.py (+29/-6)
lib/lp/registry/doc/person-account.txt (+2/-2)
lib/lp/registry/stories/person/xx-admin-person-review.txt (+55/-1)
lib/lp/registry/templates/person-review.pt (+2/-1)
lib/lp/services/permission_helpers.py (+29/-4)
lib/lp/translations/model/translationimportqueue.py (+1/-2)
versions.cfg (+1/-1)
To merge this branch: bzr merge lp:~leonardr/launchpad/anonymous-oauth
Reviewer Review Type Date Requested Status
Guilherme Salgado (community) code Approve
Review via email: mp+16199@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This branch fixes bug 385517 (but not all of it: see bug 496964). It allows OAuth requests to bypass the normal verification process (and enjoy the privileges of the unauthenticated principal) if their token key is the empty string. All the real data the client has to provide is a consumer key.

If a normal request comes in with an unrecognized consumer key, the request is still rejected. Access tokens are associated with specific known consumers, so there's no way that request can be valid. But an anonymous request is valid even if it mentions a consumer key never seen before. If that happens, my branch automatically creates a consumer object associated with the consumer key, so it can be recognized later.

I created an 'anonymous_webservice' LaunchpadWebServiceCaller for use in testing anonymous access to the web service, and added some basic tests to xx-service.txt.

Revision history for this message
Guilherme Salgado (salgado) wrote :
Download full text (6.8 KiB)

Hi Leonard,

This looks good; I have only a couple suggestions.

 review needsfixing

On Tue, 2009-12-15 at 15:13 +0000, Leonard Richardson wrote:
> === modified file 'lib/canonical/launchpad/pagetests/webservice/xx-service.txt'
> --- lib/canonical/launchpad/pagetests/webservice/xx-service.txt 2009-08-20 04:46:48 +0000
> +++ lib/canonical/launchpad/pagetests/webservice/xx-service.txt 2009-12-15 15:13:21 +0000
> @@ -56,6 +56,39 @@
> HTTP/1.1 404 Not Found
> ...
>
> +== Anonymous requests ==
> +
> +A properly signed web service request whose OAuth token key is empty
> +is treated as an anonymous request.
> +
> + >>> root = 'http://api.launchpad.dev/beta'
> + >>> body = anon_webservice.get(root).jsonBody()
> + >>> print body['projects_collection_link']
> + http://api.launchpad.dev/beta/projects
> + >>> print body['me_link']
> + http://api.launchpad.dev/beta/people/+me
> +
> +Anonymous requests can't access certain data:
> +
> + >>> response = anon_webservice.get(body['me_link'])
> + >>> print response.getheader('status')
> + 401 Unauthorized
> + >>> print response.body
> + You need to be logged in to view this URL.
> + ...
> +
> +Anonymous requests can't change the dataset.
> +
> + >>> import simplejson
> + >>> data = simplejson.dumps({'display_name' : "This won't work"})
> + >>> response = anon_webservice.patch(root + "/~salgado",
> + ... 'application/json', data)
> + >>> print response.getheader('status')
> + 401 Unauthorized
> + >>> print response.body
> + ...
> + Unauthorized: (<Person at...>, 'displayname', 'launchpad.Edit')
> + ...
>
> == API Requests to other hosts ==
>
>
> === modified file 'lib/canonical/launchpad/testing/pages.py'
> --- lib/canonical/launchpad/testing/pages.py 2009-10-16 17:14:42 +0000
> +++ lib/canonical/launchpad/testing/pages.py 2009-12-15 15:13:21 +0000
> @@ -20,7 +20,8 @@
> from BeautifulSoup import (
> BeautifulSoup, CData, Comment, Declaration, NavigableString, PageElement,
> ProcessingInstruction, SoupStrainer, Tag)
> -from contrib.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
> +from contrib.oauth import (
> + OAuthConsumer, OAuthRequest, OAuthSignatureMethod_PLAINTEXT, OAuthToken)
> from urlparse import urljoin
>
> from zope.app.testing.functional import HTTPCaller, SimpleCookie
> @@ -95,10 +96,15 @@
> """
> if oauth_consumer_key is not None and oauth_access_key is not None:
> login(ANONYMOUS)
> - self.consumer = getUtility(IOAuthConsumerSet).getByKey(
> - oauth_consumer_key)
> - self.access_token = self.consumer.getAccessToken(
> - oauth_access_key)
> + consumers = getUtility(IOAuthConsumerSet)
> + self.consumer = consumers.getByKey(oauth_consumer_key)
> + if self.consumer is None:

I find it really confusing that you rely on a non-registered consumer to
identify anonymous access here -- I was expecting you'd use an empty
token key for that.

To do that, though, you'll need to use the 'launchpad-library' as the
consumer key of your anon_webservice below, but when you do th...

Read more...

review: Needs Fixing
Revision history for this message
Leonard Richardson (leonardr) wrote :

Check the incremental diff: http://pastebin.ubuntu.com/342820/

Revision history for this message
Guilherme Salgado (salgado) wrote :
Download full text (4.9 KiB)

> === modified file 'lib/canonical/launchpad/testing/pages.py'
> --- lib/canonical/launchpad/testing/pages.py 2009-12-15 14:43:33 +0000
> +++ lib/canonical/launchpad/testing/pages.py 2009-12-16 15:52:00 +0000
> @@ -99,12 +99,24 @@
> consumers = getUtility(IOAuthConsumerSet)
> self.consumer = consumers.getByKey(oauth_consumer_key)
> if self.consumer is None:
> - # Set up for anonymous access
> + # The client wants to make a request using an
> + # unrecognized consumer key. Only an anonymous request
> + # (one in which oauth_access_key is the empty string)
> + # will succeed, but we run this code in either case,
> + # so that we can verify that a non-anonymous request
> + # fails.
> self.consumer = OAuthConsumer(oauth_consumer_key, '')
> - self.access_token = OAuthToken('', '')
> + self.access_token = OAuthToken(oauth_access_key, '')
> else:
> - self.access_token = self.consumer.getAccessToken(
> - oauth_access_key)
> + if oauth_access_key == '':
> + # The client wants to make an anonymous request
> + # using a recognized consumer key.
> + self.access_token = OAuthToken(oauth_access_key, '')
> + else:
> + # The client wants to make an authorized request
> + # using a recognized consumer key.
> + self.access_token = self.consumer.getAccessToken(
> + oauth_access_key)

Although this is now making it clear that we identify anonymous requests
by the lack of an access key, I think this can be simplified a bit.
This is what I had in mind when I first commented on this change.

    if oauth_access_key == '':
        # The client wants to make an anonymous request.
        if self.consumer is None:
            # Anonymous requests don't need a registered consumer, so we
            # manually create a "fake" OauthConsumer (we call it "fake"
            # because it's not really an IOAuthConsumer as returned by
            # IOAuthConsumerSet.getByKey) to be used in the requests we make.
            self.consumer = OAuthConsumer(oauth_consumer_key, '')
        self.access_token = OAuthToken(oauth_access_key, '')
    else:
        assert self.consumer is not None, (
            "Need a registered consumer key for authenticated requests.")
        self.access_token = self.consumer.getAccessToken(
            oauth_access_key)

It's untested but I think it's equivalent to what you have.

> logout()
> else:
> self.consumer = None
> @@ -660,7 +672,7 @@
> test.globs['user_webservice'] = LaunchpadWebServiceCaller(
> 'launchpad-library', 'nopriv-read-nonprivate')
> test.globs['anon_webservice'] = LaunchpadWebServiceCaller(
> - 'anonymous-access', '')
> + 'launchpad-library', '')
> test.globs['setupBrowser'] = setupBrowser
> test.globs['browser'] = setupBrowser()
> test.globs['a...

Read more...

Revision history for this message
Guilherme Salgado (salgado) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/doc/account.txt'
--- lib/canonical/launchpad/doc/account.txt 2009-07-16 19:12:39 +0000
+++ lib/canonical/launchpad/doc/account.txt 2010-01-04 16:07:15 +0000
@@ -86,10 +86,13 @@
8686
87= The Account object =87= The Account object =
8888
89The account implements the IAccount interface.89The account implements the IAccount interface but not all attributes are
90accessible for the owner.
9091
92 >>> login('foo.bar@canonical.com')
91 >>> verifyObject(IAccount, account)93 >>> verifyObject(IAccount, account)
92 True94 True
95 >>> login('no-priv@canonical.com')
9396
94An account has a displayname, and a preferred email address.97An account has a displayname, and a preferred email address.
9598
@@ -164,11 +167,12 @@
164 u'No Privileges Person'167 u'No Privileges Person'
165168
166When the status is changed, the date_status_set is updated in the169When the status is changed, the date_status_set is updated in the
167database.170database. Only an admin can change the status.
168171
169 >>> from canonical.launchpad.interfaces.account import AccountStatus172 >>> from canonical.launchpad.interfaces.account import AccountStatus
170173
171 >>> original_date_status_set = account.date_status_set174 >>> original_date_status_set = account.date_status_set
175 >>> login('foo.bar@canonical.com')
172 >>> account.status = AccountStatus.SUSPENDED176 >>> account.status = AccountStatus.SUSPENDED
173177
174 # Shouldn't be necessary with Storm!178 # Shouldn't be necessary with Storm!
@@ -178,6 +182,7 @@
178 True182 True
179183
180 >>> account.status = AccountStatus.ACTIVE184 >>> account.status = AccountStatus.ACTIVE
185 >>> login('no-priv@canonical.com')
181186
182The Account's displayname is synced to the Person's displayname if there187The Account's displayname is synced to the Person's displayname if there
183is one. If the Person.displayname is changed, the Account.displayname is188is one. If the Person.displayname is changed, the Account.displayname is
184189
=== modified file 'lib/canonical/launchpad/interfaces/account.py'
--- lib/canonical/launchpad/interfaces/account.py 2009-08-14 12:50:43 +0000
+++ lib/canonical/launchpad/interfaces/account.py 2010-01-04 16:07:15 +0000
@@ -254,14 +254,6 @@
254 title=_("Rationale for this account's creation."), required=True,254 title=_("Rationale for this account's creation."), required=True,
255 readonly=True, values=AccountCreationRationale.items)255 readonly=True, values=AccountCreationRationale.items)
256256
257 date_status_set = Datetime(
258 title=_('Date status last modified.'),
259 required=True, readonly=False)
260
261 status_comment = Text(
262 title=_("Why are you deactivating your account?"),
263 required=False, readonly=False)
264
265 openid_identifier = TextLine(257 openid_identifier = TextLine(
266 title=_("Key used to generate opaque OpenID identities."),258 title=_("Key used to generate opaque OpenID identities."),
267 readonly=True, required=True)259 readonly=True, required=True)
@@ -283,6 +275,14 @@
283class IAccountSpecialRestricted(Interface):275class IAccountSpecialRestricted(Interface):
284 """Attributes of `IAccount` protected with launchpad.Special."""276 """Attributes of `IAccount` protected with launchpad.Special."""
285277
278 date_status_set = Datetime(
279 title=_('Date status last modified.'),
280 required=True, readonly=False)
281
282 status_comment = Text(
283 title=_("Why are you deactivating your account?"),
284 required=False, readonly=False)
285
286 # XXX sinzui 2008-07-14 bug=248518:286 # XXX sinzui 2008-07-14 bug=248518:
287 # This method would assert the password is not None, but287 # This method would assert the password is not None, but
288 # setPreferredEmail() passes the Person's current password, which may288 # setPreferredEmail() passes the Person's current password, which may
289289
=== modified file 'lib/canonical/launchpad/pagetests/webservice/xx-service.txt'
--- lib/canonical/launchpad/pagetests/webservice/xx-service.txt 2009-08-20 04:46:48 +0000
+++ lib/canonical/launchpad/pagetests/webservice/xx-service.txt 2010-01-04 16:07:15 +0000
@@ -56,6 +56,77 @@
56 HTTP/1.1 404 Not Found56 HTTP/1.1 404 Not Found
57 ...57 ...
5858
59== Anonymous requests ==
60
61A properly signed web service request whose OAuth token key is empty
62is treated as an anonymous request.
63
64 >>> root = 'http://api.launchpad.dev/beta'
65 >>> body = anon_webservice.get(root).jsonBody()
66 >>> print body['projects_collection_link']
67 http://api.launchpad.dev/beta/projects
68 >>> print body['me_link']
69 http://api.launchpad.dev/beta/people/+me
70
71Normally, Launchpad will reject any call made with an unrecognized
72consumer key, because access tokens are registered with specific
73consumer keys.
74
75 >>> from canonical.launchpad.testing.pages import (
76 ... LaunchpadWebServiceCaller)
77 >>> from canonical.launchpad.interfaces import IOAuthConsumerSet
78
79 >>> caller = LaunchpadWebServiceCaller('new-consumer', 'access-key')
80 >>> response = caller.get(root)
81 >>> print response.getheader('status')
82 401 Unauthorized
83 >>> print response.body
84 Unknown consumer (new-consumer).
85 ...
86
87But with anonymous access there is no registration step. The first
88time Launchpad sees a consumer key might be during an
89anonymous request, and it can't reject that request just because it
90doesn't recognize the client.
91
92 >>> login(ANONYMOUS)
93 >>> consumer_set = getUtility(IOAuthConsumerSet)
94 >>> print consumer_set.getByKey('another-new-consumer')
95 None
96 >>> logout()
97
98 >>> caller = LaunchpadWebServiceCaller('another-new-consumer', '')
99 >>> response = caller.get(root)
100 >>> print response.getheader('status')
101 200 Ok
102
103Launchpad automatically adds new consumer keys it sees to its database.
104
105 >>> login(ANONYMOUS)
106 >>> print consumer_set.getByKey('another-new-consumer').key
107 another-new-consumer
108 >>> logout()
109
110Anonymous requests can't access certain data.
111
112 >>> response = anon_webservice.get(body['me_link'])
113 >>> print response.getheader('status')
114 401 Unauthorized
115 >>> print response.body
116 You need to be logged in to view this URL.
117 ...
118
119Anonymous requests can't change the dataset.
120
121 >>> import simplejson
122 >>> data = simplejson.dumps({'display_name' : "This won't work"})
123 >>> response = anon_webservice.patch(root + "/~salgado",
124 ... 'application/json', data)
125 >>> print response.getheader('status')
126 401 Unauthorized
127 >>> print response.body
128 (<Person at...>, 'displayname', 'launchpad.Edit')
129 ...
59130
60== API Requests to other hosts ==131== API Requests to other hosts ==
61132
@@ -108,3 +179,4 @@
108 ... headers={'Authorization': sample_auth})179 ... headers={'Authorization': sample_auth})
109 HTTP/1.1 401 Unauthorized180 HTTP/1.1 401 Unauthorized
110 ...181 ...
182 Request is missing an OAuth consumer key.
111183
=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py 2009-12-24 13:33:41 +0000
+++ lib/canonical/launchpad/security.py 2010-01-04 16:07:15 +0000
@@ -49,12 +49,12 @@
49from lp.registry.interfaces.distroseries import IDistroSeries49from lp.registry.interfaces.distroseries import IDistroSeries
50from lp.translations.interfaces.distroserieslanguage import (50from lp.translations.interfaces.distroserieslanguage import (
51 IDistroSeriesLanguage)51 IDistroSeriesLanguage)
52from lp.translations.utilities.permission_helpers import (
53 is_admin_or_rosetta_expert)
54from lp.registry.interfaces.entitlement import IEntitlement52from lp.registry.interfaces.entitlement import IEntitlement
55from canonical.launchpad.interfaces.hwdb import (53from canonical.launchpad.interfaces.hwdb import (
56 IHWDBApplication, IHWDevice, IHWDeviceClass, IHWDriver, IHWDriverName,54 IHWDBApplication, IHWDevice, IHWDeviceClass, IHWDriver, IHWDriverName,
57 IHWDriverPackageName, IHWSubmission, IHWSubmissionDevice, IHWVendorID)55 IHWDriverPackageName, IHWSubmission, IHWSubmissionDevice, IHWVendorID)
56from lp.services.permission_helpers import (
57 is_admin, is_admin_or_registry_expert, is_admin_or_rosetta_expert)
58from lp.services.worlddata.interfaces.language import ILanguage, ILanguageSet58from lp.services.worlddata.interfaces.language import ILanguage, ILanguageSet
59from lp.translations.interfaces.languagepack import ILanguagePack59from lp.translations.interfaces.languagepack import ILanguagePack
60from canonical.launchpad.interfaces.launchpad import (60from canonical.launchpad.interfaces.launchpad import (
@@ -195,9 +195,7 @@
195 usedfor = None195 usedfor = None
196196
197 def checkAuthenticated(self, user):197 def checkAuthenticated(self, user):
198 celebrities = getUtility(ILaunchpadCelebrities)198 return is_admin_or_registry_expert(user)
199 return (user.inTeam(celebrities.registry_experts)
200 or user.inTeam(celebrities.admin))
201199
202200
203class ReviewProduct(ReviewByRegistryExpertsOrAdmins):201class ReviewProduct(ReviewByRegistryExpertsOrAdmins):
@@ -216,6 +214,11 @@
216 usedfor = IProjectSet214 usedfor = IProjectSet
217215
218216
217class ModeratePerson(ReviewByRegistryExpertsOrAdmins):
218 permission = 'launchpad.Moderate'
219 usedfor = IPerson
220
221
219class ViewPillar(AuthorizationBase):222class ViewPillar(AuthorizationBase):
220 usedfor = IPillar223 usedfor = IPillar
221 permission = 'launchpad.View'224 permission = 'launchpad.View'
@@ -230,26 +233,43 @@
230 else:233 else:
231 celebrities = getUtility(ILaunchpadCelebrities)234 celebrities = getUtility(ILaunchpadCelebrities)
232 return (user.inTeam(celebrities.commercial_admin)235 return (user.inTeam(celebrities.commercial_admin)
233 or user.inTeam(celebrities.registry_experts)236 or is_admin_or_registry_expert(user))
234 or user.inTeam(celebrities.admin))237
235238
236239class EditAccountBySelfOrAdmin(AuthorizationBase):
237class EditAccount(AuthorizationBase):
238 permission = 'launchpad.Edit'240 permission = 'launchpad.Edit'
239 usedfor = IAccount241 usedfor = IAccount
240242
241 def checkAccountAuthenticated(self, account):243 def checkAccountAuthenticated(self, account):
242 if account == self.obj:244 if account == self.obj:
243 return True245 return True
244 user = IPerson(account, None)246 return super(
245 return (user is not None and247 EditAccountBySelfOrAdmin, self).checkAccountAuthenticated(account)
246 user.inTeam(getUtility(ILaunchpadCelebrities).admin))248
247249 def checkAuthenticated(self, user):
248250 return is_admin(user)
249class ViewAccount(EditAccount):251
252
253class ViewAccount(EditAccountBySelfOrAdmin):
250 permission = 'launchpad.View'254 permission = 'launchpad.View'
251255
252256
257class SpecialAccount(EditAccountBySelfOrAdmin):
258 permission = 'launchpad.Special'
259
260 def checkAuthenticated(self, user):
261 """Extend permission to registry experts."""
262 return is_admin_or_registry_expert(user)
263
264
265class ModerateAccountByRegistryExpert(AuthorizationBase):
266 usedfor = IAccount
267 permission = 'launchpad.Moderate'
268
269 def checkAuthenticated(self, user):
270 return is_admin_or_registry_expert(user)
271
272
253class EditOAuthAccessToken(AuthorizationBase):273class EditOAuthAccessToken(AuthorizationBase):
254 permission = 'launchpad.Edit'274 permission = 'launchpad.Edit'
255 usedfor = IOAuthAccessToken275 usedfor = IOAuthAccessToken
@@ -647,15 +667,6 @@
647 return self.obj.id == user.id667 return self.obj.id == user.id
648668
649669
650class EditAccountBySelf(AuthorizationBase):
651 permission = 'launchpad.Special'
652 usedfor = IAccount
653
654 def checkAccountAuthenticated(self, account):
655 """A user can edit the Account who is herself."""
656 return self.obj == account
657
658
659class ViewPublicOrPrivateTeamMembers(AuthorizationBase):670class ViewPublicOrPrivateTeamMembers(AuthorizationBase):
660 """Restrict viewing of private memberships of teams.671 """Restrict viewing of private memberships of teams.
661672
662673
=== modified file 'lib/canonical/launchpad/testing/pages.py'
--- lib/canonical/launchpad/testing/pages.py 2009-12-21 17:18:12 +0000
+++ lib/canonical/launchpad/testing/pages.py 2010-01-04 16:07:15 +0000
@@ -20,7 +20,8 @@
20from BeautifulSoup import (20from BeautifulSoup import (
21 BeautifulSoup, CData, Comment, Declaration, NavigableString, PageElement,21 BeautifulSoup, CData, Comment, Declaration, NavigableString, PageElement,
22 ProcessingInstruction, SoupStrainer, Tag)22 ProcessingInstruction, SoupStrainer, Tag)
23from contrib.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT23from contrib.oauth import (
24 OAuthConsumer, OAuthRequest, OAuthSignatureMethod_PLAINTEXT, OAuthToken)
24from urlparse import urljoin25from urlparse import urljoin
2526
26from zope.app.testing.functional import HTTPCaller, SimpleCookie27from zope.app.testing.functional import HTTPCaller, SimpleCookie
@@ -97,10 +98,33 @@
97 """98 """
98 if oauth_consumer_key is not None and oauth_access_key is not None:99 if oauth_consumer_key is not None and oauth_access_key is not None:
99 login(ANONYMOUS)100 login(ANONYMOUS)
100 self.consumer = getUtility(IOAuthConsumerSet).getByKey(101 consumers = getUtility(IOAuthConsumerSet)
101 oauth_consumer_key)102 self.consumer = consumers.getByKey(oauth_consumer_key)
102 self.access_token = self.consumer.getAccessToken(103 if oauth_access_key == '':
103 oauth_access_key)104 # The client wants to make an anonymous request.
105 self.access_token = OAuthToken(oauth_access_key, '')
106 if self.consumer is None:
107 # The client is trying to make an anonymous
108 # request with a previously unknown consumer. This
109 # is fine: we manually create a "fake"
110 # OAuthConsumer (it's "fake" because it's not
111 # really an IOAuthConsumer as returned by
112 # IOAuthConsumerSet.getByKey) to be used in the
113 # requests we make.
114 self.consumer = OAuthConsumer(oauth_consumer_key, '')
115 else:
116 if self.consumer is None:
117 # Requests using this caller will be rejected by
118 # the server, but we have a test that verifies
119 # such requests _are_ rejected, so we'll create a
120 # fake OAuthConsumer object.
121 self.consumer = OAuthConsumer(oauth_consumer_key, '')
122 self.access_token = OAuthToken(oauth_access_key, '')
123 else:
124 # The client wants to make an authorized request
125 # using a recognized consumer key.
126 self.access_token = self.consumer.getAccessToken(
127 oauth_access_key)
104 logout()128 logout()
105 else:129 else:
106 self.consumer = None130 self.consumer = None
@@ -676,6 +700,8 @@
676 'foobar123451432', 'salgado-read-nonprivate')700 'foobar123451432', 'salgado-read-nonprivate')
677 test.globs['user_webservice'] = LaunchpadWebServiceCaller(701 test.globs['user_webservice'] = LaunchpadWebServiceCaller(
678 'launchpad-library', 'nopriv-read-nonprivate')702 'launchpad-library', 'nopriv-read-nonprivate')
703 test.globs['anon_webservice'] = LaunchpadWebServiceCaller(
704 'launchpad-library', '')
679 test.globs['setupBrowser'] = setupBrowser705 test.globs['setupBrowser'] = setupBrowser
680 test.globs['setupDTCBrowser'] = setupDTCBrowser706 test.globs['setupDTCBrowser'] = setupDTCBrowser
681 test.globs['browser'] = setupBrowser()707 test.globs['browser'] = setupBrowser()
682708
=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py 2009-10-22 13:16:06 +0000
+++ lib/canonical/launchpad/webapp/servers.py 2010-01-04 16:07:15 +0000
@@ -1177,10 +1177,42 @@
1177 form = get_oauth_authorization(request)1177 form = get_oauth_authorization(request)
11781178
1179 consumer_key = form.get('oauth_consumer_key')1179 consumer_key = form.get('oauth_consumer_key')
1180 consumer = getUtility(IOAuthConsumerSet).getByKey(consumer_key)1180 consumers = getUtility(IOAuthConsumerSet)
1181 consumer = consumers.getByKey(consumer_key)
1182 token_key = form.get('oauth_token')
1183 anonymous_request = (token_key == '')
1181 if consumer is None:1184 if consumer is None:
1182 raise Unauthorized('Unknown consumer (%s).' % consumer_key)1185 if consumer_key is None:
1183 token_key = form.get('oauth_token')1186 # Most likely the user is trying to make a totally
1187 # unauthenticated request.
1188 raise Unauthorized(
1189 'Request is missing an OAuth consumer key.')
1190 if anonymous_request:
1191 # This is the first time anyone has tried to make an
1192 # anonymous request using this consumer
1193 # name. Dynamically create the consumer.
1194 #
1195 # In the normal website this wouldn't be possible
1196 # because GET requests have their transactions rolled
1197 # back. But webservice requests always have their
1198 # transactions committed so that we can keep track of
1199 # the OAuth nonces and prevent replay attacks.
1200 consumer = consumers.new(consumer_key, '')
1201 else:
1202 # An unknown consumer can never make a non-anonymous
1203 # request, because access tokens are registered with a
1204 # specific, known consumer.
1205 raise Unauthorized('Unknown consumer (%s).' % consumer_key)
1206 if anonymous_request:
1207 # Skip the OAuth verification step and let the user access the
1208 # web service as an unauthenticated user.
1209 #
1210 # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
1211 # auto-creating a token for the anonymous user the first
1212 # time, passing it through the OAuth verification step,
1213 # and using it on all subsequent anonymous requests.
1214 auth_utility = getUtility(IPlacelessAuthUtility)
1215 return auth_utility.unauthenticatedPrincipal()
1184 token = consumer.getAccessToken(token_key)1216 token = consumer.getAccessToken(token_key)
1185 if token is None:1217 if token is None:
1186 raise Unauthorized('Unknown access token (%s).' % token_key)1218 raise Unauthorized('Unknown access token (%s).' % token_key)
11871219
=== modified file 'lib/canonical/launchpad/zcml/account.zcml'
--- lib/canonical/launchpad/zcml/account.zcml 2009-07-13 18:15:02 +0000
+++ lib/canonical/launchpad/zcml/account.zcml 2010-01-04 16:07:15 +0000
@@ -15,8 +15,11 @@
15 permission="launchpad.Special"15 permission="launchpad.Special"
16 interface="canonical.launchpad.interfaces.IAccountSpecialRestricted" />16 interface="canonical.launchpad.interfaces.IAccountSpecialRestricted" />
17 <require17 <require
18 permission="launchpad.Moderate"
19 set_attributes="status date_status_set status_comment" />
20 <require
18 permission="launchpad.Edit"21 permission="launchpad.Edit"
19 set_schema="canonical.launchpad.interfaces.IAccount" />22 set_attributes="displayname password" />
20 </class>23 </class>
2124
22 <securedutility25 <securedutility
2326
=== modified file 'lib/lp/bugs/scripts/checkwatches.py'
--- lib/lp/bugs/scripts/checkwatches.py 2009-12-15 16:28:20 +0000
+++ lib/lp/bugs/scripts/checkwatches.py 2010-01-04 16:07:15 +0000
@@ -789,7 +789,16 @@
789 )789 )
790790
791 for bug_id in all_remote_ids:791 for bug_id in all_remote_ids:
792 bug_watches = bug_watches_by_remote_bug[bug_id]792 try:
793 bug_watches = bug_watches_by_remote_bug[bug_id]
794 except KeyError:
795 # If there aren't any bug watches for this remote bug,
796 # just log a warning and carry on.
797 self.warning(
798 "Spurious remote bug ID: No watches found for "
799 "remote bug %s on %s" % (bug_id, remotesystem.baseurl))
800 continue
801
793 for bug_watch in bug_watches:802 for bug_watch in bug_watches:
794 bug_watch.lastchecked = UTC_NOW803 bug_watch.lastchecked = UTC_NOW
795 if bug_id in unmodified_remote_ids:804 if bug_id in unmodified_remote_ids:
796805
=== modified file 'lib/lp/bugs/scripts/tests/test_checkwatches.py'
--- lib/lp/bugs/scripts/tests/test_checkwatches.py 2009-12-21 16:08:35 +0000
+++ lib/lp/bugs/scripts/tests/test_checkwatches.py 2010-01-04 16:07:15 +0000
@@ -15,6 +15,8 @@
1515
16from lp.bugs.externalbugtracker.bugzilla import BugzillaAPI16from lp.bugs.externalbugtracker.bugzilla import BugzillaAPI
17from lp.bugs.scripts import checkwatches17from lp.bugs.scripts import checkwatches
18from lp.bugs.scripts.checkwatches import CheckWatchesErrorUtility
19from lp.bugs.tests.externalbugtracker import TestBugzillaAPIXMLRPCTransport
18from lp.testing import TestCaseWithFactory20from lp.testing import TestCaseWithFactory
1921
2022
@@ -23,6 +25,52 @@
23 return BugzillaAPI(bugtracker.baseurl)25 return BugzillaAPI(bugtracker.baseurl)
2426
2527
28class NonConnectingBugzillaAPI(BugzillaAPI):
29 """A non-connected version of the BugzillaAPI ExternalBugTracker."""
30
31 bugs = {
32 1: {'product': 'test-product'},
33 }
34
35 def getCurrentDBTime(self):
36 return None
37
38 def getExternalBugTrackerToUse(self):
39 return self
40
41 def getProductsForRemoteBugs(self, remote_bugs):
42 """Return the products for some remote bugs.
43
44 This method is basically the same as that of the superclass but
45 without the call to initializeRemoteBugDB().
46 """
47 bug_products = {}
48 for bug_id in bug_ids:
49 # If one of the bugs we're trying to get the product for
50 # doesn't exist, just skip it.
51 try:
52 actual_bug_id = self._getActualBugId(bug_id)
53 except BugNotFound:
54 continue
55
56 bug_dict = self._bugs[actual_bug_id]
57 bug_products[bug_id] = bug_dict['product']
58
59 return bug_products
60
61
62class NoBugWatchesByRemoteBugUpdater(checkwatches.BugWatchUpdater):
63 """A subclass of BugWatchUpdater with methods overridden for testing."""
64
65 def _getBugWatchesByRemoteBug(self, bug_watch_ids):
66 """Return an empty dict.
67
68 This method overrides _getBugWatchesByRemoteBug() so that bug
69 497141 can be regression-tested.
70 """
71 return {}
72
73
26class TestCheckwatchesWithSyncableGnomeProducts(TestCaseWithFactory):74class TestCheckwatchesWithSyncableGnomeProducts(TestCaseWithFactory):
2775
28 layer = LaunchpadZopelessLayer76 layer = LaunchpadZopelessLayer
@@ -63,5 +111,40 @@
63 gnome_bugzilla, [bug_watch_1, bug_watch_2])111 gnome_bugzilla, [bug_watch_1, bug_watch_2])
64112
65113
114class TestBugWatchUpdater(TestCaseWithFactory):
115
116 layer = LaunchpadZopelessLayer
117
118 def test_bug_497141(self):
119 # Regression test for bug 497141. KeyErrors raised in
120 # BugWatchUpdater.updateBugWatches() shouldn't cause
121 # checkwatches to abort.
122 updater = NoBugWatchesByRemoteBugUpdater(
123 transaction, QuietFakeLogger())
124
125 # Create a couple of bug watches for testing purposes.
126 bug_tracker = self.factory.makeBugTracker()
127 bug_watches = [
128 self.factory.makeBugWatch(bugtracker=bug_tracker)
129 for i in range(2)]
130
131 # Use a test XML-RPC transport to ensure no connections happen.
132 test_transport = TestBugzillaAPIXMLRPCTransport(bug_tracker.baseurl)
133 remote_system = NonConnectingBugzillaAPI(
134 bug_tracker.baseurl, xmlrpc_transport=test_transport)
135
136 # Calling updateBugWatches() shouldn't raise a KeyError, even
137 # though with our broken updater _getExternalBugTrackersAndWatches()
138 # will return an empty dict.
139 updater.updateBugWatches(remote_system, bug_watches)
140
141 # An error will have been logged instead of the KeyError being
142 # raised.
143 error_utility = CheckWatchesErrorUtility()
144 last_oops = error_utility.getLastOopsReport()
145 self.assertTrue(
146 last_oops.value.startswith('Spurious remote bug ID'))
147
148
66def test_suite():149def test_suite():
67 return unittest.TestLoader().loadTestsFromName(__name__)150 return unittest.TestLoader().loadTestsFromName(__name__)
68151
=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml 2009-12-05 18:37:28 +0000
+++ lib/lp/registry/browser/configure.zcml 2010-01-04 16:07:15 +0000
@@ -761,7 +761,7 @@
761 name="+reviewaccount"761 name="+reviewaccount"
762 for="lp.registry.interfaces.person.IPerson"762 for="lp.registry.interfaces.person.IPerson"
763 class="lp.registry.browser.person.PersonAccountAdministerView"763 class="lp.registry.browser.person.PersonAccountAdministerView"
764 permission="launchpad.Admin"764 permission="launchpad.Moderate"
765 template="../templates/person-review.pt"/>765 template="../templates/person-review.pt"/>
766 <browser:page766 <browser:page
767 name="+claimteam"767 name="+claimteam"
768768
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2009-12-23 16:17:12 +0000
+++ lib/lp/registry/browser/person.py 2010-01-04 16:07:15 +0000
@@ -901,6 +901,12 @@
901 text = 'Administer'901 text = 'Administer'
902 return Link(target, text, icon='edit')902 return Link(target, text, icon='edit')
903903
904 @enabled_with_permission('launchpad.Moderate')
905 def administer_account(self):
906 target = '+reviewaccount'
907 text = 'Administer Account'
908 return Link(target, text, icon='edit')
909
904910
905class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin):911class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin):
906912
@@ -910,9 +916,10 @@
910 'editemailaddresses', 'editlanguages', 'editwikinames',916 'editemailaddresses', 'editlanguages', 'editwikinames',
911 'editircnicknames', 'editjabberids', 'editpassword',917 'editircnicknames', 'editjabberids', 'editpassword',
912 'editsshkeys', 'editpgpkeys', 'editlocation', 'memberships',918 'editsshkeys', 'editpgpkeys', 'editlocation', 'memberships',
913 'codesofconduct', 'karma', 'administer', 'projects',919 'codesofconduct', 'karma', 'administer', 'administer_account',
914 'activate_ppa', 'maintained', 'view_ppa_subscriptions',920 'projects', 'activate_ppa', 'maintained',
915 'ppa', 'oauth_tokens', 'related_software_summary']921 'view_ppa_subscriptions', 'ppa', 'oauth_tokens',
922 'related_software_summary']
916923
917 def related_software_summary(self):924 def related_software_summary(self):
918 target = '+related-software'925 target = '+related-software'
@@ -1687,8 +1694,6 @@
1687 """Administer an `IAccount` belonging to an `IPerson`."""1694 """Administer an `IAccount` belonging to an `IPerson`."""
1688 schema = IAccount1695 schema = IAccount
1689 label = "Review person's account"1696 label = "Review person's account"
1690 field_names = [
1691 'displayname', 'password', 'status', 'status_comment']
1692 custom_widget(1697 custom_widget(
1693 'status_comment', TextAreaWidget, height=5, width=60)1698 'status_comment', TextAreaWidget, height=5, width=60)
1694 custom_widget('password', PasswordChangeWidget)1699 custom_widget('password', PasswordChangeWidget)
@@ -1697,9 +1702,14 @@
1697 """See `LaunchpadEditFormView`."""1702 """See `LaunchpadEditFormView`."""
1698 super(PersonAccountAdministerView, self).__init__(context, request)1703 super(PersonAccountAdministerView, self).__init__(context, request)
1699 # Only the IPerson can be traversed to, so it provides the IAccount.1704 # Only the IPerson can be traversed to, so it provides the IAccount.
1705 # It also means that permissions are checked on IAccount, not IPerson.
1700 self.person = self.context1706 self.person = self.context
1701 from canonical.launchpad.interfaces import IMasterObject1707 from canonical.launchpad.interfaces import IMasterObject
1702 self.context = IMasterObject(self.context.account)1708 self.context = IMasterObject(self.context.account)
1709 # Set fields to be displayed.
1710 self.field_names = ['status', 'status_comment']
1711 if self.viewed_by_admin:
1712 self.field_names = ['displayname', 'password'] + self.field_names
17031713
1704 @property1714 @property
1705 def is_viewing_person(self):1715 def is_viewing_person(self):
@@ -1711,6 +1721,11 @@
1711 return False1721 return False
17121722
1713 @property1723 @property
1724 def viewed_by_admin(self):
1725 """Is the user a Launchpad admin?"""
1726 return check_permission('launchpad.Admin', self.context)
1727
1728 @property
1714 def email_addresses(self):1729 def email_addresses(self):
1715 """A list of the user's preferred and validated email addresses."""1730 """A list of the user's preferred and validated email addresses."""
1716 emails = sorted(1731 emails = sorted(
@@ -1722,6 +1737,10 @@
1722 @property1737 @property
1723 def next_url(self):1738 def next_url(self):
1724 """See `LaunchpadEditFormView`."""1739 """See `LaunchpadEditFormView`."""
1740 is_suspended = self.context.status == AccountStatus.SUSPENDED
1741 if is_suspended and not self.viewed_by_admin:
1742 # Non-admins cannot see suspended persons.
1743 return canonical_url(getUtility(IPersonSet))
1725 return canonical_url(self.person)1744 return canonical_url(self.person)
17261745
1727 @property1746 @property
@@ -1739,6 +1758,9 @@
1739 # email is sent to the user.1758 # email is sent to the user.
1740 data['password'] = 'invalid'1759 data['password'] = 'invalid'
1741 self.person.setPreferredEmail(None)1760 self.person.setPreferredEmail(None)
1761 self.request.response.addNoticeNotification(
1762 u'The account "%s" has been suspended.' % (
1763 self.context.displayname))
1742 if (data['status'] == AccountStatus.ACTIVE1764 if (data['status'] == AccountStatus.ACTIVE
1743 and self.context.status != AccountStatus.ACTIVE):1765 and self.context.status != AccountStatus.ACTIVE):
1744 self.request.response.addNoticeNotification(1766 self.request.response.addNoticeNotification(
@@ -5834,7 +5856,8 @@
5834 usedfor = IPersonIndexMenu5856 usedfor = IPersonIndexMenu
5835 facet = 'overview'5857 facet = 'overview'
5836 title = 'Change person'5858 title = 'Change person'
5837 links = ('edit', 'administer', 'branding', 'editpassword')5859 links = ('edit', 'administer', 'administer_account',
5860 'branding', 'editpassword')
58385861
58395862
5840class ITeamIndexMenu(Interface):5863class ITeamIndexMenu(Interface):
58415864
=== modified file 'lib/lp/registry/doc/person-account.txt'
--- lib/lp/registry/doc/person-account.txt 2009-08-13 19:03:36 +0000
+++ lib/lp/registry/doc/person-account.txt 2010-01-04 16:07:15 +0000
@@ -26,9 +26,9 @@
26 None26 None
2727
28The account can only be activated by the user who is claiming28The account can only be activated by the user who is claiming
29the profile. Mark cannot claim it.29the profile. Sample Person cannot claim it.
3030
31 >>> login('mark@example.com')31 >>> login('test@canonical.com')
32 >>> matsubara.account.activate(32 >>> matsubara.account.activate(
33 ... comment="test", password='ok', preferred_email=emailaddress)33 ... comment="test", password='ok', preferred_email=emailaddress)
34 Traceback (most recent call last):34 Traceback (most recent call last):
3535
=== modified file 'lib/lp/registry/stories/person/xx-admin-person-review.txt'
--- lib/lp/registry/stories/person/xx-admin-person-review.txt 2009-12-23 16:17:12 +0000
+++ lib/lp/registry/stories/person/xx-admin-person-review.txt 2010-01-04 16:07:15 +0000
@@ -51,7 +51,8 @@
51 >>> print link.text51 >>> print link.text
52 edit[IMG] Review the user's Launchpad information52 edit[IMG] Review the user's Launchpad information
5353
54The admin can update the user's account.54The admin can update the user's account. Suspending an account will give a
55feedback message.
5556
56 >>> admin_browser.getControl(57 >>> admin_browser.getControl(
57 ... 'The status of this account').value = ['SUSPENDED']58 ... 'The status of this account').value = ['SUSPENDED']
@@ -61,6 +62,8 @@
6162
62 >>> print admin_browser.title63 >>> print admin_browser.title
63 The one and only Salgado does not use Launchpad64 The one and only Salgado does not use Launchpad
65 >>> print get_feedback_messages(admin_browser.contents)[0]
66 The account "The one and only Salgado" has been suspended.
6467
65The admin can see the account information of a user that does not use68The admin can see the account information of a user that does not use
66Launchpad, and can change the account too. Note that all pages that belong69Launchpad, and can change the account too. Note that all pages that belong
@@ -88,6 +91,57 @@
88 The user is reactivated. He must use the "forgot password" to log in.91 The user is reactivated. He must use the "forgot password" to log in.
8992
9093
94Partial access for registry experts
95-----------------------------------
96
97Members of the registry team get partial access to the review account page to
98be able to suspend spam accounts.
99
100 >>> login('foo.bar@canonical.com')
101 >>> registry_expert = factory.makePerson(email='expert@example.com',
102 ... password='test')
103 >>> from canonical.launchpad.interfaces import ILaunchpadCelebrities
104 >>> from zope.component import getUtility
105 >>> registry_team = getUtility(ILaunchpadCelebrities).registry_experts
106 >>> registry_team.addMember(registry_expert, registry_team.teamowner)
107 >>> print registry_expert.inTeam(registry_team)
108 True
109 >>> logout()
110
111 >>> expert_browser = setupBrowser(auth='Basic expert@example.com:test')
112 >>> expert_browser.open('http://launchpad.dev/~no-priv/+reviewaccount')
113 >>> print expert_browser.title
114 Review person's account...
115
116The +reviewaccount page does not display account information for registry
117experts. It also has no form elements to change the display name or the
118password.
119
120 >>> content = find_main_content(expert_browser.contents)
121 >>> print content.find(id='summary')
122 None
123 >>> print content.find(name='field.displayname')
124 None
125 >>> print content.find(name='field.password')
126 None
127
128The only option for registry experts is to change an account's status and to
129provide a reason why they did it. After suspending the account the
130page will only be visible to admins, so the registry expert will be
131redirected to the "people" main page. A feedback message informs the expert
132about the suspension.
133
134 >>> control = expert_browser.getControl(name='field.status_comment')
135 >>> control.value = 'This is SPAM!'
136 >>> expert_browser.getControl(
137 ... 'The status of this account').value = ['SUSPENDED']
138 >>> expert_browser.getControl('Change').click()
139 >>> print expert_browser.url
140 http://launchpad.dev/people
141 >>> print get_feedback_messages(expert_browser.contents)[0]
142 The account "No Privileges Person" has been suspended.
143
144
91Restricted to admins145Restricted to admins
92--------------------146--------------------
93147
94148
=== modified file 'lib/lp/registry/templates/person-review.pt'
--- lib/lp/registry/templates/person-review.pt 2009-09-15 15:42:39 +0000
+++ lib/lp/registry/templates/person-review.pt 2010-01-04 16:07:15 +0000
@@ -33,7 +33,8 @@
33 </p>33 </p>
34 </tal:review-person>34 </tal:review-person>
3535
36 <tal:review-account tal:condition="not: view/is_viewing_person">36 <tal:review-account
37 condition="python: not view.is_viewing_person and view.viewed_by_admin">
37 <p>38 <p>
38 The account displayname is not always the same as the Launchpad39 The account displayname is not always the same as the Launchpad
39 displayname.40 displayname.
4041
=== renamed file 'lib/lp/translations/utilities/permission_helpers.py' => 'lib/lp/services/permission_helpers.py'
--- lib/lp/translations/utilities/permission_helpers.py 2009-11-17 09:50:33 +0000
+++ lib/lp/services/permission_helpers.py 2010-01-04 16:07:15 +0000
@@ -6,7 +6,11 @@
6__metaclass__ = type6__metaclass__ = type
77
8__all__ = [8__all__ = [
9 'is_admin',
10 'is_admin_or_registry_expert',
9 'is_admin_or_rosetta_expert',11 'is_admin_or_rosetta_expert',
12 'is_registry_expert',
13 'is_rosetta_expert',
10 ]14 ]
1115
12from zope.component import getUtility16from zope.component import getUtility
@@ -14,8 +18,29 @@
14from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities18from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
1519
1620
21def is_admin(user):
22 """Check if the user is a Launchpad admin."""
23 celebrities = getUtility(ILaunchpadCelebrities)
24 return user.inTeam(celebrities.admin)
25
26
27def is_rosetta_expert(user):
28 """Check if the user is a Rosetta expert."""
29 celebrities = getUtility(ILaunchpadCelebrities)
30 return user.inTeam(celebrities.rosetta_experts)
31
32
33def is_registry_expert(user):
34 """Check if the user is a registry expert."""
35 celebrities = getUtility(ILaunchpadCelebrities)
36 return user.inTeam(celebrities.registry_experts)
37
38
17def is_admin_or_rosetta_expert(user):39def is_admin_or_rosetta_expert(user):
18 """Check if the user is a Launchpad admin or a Rosettta expert."""40 """Check if the user is a Launchpad admin or a Rosetta expert."""
19 celebrities = getUtility(ILaunchpadCelebrities)41 return is_admin(user) or is_rosetta_expert(user)
20 return (user.inTeam(celebrities.admin) or42
21 user.inTeam(celebrities.rosetta_experts))43
44def is_admin_or_registry_expert(user):
45 """Check if the user is a Launchpad admin or a registry expert."""
46 return is_admin(user) or is_registry_expert(user)
2247
=== modified file 'lib/lp/translations/model/translationimportqueue.py'
--- lib/lp/translations/model/translationimportqueue.py 2009-12-18 09:25:14 +0000
+++ lib/lp/translations/model/translationimportqueue.py 2010-01-04 16:07:15 +0000
@@ -43,6 +43,7 @@
43from lp.registry.interfaces.product import IProduct43from lp.registry.interfaces.product import IProduct
44from lp.registry.interfaces.productseries import IProductSeries44from lp.registry.interfaces.productseries import IProductSeries
45from lp.registry.interfaces.sourcepackage import ISourcePackage45from lp.registry.interfaces.sourcepackage import ISourcePackage
46from lp.services.permission_helpers import is_admin_or_rosetta_expert
46from lp.services.worlddata.interfaces.language import ILanguageSet47from lp.services.worlddata.interfaces.language import ILanguageSet
47from lp.translations.interfaces.pofile import IPOFileSet48from lp.translations.interfaces.pofile import IPOFileSet
48from lp.translations.interfaces.potemplate import IPOTemplateSet49from lp.translations.interfaces.potemplate import IPOTemplateSet
@@ -62,8 +63,6 @@
62from lp.translations.interfaces.translations import TranslationConstants63from lp.translations.interfaces.translations import TranslationConstants
63from lp.translations.utilities.gettext_po_importer import (64from lp.translations.utilities.gettext_po_importer import (
64 GettextPOImporter)65 GettextPOImporter)
65from lp.translations.utilities.permission_helpers import (
66 is_admin_or_rosetta_expert)
67from canonical.librarian.interfaces import ILibrarianClient66from canonical.librarian.interfaces import ILibrarianClient
6867
6968
7069
=== modified file 'versions.cfg'
--- versions.cfg 2009-12-22 23:35:26 +0000
+++ versions.cfg 2010-01-04 16:07:15 +0000
@@ -19,7 +19,7 @@
19grokcore.component = 1.619grokcore.component = 1.6
20httplib2 = 0.4.020httplib2 = 0.4.0
21ipython = 0.9.121ipython = 0.9.1
22launchpadlib = 1.5.3.122launchpadlib = 1.5.4
23lazr.authentication = 0.1.123lazr.authentication = 0.1.1
24lazr.batchnavigator = 1.124lazr.batchnavigator = 1.1
25lazr.config = 1.1.325lazr.config = 1.1.3

Subscribers

People subscribed via source and target branches

to status/vote changes: