Merge lp:~gary/launchpad/bug548-db-3 into lp:launchpad/db-devel

Proposed by Gary Poster
Status: Merged
Approved by: Brad Crittenden
Approved revision: no longer in the source branch.
Merged at revision: 10177
Proposed branch: lp:~gary/launchpad/bug548-db-3
Merge into: lp:launchpad/db-devel
Prerequisite: lp:~wgrant/launchpad/bug548-db-2-tests
Diff against target: 1057 lines (+693/-128)
5 files modified
lib/canonical/widgets/__init__.py (+12/-0)
lib/contrib/oauth.py (+529/-0)
lib/lp/bugs/doc/bugnotification-sending.txt (+91/-118)
lib/lp/registry/model/person.py (+55/-8)
lib/lp/scripts/garbo.py (+6/-2)
To merge this branch: bzr merge lp:~gary/launchpad/bug548-db-3
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
Review via email: mp+48651@code.launchpad.net

Commit message

[r=bac][ui=none][no-qa] Add "selfgenerated_bugnotifications" as a global option for people so that we can work on bug 548. Only in the DB at this time, since the actual implementation is not yet done.

Description of the change

This branch fixes the remaining test failures from wgrant's branch (fixing most of the failures) of my previous branches. It also reinstates the use of a test helper for one file (bugnotification-sending.txt) that wgrant reverted because I believe it is an improvement and I don't see a reason to lose it. He probably reverted it because it was unnecessary to get the tests to pass, which is absolutely true. These changes have already been reviewed and approved (https://code.launchpad.net/~gary/launchpad/bug548-db-2-tests/+merge/48570).

This branch had to fix three sorts of test failures. First, I had hoped that we would be able to make the person-specific settings simply not implemented for teams. This proved to be untenable: at least two automated interface-based functions (verifyObject from zope.interface and lazr.lifecycle's snapshot functionality) crashed and burned by this behavior. Therefore, I opted to make the attributes readonly for teams, using the defaults from the interfaces. I had a mid-implementation call with Danilo about how to tackle that. I want to do what you see here, rather than a __getattr__/__setattr__ approach or a manual approach. I don't think that the __getattr__/__setattr__ approach is potentially better than this, but I was somehwhat tempted by the manual approach. My arguments against it were these.

 - This way keeps you from having to repeat yourself (again) by explicitly listing the attribute names.
 - We expect to add many more attributes to this settings bag, and setting up each one manually in a readonly version would be a drag.
 - We expect to add a team-based settings bag, and repeating things there would be even more of a drag.

Danilo thought that my preferred approach was acceptable, so I proceeded.

The second test failure was in garbo, cleaning up people. This was simply addressed by teaching it about the new person settings table (that it is safe to ignore it when trying to determine whether a person is still linked).

The last test failure was that ImmutableVisibilityError was being raised because Person.visibilityConsistencyWarning needed to be taught to ignore person settings too.

My goal is to get this landed asap to try and make it into PQM. I'd prefer having to make "I'll fix that next" promises if possible, rather than significantly holding up the branch, but I will understand if you are not comfortable with that.

Thank you

Gary

To post a comment you must log in.
Revision history for this message
Gary Poster (gary) wrote :

Lines 1 - 396 have been reviewed before, though comments/requests/suggestions are welcome.

Revision history for this message
Gary Poster (gary) wrote :

- The widgets bits are spurious diff artifacts and are not pertinent to or part of my change.
- I pushed a revert to a comment change in garbo.py to re-correct some grammar.
- I lied. lines 299-319 of the current diff have not been reviewed. They reflect the change making the selfgenerated_bugnotifcation attribute readonly for teams, rather than completely not implemented for them. :-( sorry

Revision history for this message
Brad Crittenden (bac) wrote :

Thanks for the great explanation to a particularly dense piece of code. It is understandable now.

The changes look good. Please do figure out the odd appearance of extra files before sending this up.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'lib/canonical/widgets'
2=== added file 'lib/canonical/widgets/__init__.py'
3--- lib/canonical/widgets/__init__.py 1970-01-01 00:00:00 +0000
4+++ lib/canonical/widgets/__init__.py 2011-02-02 21:39:04 +0000
5@@ -0,0 +1,12 @@
6+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
7+# GNU Affero General Public License version 3 (see the file LICENSE).
8+
9+# This is a stub to keep canonical.shipit operational. this module
10+# can delete when shipit is independent.
11+
12+from lp.app.widgets.itemswidgets import (
13+ CheckBoxMatrixWidget,
14+ LabeledMultiCheckBoxWidget,
15+ )
16+
17+
18
19=== added file 'lib/contrib/oauth.py'
20--- lib/contrib/oauth.py 1970-01-01 00:00:00 +0000
21+++ lib/contrib/oauth.py 2011-02-04 14:41:18 +0000
22@@ -0,0 +1,529 @@
23+# pylint: disable-msg=C0301,E0602,E0211,E0213,W0105,W0231,W0702
24+
25+import cgi
26+import urllib
27+import time
28+import random
29+import urlparse
30+import hmac
31+import base64
32+
33+VERSION = '1.0' # Hi Blaine!
34+HTTP_METHOD = 'GET'
35+SIGNATURE_METHOD = 'PLAINTEXT'
36+
37+# Generic exception class
38+class OAuthError(RuntimeError):
39+ def __init__(self, message='OAuth error occured'):
40+ self.message = message
41+
42+# optional WWW-Authenticate header (401 error)
43+def build_authenticate_header(realm=''):
44+ return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
45+
46+# url escape
47+def escape(s):
48+ # escape '/' too
49+ return urllib.quote(s, safe='~')
50+
51+# util function: current timestamp
52+# seconds since epoch (UTC)
53+def generate_timestamp():
54+ return int(time.time())
55+
56+# util function: nonce
57+# pseudorandom number
58+def generate_nonce(length=8):
59+ return ''.join(str(random.randint(0, 9)) for i in range(length))
60+
61+# OAuthConsumer is a data type that represents the identity of the Consumer
62+# via its shared secret with the Service Provider.
63+class OAuthConsumer(object):
64+ key = None
65+ secret = None
66+
67+ def __init__(self, key, secret):
68+ self.key = key
69+ self.secret = secret
70+
71+# OAuthToken is a data type that represents an End User via either an access
72+# or request token.
73+class OAuthToken(object):
74+ # access tokens and request tokens
75+ key = None
76+ secret = None
77+
78+ '''
79+ key = the token
80+ secret = the token secret
81+ '''
82+ def __init__(self, key, secret):
83+ self.key = key
84+ self.secret = secret
85+
86+ def to_string(self):
87+ return urllib.urlencode({'oauth_token': self.key, 'oauth_token_secret': self.secret})
88+
89+ # return a token from something like:
90+ # oauth_token_secret=digg&oauth_token=digg
91+ @staticmethod
92+ def from_string(s):
93+ params = cgi.parse_qs(s, keep_blank_values=False)
94+ key = params['oauth_token'][0]
95+ secret = params['oauth_token_secret'][0]
96+ return OAuthToken(key, secret)
97+
98+ def __str__(self):
99+ return self.to_string()
100+
101+# OAuthRequest represents the request and can be serialized
102+class OAuthRequest(object):
103+ '''
104+ OAuth parameters:
105+ - oauth_consumer_key
106+ - oauth_token
107+ - oauth_signature_method
108+ - oauth_signature
109+ - oauth_timestamp
110+ - oauth_nonce
111+ - oauth_version
112+ ... any additional parameters, as defined by the Service Provider.
113+ '''
114+ parameters = None # oauth parameters
115+ http_method = HTTP_METHOD
116+ http_url = None
117+ version = VERSION
118+
119+ def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None):
120+ self.http_method = http_method
121+ self.http_url = http_url
122+ self.parameters = parameters or {}
123+
124+ def set_parameter(self, parameter, value):
125+ self.parameters[parameter] = value
126+
127+ def get_parameter(self, parameter):
128+ try:
129+ return self.parameters[parameter]
130+ except:
131+ raise OAuthError('Parameter not found: %s' % parameter)
132+
133+ def _get_timestamp_nonce(self):
134+ return self.get_parameter('oauth_timestamp'), self.get_parameter('oauth_nonce')
135+
136+ # get any non-oauth parameters
137+ def get_nonoauth_parameters(self):
138+ parameters = {}
139+ for k, v in self.parameters.iteritems():
140+ # ignore oauth parameters
141+ if k.find('oauth_') < 0:
142+ parameters[k] = v
143+ return parameters
144+
145+ # serialize as a header for an HTTPAuth request
146+ def to_header(self, realm=''):
147+ auth_header = 'OAuth realm="%s"' % realm
148+ # add the oauth parameters
149+ if self.parameters:
150+ for k, v in self.parameters.iteritems():
151+ auth_header += ', %s="%s"' % (k, v)
152+ return {'Authorization': auth_header}
153+
154+ # serialize as post data for a POST request
155+ def to_postdata(self):
156+ return '&'.join('%s=%s' % (escape(str(k)), escape(str(v))) for k, v in self.parameters.iteritems())
157+
158+ # serialize as a url for a GET request
159+ def to_url(self):
160+ return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata())
161+
162+ # return a string that consists of all the parameters that need to be signed
163+ def get_normalized_parameters(self):
164+ params = self.parameters
165+ try:
166+ # exclude the signature if it exists
167+ del params['oauth_signature']
168+ except:
169+ pass
170+ key_values = params.items()
171+ # sort lexicographically, first after key, then after value
172+ key_values.sort()
173+ # combine key value pairs in string and escape
174+ return '&'.join('%s=%s' % (escape(str(k)), escape(str(v))) for k, v in key_values)
175+
176+ # just uppercases the http method
177+ def get_normalized_http_method(self):
178+ return self.http_method.upper()
179+
180+ # parses the url and rebuilds it to be scheme://host/path
181+ def get_normalized_http_url(self):
182+ parts = urlparse.urlparse(self.http_url)
183+ url_string = '%s://%s%s' % (parts[0], parts[1], parts[2]) # scheme, netloc, path
184+ return url_string
185+
186+ # set the signature parameter to the result of build_signature
187+ def sign_request(self, signature_method, consumer, token):
188+ # set the signature method
189+ self.set_parameter('oauth_signature_method', signature_method.get_name())
190+ # set the signature
191+ self.set_parameter('oauth_signature', self.build_signature(signature_method, consumer, token))
192+
193+ def build_signature(self, signature_method, consumer, token):
194+ # call the build signature method within the signature method
195+ return signature_method.build_signature(self, consumer, token)
196+
197+ @staticmethod
198+ def from_request(http_method, http_url, headers=None, postdata=None, parameters=None):
199+
200+ # let the library user override things however they'd like, if they know
201+ # which parameters to use then go for it, for example XMLRPC might want to
202+ # do this
203+ if parameters is not None:
204+ return OAuthRequest(http_method, http_url, parameters)
205+
206+ # from the headers
207+ if headers is not None:
208+ try:
209+ auth_header = headers['Authorization']
210+ # check that the authorization header is OAuth
211+ auth_header.index('OAuth')
212+ # get the parameters from the header
213+ parameters = OAuthRequest._split_header(auth_header)
214+ return OAuthRequest(http_method, http_url, parameters)
215+ except:
216+ raise OAuthError('Unable to parse OAuth parameters from Authorization header.')
217+
218+ # from the parameter string (post body)
219+ if http_method == 'POST' and postdata is not None:
220+ parameters = OAuthRequest._split_url_string(postdata)
221+
222+ # from the url string
223+ elif http_method == 'GET':
224+ param_str = urlparse.urlparse(http_url).query
225+ parameters = OAuthRequest._split_url_string(param_str)
226+
227+ if parameters:
228+ return OAuthRequest(http_method, http_url, parameters)
229+
230+ raise OAuthError('Missing all OAuth parameters. OAuth parameters must be in the headers, post body, or url.')
231+
232+ @staticmethod
233+ def from_consumer_and_token(oauth_consumer, token=None, http_method=HTTP_METHOD, http_url=None, parameters=None):
234+ if not parameters:
235+ parameters = {}
236+
237+ defaults = {
238+ 'oauth_consumer_key': oauth_consumer.key,
239+ 'oauth_timestamp': generate_timestamp(),
240+ 'oauth_nonce': generate_nonce(),
241+ 'oauth_version': OAuthRequest.version,
242+ }
243+
244+ defaults.update(parameters)
245+ parameters = defaults
246+
247+ if token:
248+ parameters['oauth_token'] = token.key
249+
250+ return OAuthRequest(http_method, http_url, parameters)
251+
252+ @staticmethod
253+ def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, http_url=None, parameters=None):
254+ if not parameters:
255+ parameters = {}
256+
257+ parameters['oauth_token'] = token.key
258+
259+ if callback:
260+ parameters['oauth_callback'] = escape(callback)
261+
262+ return OAuthRequest(http_method, http_url, parameters)
263+
264+ # util function: turn Authorization: header into parameters, has to do some unescaping
265+ @staticmethod
266+ def _split_header(header):
267+ params = {}
268+ header = header.lstrip()
269+ if not header.startswith('OAuth '):
270+ raise ValueError("not an OAuth header: %r" % header)
271+ header = header[6:]
272+ parts = header.split(',')
273+ for param in parts:
274+ # remove whitespace
275+ param = param.strip()
276+ # split key-value
277+ param_parts = param.split('=', 1)
278+ if param_parts[0] == 'realm':
279+ # Realm header is not an OAuth parameter according to rfc5849
280+ # section 3.4.1.3.1.
281+ continue
282+ # remove quotes and unescape the value
283+ params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"'))
284+ return params
285+
286+ # util function: turn url string into parameters, has to do some unescaping
287+ @staticmethod
288+ def _split_url_string(param_str):
289+ parameters = cgi.parse_qs(param_str, keep_blank_values=False)
290+ for k, v in parameters.iteritems():
291+ parameters[k] = urllib.unquote(v[0])
292+ return parameters
293+
294+# OAuthServer is a worker to check a requests validity against a data store
295+class OAuthServer(object):
296+ timestamp_threshold = 300 # in seconds, five minutes
297+ version = VERSION
298+ signature_methods = None
299+ data_store = None
300+
301+ def __init__(self, data_store=None, signature_methods=None):
302+ self.data_store = data_store
303+ self.signature_methods = signature_methods or {}
304+
305+ def set_data_store(self, oauth_data_store):
306+ self.data_store = oauth_data_store
307+
308+ def get_data_store(self):
309+ return self.data_store
310+
311+ def add_signature_method(self, signature_method):
312+ self.signature_methods[signature_method.get_name()] = signature_method
313+ return self.signature_methods
314+
315+ # process a request_token request
316+ # returns the request token on success
317+ def fetch_request_token(self, oauth_request):
318+ try:
319+ # get the request token for authorization
320+ token = self._get_token(oauth_request, 'request')
321+ except:
322+ # no token required for the initial token request
323+ version = self._get_version(oauth_request)
324+ consumer = self._get_consumer(oauth_request)
325+ self._check_signature(oauth_request, consumer, None)
326+ # fetch a new token
327+ token = self.data_store.fetch_request_token(consumer)
328+ return token
329+
330+ # process an access_token request
331+ # returns the access token on success
332+ def fetch_access_token(self, oauth_request):
333+ version = self._get_version(oauth_request)
334+ consumer = self._get_consumer(oauth_request)
335+ # get the request token
336+ token = self._get_token(oauth_request, 'request')
337+ self._check_signature(oauth_request, consumer, token)
338+ new_token = self.data_store.fetch_access_token(consumer, token)
339+ return new_token
340+
341+ # verify an api call, checks all the parameters
342+ def verify_request(self, oauth_request):
343+ # -> consumer and token
344+ version = self._get_version(oauth_request)
345+ consumer = self._get_consumer(oauth_request)
346+ # get the access token
347+ token = self._get_token(oauth_request, 'access')
348+ self._check_signature(oauth_request, consumer, token)
349+ parameters = oauth_request.get_nonoauth_parameters()
350+ return consumer, token, parameters
351+
352+ # authorize a request token
353+ def authorize_token(self, token, user):
354+ return self.data_store.authorize_request_token(token, user)
355+
356+ # get the callback url
357+ def get_callback(self, oauth_request):
358+ return oauth_request.get_parameter('oauth_callback')
359+
360+ # optional support for the authenticate header
361+ def build_authenticate_header(self, realm=''):
362+ return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
363+
364+ # verify the correct version request for this server
365+ def _get_version(self, oauth_request):
366+ try:
367+ version = oauth_request.get_parameter('oauth_version')
368+ except:
369+ version = VERSION
370+ if version and version != self.version:
371+ raise OAuthError('OAuth version %s not supported' % str(version))
372+ return version
373+
374+ # figure out the signature with some defaults
375+ def _get_signature_method(self, oauth_request):
376+ try:
377+ signature_method = oauth_request.get_parameter('oauth_signature_method')
378+ except:
379+ signature_method = SIGNATURE_METHOD
380+ try:
381+ # get the signature method object
382+ signature_method = self.signature_methods[signature_method]
383+ except:
384+ signature_method_names = ', '.join(self.signature_methods.keys())
385+ raise OAuthError('Signature method %s not supported try one of the following: %s' % (signature_method, signature_method_names))
386+
387+ return signature_method
388+
389+ def _get_consumer(self, oauth_request):
390+ consumer_key = oauth_request.get_parameter('oauth_consumer_key')
391+ if not consumer_key:
392+ raise OAuthError('Invalid consumer key')
393+ consumer = self.data_store.lookup_consumer(consumer_key)
394+ if not consumer:
395+ raise OAuthError('Invalid consumer')
396+ return consumer
397+
398+ # try to find the token for the provided request token key
399+ def _get_token(self, oauth_request, token_type='access'):
400+ token_field = oauth_request.get_parameter('oauth_token')
401+ token = self.data_store.lookup_token(token_type, token_field)
402+ if not token:
403+ raise OAuthError('Invalid %s token: %s' % (token_type, token_field))
404+ return token
405+
406+ def _check_signature(self, oauth_request, consumer, token):
407+ timestamp, nonce = oauth_request._get_timestamp_nonce()
408+ self._check_timestamp(timestamp)
409+ self._check_nonce(consumer, token, nonce)
410+ signature_method = self._get_signature_method(oauth_request)
411+ try:
412+ signature = oauth_request.get_parameter('oauth_signature')
413+ except:
414+ raise OAuthError('Missing signature')
415+ # attempt to construct the same signature
416+ built = signature_method.build_signature(oauth_request, consumer, token)
417+ if signature != built:
418+ key, base = signature_method.build_signature_base_string(oauth_request, consumer, token)
419+ raise OAuthError('Signature does not match. Expected: %s Got: %s Expected signature base string: %s' % (built, signature, base))
420+
421+ def _check_timestamp(self, timestamp):
422+ # verify that timestamp is recentish
423+ timestamp = int(timestamp)
424+ now = int(time.time())
425+ lapsed = now - timestamp
426+ if lapsed > self.timestamp_threshold:
427+ raise OAuthError('Expired timestamp: given %d and now %s has a greater difference than threshold %d' % (timestamp, now, self.timestamp_threshold))
428+
429+ def _check_nonce(self, consumer, token, nonce):
430+ # verify that the nonce is uniqueish
431+ try:
432+ self.data_store.lookup_nonce(consumer, token, nonce)
433+ raise OAuthError('Nonce already used: %s' % str(nonce))
434+ except:
435+ pass
436+
437+# OAuthClient is a worker to attempt to execute a request
438+class OAuthClient(object):
439+ consumer = None
440+ token = None
441+
442+ def __init__(self, oauth_consumer, oauth_token):
443+ self.consumer = oauth_consumer
444+ self.token = oauth_token
445+
446+ def get_consumer(self):
447+ return self.consumer
448+
449+ def get_token(self):
450+ return self.token
451+
452+ def fetch_request_token(self, oauth_request):
453+ # -> OAuthToken
454+ raise NotImplementedError
455+
456+ def fetch_access_token(self, oauth_request):
457+ # -> OAuthToken
458+ raise NotImplementedError
459+
460+ def access_resource(self, oauth_request):
461+ # -> some protected resource
462+ raise NotImplementedError
463+
464+# OAuthDataStore is a database abstraction used to lookup consumers and tokens
465+class OAuthDataStore(object):
466+
467+ def lookup_consumer(self, key):
468+ # -> OAuthConsumer
469+ raise NotImplementedError
470+
471+ def lookup_token(self, oauth_consumer, token_type, token_token):
472+ # -> OAuthToken
473+ raise NotImplementedError
474+
475+ def lookup_nonce(self, oauth_consumer, oauth_token, nonce, timestamp):
476+ # -> OAuthToken
477+ raise NotImplementedError
478+
479+ def fetch_request_token(self, oauth_consumer):
480+ # -> OAuthToken
481+ raise NotImplementedError
482+
483+ def fetch_access_token(self, oauth_consumer, oauth_token):
484+ # -> OAuthToken
485+ raise NotImplementedError
486+
487+ def authorize_request_token(self, oauth_token, user):
488+ # -> OAuthToken
489+ raise NotImplementedError
490+
491+# OAuthSignatureMethod is a strategy class that implements a signature method
492+class OAuthSignatureMethod(object):
493+ def get_name():
494+ # -> str
495+ raise NotImplementedError
496+
497+ def build_signature_base_string(oauth_request, oauth_consumer, oauth_token):
498+ # -> str key, str raw
499+ raise NotImplementedError
500+
501+ def build_signature(oauth_request, oauth_consumer, oauth_token):
502+ # -> str
503+ raise NotImplementedError
504+
505+class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod):
506+
507+ def get_name(self):
508+ return 'HMAC-SHA1'
509+
510+ def build_signature_base_string(self, oauth_request, consumer, token):
511+ sig = (
512+ escape(oauth_request.get_normalized_http_method()),
513+ escape(oauth_request.get_normalized_http_url()),
514+ escape(oauth_request.get_normalized_parameters()),
515+ )
516+
517+ key = '%s&' % escape(consumer.secret)
518+ if token:
519+ key += escape(token.secret)
520+ raw = '&'.join(sig)
521+ return key, raw
522+
523+ def build_signature(self, oauth_request, consumer, token):
524+ # build the base signature string
525+ key, raw = self.build_signature_base_string(oauth_request, consumer, token)
526+
527+ # hmac object
528+ try:
529+ import hashlib # 2.5
530+ hashed = hmac.new(key, raw, hashlib.sha1)
531+ except:
532+ import sha # deprecated
533+ hashed = hmac.new(key, raw, sha)
534+
535+ # calculate the digest base 64
536+ return base64.b64encode(hashed.digest())
537+
538+class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod):
539+
540+ def get_name(self):
541+ return 'PLAINTEXT'
542+
543+ def build_signature_base_string(self, oauth_request, consumer, token):
544+ # concatenate the consumer key and secret
545+ sig = escape(consumer.secret)
546+ if token:
547+ sig = '&'.join((sig, escape(token.secret)))
548+ return sig
549+
550+ def build_signature(self, oauth_request, consumer, token):
551+ return self.build_signature_base_string(oauth_request, consumer, token)
552
553=== modified file 'lib/lp/bugs/doc/bugnotification-sending.txt'
554--- lib/lp/bugs/doc/bugnotification-sending.txt 2011-02-04 20:32:01 +0000
555+++ lib/lp/bugs/doc/bugnotification-sending.txt 2011-02-04 20:32:11 +0000
556@@ -30,20 +30,9 @@
557 ... print email_notification.get_payload(decode=True)
558 ... print "-" * 70
559
560-We'll also define some helper functions to help us with database users.
561-
562- >>> from canonical.config import config
563- >>> from canonical.database.sqlbase import commit
564- >>> from canonical.testing.layers import LaunchpadZopelessLayer
565-
566- >>> def switch_db_to_launchpad():
567- ... commit()
568- ... LaunchpadZopelessLayer.switchDbUser('launchpad')
569-
570- >>> def switch_db_to_bugnotification():
571- ... commit()
572- ... LaunchpadZopelessLayer.switchDbUser(
573- ... config.malone.bugnotification_dbuser)
574+We'll also import a helper function to help us with database users.
575+
576+ >>> from lp.testing.dbuser import lp_dbuser
577
578 You'll note that we are printing out an X-Launchpad-Message-Rationale
579 header. This header is a simple string that allows people to filter
580@@ -356,16 +345,13 @@
581 ... print member.preferredemail.email
582 marilize@hbd.com
583
584- >>> switch_db_to_launchpad()
585- >>> bug_one.subscribe(shipit_admins, shipit_admins)
586- <...>
587-
588- >>> comment = getUtility(IMessageSet).fromText(
589- ... 'subject', 'a comment.', sample_person,
590- ... datecreated=ten_minutes_ago)
591- >>> bug_one.addCommentNotification(comment)
592-
593- >>> switch_db_to_bugnotification()
594+ >>> with lp_dbuser():
595+ ... ignored = bug_one.subscribe(shipit_admins, shipit_admins)
596+ ... comment = getUtility(IMessageSet).fromText(
597+ ... 'subject', 'a comment.', sample_person,
598+ ... datecreated=ten_minutes_ago)
599+ ... bug_one.addCommentNotification(comment)
600+
601 >>> pending_notifications = getUtility(
602 ... IBugNotificationSet).getNotificationsToSend()
603 >>> len(pending_notifications)
604@@ -398,17 +384,15 @@
605 >>> params = CreateBugParams(
606 ... msg=description, owner=sample_person, title='new bug')
607
608- >>> switch_db_to_launchpad()
609- >>> new_bug = ubuntu.createBug(params)
610- >>> switch_db_to_bugnotification()
611- >>> flush_notifications()
612+ >>> with lp_dbuser():
613+ ... new_bug = ubuntu.createBug(params)
614
615 If a bug is a duplicate of another bug, a marker gets inserted at the
616 top of the email:
617
618- >>> switch_db_to_launchpad()
619- >>> new_bug.markAsDuplicate(bug_one)
620- >>> switch_db_to_bugnotification()
621+ >>> flush_notifications()
622+ >>> with lp_dbuser():
623+ ... new_bug.markAsDuplicate(bug_one)
624 >>> comment = getUtility(IMessageSet).fromText(
625 ... 'subject', 'a comment.', sample_person,
626 ... datecreated=ten_minutes_ago)
627@@ -474,12 +458,11 @@
628 ... 'Zero-day on Frobulator', 'Woah.', sample_person,
629 ... datecreated=ten_minutes_ago)
630
631- >>> switch_db_to_launchpad()
632- >>> sec_vuln_bug = ubuntu.createBug(CreateBugParams(
633+ >>> with lp_dbuser():
634+ ... sec_vuln_bug = ubuntu.createBug(CreateBugParams(
635 ... msg=sec_vuln_description, owner=sample_person,
636 ... title='Zero-day on Frobulator',
637 ... security_related=True, private=True))
638- >>> switch_db_to_bugnotification()
639
640 >>> sec_vuln_bug.security_related
641 True
642@@ -730,10 +713,8 @@
643 The tags will be space-separated to allow the list to be wrapped if it
644 gets over-long.
645
646- >>> switch_db_to_launchpad()
647- >>> bug_three.tags = [u'layout-test', u'another-tag', u'yet-another']
648-
649- >>> switch_db_to_bugnotification()
650+ >>> with lp_dbuser():
651+ ... bug_three.tags = [u'layout-test', u'another-tag', u'yet-another']
652
653 >>> bug_three = getUtility(IBugSet).get(3)
654 >>> for message in trigger_and_get_email_messages(bug_three):
655@@ -743,15 +724,13 @@
656 If we remove the tags from the bug, the X-Launchpad-Bug-Tags header
657 won't be included.
658
659- >>> switch_db_to_launchpad()
660- >>> bug_three.tags = []
661- >>> switch_db_to_bugnotification()
662+ >>> with lp_dbuser():
663+ ... bug_three.tags = []
664
665 >>> bug_three = getUtility(IBugSet).get(3)
666 >>> for message in trigger_and_get_email_messages(bug_three):
667 ... message.get_all('X-Launchpad-Bug-Tags')
668
669- >>> switch_db_to_launchpad()
670 >>> #bug_three.unsubscribe(sample_person, sample_person)
671
672
673@@ -771,7 +750,8 @@
674
675 Predictably, private bugs are sent with a slightly different header:
676
677- >>> bug_three.setPrivate(True, sample_person)
678+ >>> with lp_dbuser():
679+ ... bug_three.setPrivate(True, sample_person)
680 True
681 >>> bug_three.private
682 True
683@@ -799,7 +779,8 @@
684 The presence of the security flag on a bug is, surprise, denoted by a
685 simple "yes":
686
687- >>> bug_three.setSecurityRelated(True)
688+ >>> with lp_dbuser():
689+ ... bug_three.setSecurityRelated(True)
690 True
691 >>> bug_three.security_related
692 True
693@@ -824,9 +805,9 @@
694 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
695
696 >>> from lp.bugs.interfaces.bugmessage import IBugMessageSet
697- >>> getUtility(IBugMessageSet).createMessage(
698- ... 'Hungry', bug_three, foo_bar, "Make me a sandwich.")
699- <BugMessage ...>
700+ >>> with lp_dbuser():
701+ ... ignored = getUtility(IBugMessageSet).createMessage(
702+ ... 'Hungry', bug_three, foo_bar, "Make me a sandwich.")
703
704 >>> for message in trigger_and_get_email_messages(bug_three):
705 ... print message.get('X-Launchpad-Bug-Commenters')
706@@ -835,9 +816,9 @@
707 It only lists each user once, no matter how many comments they've
708 made.
709
710- >>> getUtility(IBugMessageSet).createMessage(
711- ... 'Hungry', bug_three, foo_bar, "Make me a sandwich.")
712- <BugMessage ...>
713+ >>> with lp_dbuser():
714+ ... ignored = getUtility(IBugMessageSet).createMessage(
715+ ... 'Hungry', bug_three, foo_bar, "Make me a sandwich.")
716
717 >>> for message in trigger_and_get_email_messages(bug_three):
718 ... print message.get('X-Launchpad-Bug-Commenters')
719@@ -868,63 +849,58 @@
720 Concise Person does not. We'll also create teams and give them members
721 with different verbose_bugnotifications settings.
722
723- >>> switch_db_to_launchpad()
724- >>> bug = factory.makeBug(
725- ... product=factory.makeProduct(title='Foo'),
726- ... title='In the beginning, the universe was created. This '
727- ... 'has made a lot of people very angry and has been '
728- ... 'widely regarded as a bad move',
729- ... description="This is a long description of the bug, which "
730- ... "will be automatically wrapped by the BugNotification "
731- ... "machinery. Ain't technology great?")
732-
733- >>> verbose_person = factory.makePerson(
734- ... displayname='Verbose Person', email='verbose@example.com')
735- >>> verbose_person.verbose_bugnotifications = True
736- >>> bug.subscribe(verbose_person, verbose_person)
737- <lp.bugs.model.bugsubscription.BugSubscription ...>
738-
739- >>> concise_person = factory.makePerson(
740- ... displayname='Concise Person', email='concise@example.com')
741- >>> concise_person.verbose_bugnotifications = False
742- >>> bug.subscribe(concise_person, concise_person)
743- <lp.bugs.model.bugsubscription.BugSubscription ...>
744+ >>> with lp_dbuser():
745+ ... bug = factory.makeBug(
746+ ... product=factory.makeProduct(title='Foo'),
747+ ... title='In the beginning, the universe was created. This '
748+ ... 'has made a lot of people very angry and has been '
749+ ... 'widely regarded as a bad move',
750+ ... description="This is a long description of the bug, which "
751+ ... "will be automatically wrapped by the BugNotification "
752+ ... "machinery. Ain't technology great?")
753+ ... verbose_person = factory.makePerson(
754+ ... displayname='Verbose Person', email='verbose@example.com')
755+ ... verbose_person.verbose_bugnotifications = True
756+ ... ignored = bug.subscribe(verbose_person, verbose_person)
757+ ... concise_person = factory.makePerson(
758+ ... displayname='Concise Person', email='concise@example.com')
759+ ... concise_person.verbose_bugnotifications = False
760+ ... ignored = bug.subscribe(concise_person, concise_person)
761
762
763 Concise Team doesn't want verbose notifications, while Concise Team
764 Person, a member, does.
765
766- >>> concise_team = factory.makeTeam(
767- ... name='conciseteam', displayname='Concise Team')
768- >>> concise_team.verbose_bugnotifications = False
769- >>> concise_team_person = factory.makePerson(
770- ... displayname='Concise Team Person',
771- ... email='conciseteam@example.com')
772- >>> concise_team_person.verbose_bugnotifications = True
773- >>> ignored = concise_team.addMember(
774- ... concise_team_person, concise_team_person)
775- >>> bug.subscribe(concise_team, concise_team_person)
776- <lp.bugs.model.bugsubscription.BugSubscription ...>
777+ >>> with lp_dbuser():
778+ ... concise_team = factory.makeTeam(
779+ ... name='conciseteam', displayname='Concise Team')
780+ ... concise_team.verbose_bugnotifications = False
781+ ... concise_team_person = factory.makePerson(
782+ ... displayname='Concise Team Person',
783+ ... email='conciseteam@example.com')
784+ ... concise_team_person.verbose_bugnotifications = True
785+ ... ignored = concise_team.addMember(
786+ ... concise_team_person, concise_team_person)
787+ ... ignored = bug.subscribe(concise_team, concise_team_person)
788
789 Verbose Team wants verbose notifications, while Verbose Team Person, a
790 member, does not.
791
792- >>> verbose_team = factory.makeTeam(
793- ... name='verboseteam', displayname='Verbose Team')
794- >>> verbose_team.verbose_bugnotifications = True
795- >>> verbose_team_person = factory.makePerson(
796- ... displayname='Verbose Team Person',
797- ... email='verboseteam@example.com')
798- >>> verbose_team_person.verbose_bugnotifications = False
799- >>> ignored = verbose_team.addMember(
800- ... verbose_team_person, verbose_team_person)
801- >>> bug.subscribe(verbose_team, verbose_team_person)
802- <lp.bugs.model.bugsubscription.BugSubscription ...>
803+ >>> with lp_dbuser():
804+ ... verbose_team = factory.makeTeam(
805+ ... name='verboseteam', displayname='Verbose Team')
806+ ... verbose_team.verbose_bugnotifications = True
807+ ... verbose_team_person = factory.makePerson(
808+ ... displayname='Verbose Team Person',
809+ ... email='verboseteam@example.com')
810+ ... verbose_team_person.verbose_bugnotifications = False
811+ ... ignored = verbose_team.addMember(
812+ ... verbose_team_person, verbose_team_person)
813+ ... ignored = bug.subscribe(verbose_team, verbose_team_person)
814
815 We'll expire all existing notifications since we're not interested in
816 them:
817
818- >>> switch_db_to_bugnotification()
819 >>> notifications = getUtility(
820 ... IBugNotificationSet).getNotificationsToSend()
821 >>> len(notifications)
822@@ -1103,19 +1079,22 @@
823
824 People (not teams) will have the choice to receive notifications from actions
825 they generated. For now, everyone receives these notifications whether they
826-want them or not. However, we can show that the attribute exists for now.
827+want them or not.
828
829- >>> flush_notifications()
830- >>> switch_db_to_launchpad()
831 >>> verbose_person.selfgenerated_bugnotifications
832 True
833+ >>> with lp_dbuser():
834+ ... verbose_person.selfgenerated_bugnotifications = False
835
836-Teams do not implement this attribute.
837+Teams provide this attribute read-only.
838
839 >>> verbose_team.selfgenerated_bugnotifications
840+ True
841+ >>> with lp_dbuser():
842+ ... verbose_team.selfgenerated_bugnotifications = False
843 Traceback (most recent call last):
844 ...
845- NotImplementedError: Teams do not support this attribute.
846+ NotImplementedError: Teams do not support changing this attribute.
847
848 Notification Recipients
849 -----------------------
850@@ -1125,16 +1104,14 @@
851 notification level of the subscription.
852
853 >>> flush_notifications()
854- >>> switch_db_to_launchpad()
855
856 >>> from lp.bugs.enum import BugNotificationLevel
857 >>> from lp.registry.interfaces.product import IProductSet
858 >>> firefox = getUtility(IProductSet).getByName('firefox')
859 >>> mr_no_privs = getUtility(IPersonSet).getByName('no-priv')
860- >>> subscription_no_priv = firefox.addBugSubscription(
861- ... mr_no_privs, mr_no_privs)
862-
863- >>> switch_db_to_bugnotification()
864+ >>> with lp_dbuser():
865+ ... subscription_no_priv = firefox.addBugSubscription(
866+ ... mr_no_privs, mr_no_privs)
867
868 The notifications generated by addCommentNotification() are sent only to
869 structural subscribers with no filters, or with the notification level
870@@ -1200,10 +1177,9 @@
871
872
873 >>> flush_notifications()
874- >>> switch_db_to_launchpad()
875- >>> filter = subscription_no_priv.newBugFilter()
876- >>> filter.bug_notification_level = BugNotificationLevel.COMMENTS
877- >>> switch_db_to_bugnotification()
878+ >>> with lp_dbuser():
879+ ... filter = subscription_no_priv.newBugFilter()
880+ ... filter.bug_notification_level = BugNotificationLevel.COMMENTS
881
882 >>> comment = getUtility(IMessageSet).fromText(
883 ... 'subject', 'another comment.', sample_person,
884@@ -1261,9 +1237,8 @@
885 no comment notifications.
886
887 >>> flush_notifications()
888- >>> switch_db_to_launchpad()
889- >>> filter.bug_notification_level = BugNotificationLevel.METADATA
890- >>> switch_db_to_bugnotification()
891+ >>> with lp_dbuser():
892+ ... filter.bug_notification_level = BugNotificationLevel.METADATA
893
894 >>> comment = getUtility(IMessageSet).fromText(
895 ... 'subject', 'no comment for no-priv.', sample_person,
896@@ -1373,9 +1348,8 @@
897 no notifications created by addChangeNotification().
898
899 >>> flush_notifications()
900- >>> switch_db_to_launchpad()
901- >>> filter.bug_notification_level = BugNotificationLevel.LIFECYCLE
902- >>> switch_db_to_bugnotification()
903+ >>> with lp_dbuser():
904+ ... filter.bug_notification_level = BugNotificationLevel.LIFECYCLE
905
906 >>> bug_one.addChangeNotification('** Summary changed to: something.',
907 ... sample_person, when=ten_minutes_ago)
908@@ -1433,10 +1407,9 @@
909 after all.
910
911 >>> flush_notifications()
912- >>> switch_db_to_launchpad()
913- >>> filter2 = subscription_no_priv.newBugFilter()
914- >>> filter2.bug_notification_level = BugNotificationLevel.METADATA
915- >>> switch_db_to_bugnotification()
916+ >>> with lp_dbuser():
917+ ... filter2 = subscription_no_priv.newBugFilter()
918+ ... filter2.bug_notification_level = BugNotificationLevel.METADATA
919
920 >>> bug_one.addChangeNotification('** Summary changed to: whatever.',
921 ... sample_person, when=ten_minutes_ago)
922
923=== modified file 'lib/lp/registry/model/person.py'
924--- lib/lp/registry/model/person.py 2011-02-04 20:32:01 +0000
925+++ lib/lp/registry/model/person.py 2011-02-04 20:32:11 +0000
926@@ -37,7 +37,6 @@
927 import weakref
928
929 from lazr.delegates import delegates
930-from lazr.restful.fields import Reference
931 import pytz
932 from sqlobject import (
933 BoolCol,
934@@ -84,6 +83,7 @@
935 from zope.event import notify
936 from zope.interface import (
937 alsoProvides,
938+ classImplements,
939 implementer,
940 implements,
941 )
942@@ -361,6 +361,7 @@
943
944
945 class PersonSettings(Storm):
946+ "The relatively rarely used settings for person (not a team)."
947
948 implements(IPersonSettings)
949
950@@ -372,6 +373,53 @@
951 selfgenerated_bugnotifications = BoolCol(notNull=True, default=True)
952
953
954+def readonly_settings(message, interface):
955+ """Make an object that disallows writes to values on the interface.
956+
957+ When you write, the message is raised in a NotImplementedError.
958+ """
959+ # We will make a class that has properties for each field on the
960+ # interface (we expect every name on the interface to correspond to a
961+ # zope.schema field). Each property will have a getter that will
962+ # return the interface default for that name; and it will have a
963+ # setter that will raise a hopefully helpful error message
964+ # explaining why writing is not allowed.
965+ # This is the setter we are going to use for every property.
966+ def unwritable(self, value):
967+ raise NotImplementedError(message)
968+ # This will become the dict of the class.
969+ data = {}
970+ # The interface.names() method returns the names on the interface. If
971+ # "all" is True, then you will also get the names on base
972+ # interfaces. That is unlikely to be needed here, but would be the
973+ # expected behavior if it were.
974+ for name in interface.names(all=True):
975+ # This next line is a work-around for a classic problem of
976+ # closures in a loop. Closures are bound (indirectly) to frame
977+ # locals, which are a mutable collection. Therefore, if we
978+ # naively make closures for each different value within a loop,
979+ # each closure will be bound to the value as it is at the *end
980+ # of the loop*. That's usually not what we want. To prevent
981+ # this, we make a helper function (which has its own locals)
982+ # that returns the actual closure we want.
983+ closure_maker = lambda result: lambda self: result
984+ # Now we make a property with the name-specific getter and the generic
985+ # setter, and put it in the dictionary of the class we are making.
986+ data[name] = property(
987+ closure_maker(interface[name].default), unwritable)
988+ # Now we have all the attributes we want. We will make the class...
989+ cls = type('Unwritable' + interface.__name__, (), data)
990+ # ...specify that the class implements the interface that we are working
991+ # with...
992+ classImplements(cls, interface)
993+ # ...and return an instance. We should only need one, since it is
994+ # read-only.
995+ return cls()
996+
997+_readonly_person_settings = readonly_settings(
998+ 'Teams do not support changing this attribute.', IPersonSettings)
999+
1000+
1001 class Person(
1002 SQLBase, HasBugsBase, HasSpecificationsMixin, HasTranslationImportsMixin,
1003 HasBranchesMixin, HasMergeProposalsMixin, HasRequestedReviewsMixin):
1004@@ -391,13 +439,11 @@
1005 @cachedproperty
1006 def _person_settings(self):
1007 if self.is_team:
1008- # Hopefully no-one ever encounters this. If someone does,
1009- # that means that the code is trying to look at
1010- # person-specific attributes on a team, and we should warn
1011- # about that explicitly to give a hint about what is wrong
1012- # (rather than merely returning None).
1013- raise NotImplementedError(
1014- 'Teams do not support this attribute.')
1015+ # Teams need to provide these attributes for reading in order for
1016+ # things like snapshots to work, but they are not actually
1017+ # pertinent to teams, so they are not actually implemented for
1018+ # writes.
1019+ return _readonly_person_settings
1020 else:
1021 # This is a person.
1022 return IStore(PersonSettings).find(
1023@@ -2112,6 +2158,7 @@
1024 ('logintoken', 'requester'),
1025 ('personlanguage', 'person'),
1026 ('personlocation', 'person'),
1027+ ('personsettings', 'person'),
1028 ('persontransferjob', 'minor_person'),
1029 ('persontransferjob', 'major_person'),
1030 ('signedcodeofconduct', 'owner'),
1031
1032=== modified file 'lib/lp/scripts/garbo.py'
1033--- lib/lp/scripts/garbo.py 2011-01-31 09:24:13 +0000
1034+++ lib/lp/scripts/garbo.py 2011-02-04 20:32:11 +0000
1035@@ -432,9 +432,12 @@
1036 postgresql.listReferences(cursor(), 'person', 'id')):
1037 # Skip things that don't link to Person.id or that link to it from
1038 # TeamParticipation or EmailAddress, as all Person entries will be
1039- # linked to from these tables.
1040+ # linked to from these tables. Similarly, PersonSettings can
1041+ # simply be deleted if it exists, because it has a 1 (or 0) to 1
1042+ # relationship with Person.
1043 if (to_table != 'person' or to_column != 'id'
1044- or from_table in ('teamparticipation', 'emailaddress')):
1045+ or from_table in ('teamparticipation', 'emailaddress',
1046+ 'personsettings')):
1047 continue
1048 self.log.debug(
1049 "Populating LinkedPeople from %s.%s"
1050@@ -510,6 +513,7 @@
1051 UPDATE EmailAddress SET person=NULL
1052 WHERE person IN (%s)
1053 """ % people_ids)
1054+ # This cascade deletes any PersonSettings records.
1055 self.store.execute("""
1056 DELETE FROM Person
1057 WHERE id IN (%s)

Subscribers

People subscribed via source and target branches

to status/vote changes: