Skip to content
This repository was archived by the owner on Mar 15, 2018. It is now read-only.

Commit df28893

Browse files
author
Andy McKay
committed
ability to ask for personal information from the paypal api (bug 719149)
1 parent 97d82df commit df28893

File tree

10 files changed

+202
-47
lines changed

10 files changed

+202
-47
lines changed

apps/constants/payments.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,22 @@
8282
(INAPP_STATUS_INACTIVE, _('Inactive')),
8383
(INAPP_STATUS_REVOKED, _('Revoked'))
8484
)
85+
86+
PAYPAL_PERSONAL = {
87+
'first': 'http://axschema.org/namePerson/first',
88+
'last': 'http://axschema.org/namePerson/last',
89+
'email': 'http://axschema.org/contact/email',
90+
'fullname': 'http://schema.openid.net/contact/fullname',
91+
'company': 'http://openid.net/schema/company/name',
92+
'country': 'http://axschema.org/contact/country/home',
93+
'payerID': 'https://www.paypal.com/webapps/auth/schema/payerID',
94+
'birthDate': 'http://axschema.org/birthDate',
95+
'home': 'http://axschema.org/contact/postalCode/home',
96+
'street1': 'http://schema.openid.net/contact/street1',
97+
'street2': 'http://schema.openid.net/contact/street2',
98+
'city': 'http://axschema.org/contact/city/home',
99+
'state': 'http://axschema.org/contact/state/home',
100+
'phone': 'http://axschema.org/contact/phone/default'
101+
}
102+
PAYPAL_PERSONAL_LOOKUP = dict([(v, k) for k, v
103+
in PAYPAL_PERSONAL.iteritems()])

apps/devhub/forms.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,8 +1034,9 @@ def __init__(self, *args, **kw):
10341034

10351035
def _show_token_msg(self, message):
10361036
"""Display warning for an invalid PayPal refund token."""
1037-
url = paypal.refund_permission_url(self.addon,
1038-
self.extra.get('dest', 'payment'))
1037+
url = paypal.get_permission_url(self.addon,
1038+
self.extra.get('dest', 'payment'),
1039+
['REFUND'])
10391040
msg = _(' <a href="%s">Visit PayPal to grant permission'
10401041
' for refunds on your behalf.</a>') % url
10411042
messages.warning(self.request, '%s %s' % (message, Markup(msg)))

apps/devhub/tests/test_views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -991,9 +991,9 @@ def test_no_token(self):
991991
"support_email": "dev@example.com"})
992992
assert 'refund token' in pq(res.content)('.notification-box')[0].text
993993

994-
@mock.patch('paypal.check_refund_permission')
995-
def test_with_token(self, crp):
996-
crp.return_value = True
994+
@mock.patch('paypal.check_permission')
995+
def test_with_token(self, cp):
996+
cp.return_value = True
997997
self.setup_premium()
998998
self.addon.addonpremium.update(paypal_permissions_token='foo')
999999
res = self.client.post(self.url, {"paypal_id": "a@a.com",

apps/market/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ def has_valid_permissions_token(self):
204204
return True
205205
if not self.paypal_permissions_token:
206206
return False
207-
return paypal.check_refund_permission(self.paypal_permissions_token)
207+
return paypal.check_permission(self.paypal_permissions_token,
208+
['REFUND'])
208209

209210

210211
class PreApprovalUser(amo.models.ModelBase):

apps/market/tests/test_models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ def test_has_permissions_token_ignore(self):
4444
assert ap.has_permissions_token()
4545

4646
@mock.patch('paypal.should_ignore_paypal', lambda: False)
47-
@mock.patch('paypal.check_refund_permission')
48-
def test_has_valid_permissions_token(self, check_refund_permission):
47+
@mock.patch('paypal.check_permission')
48+
def test_has_valid_permissions_token(self, check_permission):
4949
ap = AddonPremium.objects.create(addon=self.addon)
5050
assert not ap.has_valid_permissions_token()
51-
check_refund_permission.return_value = True
51+
check_permission.return_value = True
5252
ap.paypal_permissions_token = 'some_token'
5353
assert ap.has_valid_permissions_token()
5454

apps/paypal/__init__.py

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import commonware.log
1414
from django_statsd.clients import statsd
1515

16+
import amo
1617
from amo.helpers import absolutify, loc, urlparams
1718
from amo.urlresolvers import reverse
1819
from amo.utils import log_cef
@@ -241,10 +242,58 @@ def refund(paykey):
241242
return responses
242243

243244

244-
def check_refund_permission(token):
245+
def get_personal_data(token):
246+
"""
247+
Ask PayPal for personal data based on the token. This makes two API
248+
calls to PayPal. It's assumed you've already done the check_permission
249+
call below.
250+
Documentation: http://bit.ly/xy5BTs and http://bit.ly/yRYbRx
251+
"""
252+
def call(api, data):
253+
try:
254+
with statsd.timer('paypal.get.personal'):
255+
r = _call(settings.PAYPAL_PERMISSIONS_URL + api, data)
256+
except PaypalError, error:
257+
paypal_log.debug('Paypal returned an error when getting personal'
258+
'data for token: %s... error: %s'
259+
% (token[:10], error))
260+
raise
261+
return r
262+
263+
# A mapping fo the api and the values passed to the API.
264+
calls = {
265+
'GetBasicPersonalData':
266+
{'attributeList.attribute':
267+
[amo.PAYPAL_PERSONAL[k] for k in
268+
['first', 'last', 'email', 'fullname',
269+
'company', 'country', 'payerID']]},
270+
'GetAdvancedPersonalData':
271+
{'attributeList.attribute':
272+
[amo.PAYPAL_PERSONAL[k] for k in
273+
['birthDate', 'home', 'street1',
274+
'street2', 'city', 'state', 'phone']]}
275+
}
276+
277+
result = {}
278+
for url, data in calls.items():
279+
data = call(url, data)
280+
for k, v in data.items():
281+
if k.endswith('personalDataKey'):
282+
k_ = k.rsplit('.', 1)[0]
283+
v_ = amo.PAYPAL_PERSONAL_LOOKUP[v]
284+
# If the value isn't present the value won't be there.
285+
result[v_] = data.get(k_ + '.personalDataValue', '')
286+
287+
return result
288+
289+
290+
def check_permission(token, permissions):
245291
"""
246292
Asks PayPal whether the PayPal ID for this account has granted
247-
refund permission to us.
293+
the permissions requested to us. Permissions are strings from the
294+
PayPal documentation.
295+
Documentation: http://bit.ly/zlhXlT
296+
248297
"""
249298
# This is set in settings_test so we don't start calling PayPal
250299
# by accident. Explicitly set this in your tests.
@@ -264,19 +313,21 @@ def check_refund_permission(token):
264313
# make sure REFUND is one of them.
265314
paypal_log.debug('Paypal returned permissions for token: %s.. perms: %s'
266315
% (token[:10], r))
267-
return 'REFUND' in [v for (k, v) in r.iteritems()
268-
if k.startswith('scope')]
316+
result = [v for (k, v) in r.iteritems() if k.startswith('scope')]
317+
return set(permissions).issubset(set(result))
269318

270319

271-
def refund_permission_url(addon, dest='payments'):
320+
def get_permission_url(addon, dest, scope):
272321
"""
273-
Send permissions request to PayPal for refund privileges on
274-
this addon's paypal account. Returns URL on PayPal site to visit.
322+
Send permissions request to PayPal for privileges on
323+
this PayPal account. Returns URL on PayPal site to visit.
324+
Documentation: http://bit.ly/zlhXlT
275325
"""
276326
# This is set in settings_test so we don't start calling PayPal
277327
# by accident. Explicitly set this in your tests.
278328
if not settings.PAYPAL_PERMISSIONS_URL:
279329
return ''
330+
280331
paypal_log.debug('Getting refund permission URL for addon: %s' % addon.pk)
281332

282333
with statsd.timer('paypal.permissions.url'):
@@ -285,7 +336,7 @@ def refund_permission_url(addon, dest='payments'):
285336
dest=dest)
286337
try:
287338
r = _call(settings.PAYPAL_PERMISSIONS_URL + 'RequestPermissions',
288-
{'scope': 'REFUND', 'callback': absolutify(url)})
339+
{'scope': scope, 'callback': absolutify(url)})
289340
except PaypalError, e:
290341
paypal_log.debug('Error on refund permission URL addon: %s, %s' %
291342
(addon.pk, e))
@@ -344,6 +395,23 @@ def get_preapproval_url(key):
344395
preapprovalkey=key)
345396

346397

398+
def _nvp_dump(data):
399+
"""
400+
Dumps a dict out into NVP pairs suitable for PayPal to consume.
401+
"""
402+
out = []
403+
escape = lambda k, v: '%s=%s' % (k, urlquote(v))
404+
# This must be sorted for chained payments to work correctly.
405+
for k, v in sorted(data.items()):
406+
if isinstance(v, (list, tuple)):
407+
out.extend([escape('%s(%s)' % (k, x), v_)
408+
for x, v_ in enumerate(v)])
409+
else:
410+
out.append(escape(k, v))
411+
412+
return '&'.join(out)
413+
414+
347415
def _call(url, paypal_data, ip=None):
348416
request = urllib2.Request(url)
349417

@@ -364,12 +432,10 @@ def _call(url, paypal_data, ip=None):
364432

365433
# Warning, a urlencode will not work with chained payments, it must
366434
# be sorted and the key should not be escaped.
367-
data = '&'.join(['%s=%s' % (k, urlquote(v))
368-
for k, v in sorted(paypal_data.items())])
369435
opener = urllib2.build_opener()
370436
try:
371437
with socket_timeout(10):
372-
feeddata = opener.open(request, data).read()
438+
feeddata = opener.open(request, _nvp_dump(paypal_data)).read()
373439
except AuthError, error:
374440
paypal_log.error('Authentication error: %s' % error)
375441
raise

apps/paypal/check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def check_refund(self):
7373
return
7474

7575
try:
76-
status = paypal.check_refund_permission(token)
76+
status = paypal.check_permission(token, ['REFUND'])
7777
if not status:
7878
self.failure(test_id, loc('No permission to do refunds.'))
7979
else:

apps/paypal/tests/test.py

Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# -*- coding: utf8 -*-
1+
# -*- coding: utf-8 -*-
22
from cStringIO import StringIO
33
from datetime import datetime, timedelta
44
from decimal import Decimal
@@ -247,63 +247,81 @@ def test_check_paypal_id(urlopen_mock):
247247
eq_(val, (True, None))
248248

249249

250+
def test_nvp():
251+
eq_(paypal._nvp_dump({'foo': 'bar'}), 'foo=bar')
252+
eq_(paypal._nvp_dump({'foo': 'ba r'}), 'foo=ba%20r')
253+
eq_(paypal._nvp_dump({'foo': 'bar', 'bar': 'foo'}), 'bar=foo&foo=bar')
254+
eq_(paypal._nvp_dump({'foo': ['bar', 'baa']}), 'foo(0)=bar&foo(1)=baa')
255+
256+
250257
@mock.patch('paypal._call')
251258
@mock.patch.object(settings, 'PAYPAL_PERMISSIONS_URL', 'something')
252259
class TestRefundPermissions(amo.tests.TestCase):
253260

254261
def setUp(self):
255262
self.addon = Addon(type=amo.ADDON_EXTENSION, slug='foo')
256263

257-
def test_refund_permissions_url(self, _call):
264+
def test_get_permissions_url(self, _call):
258265
"""
259-
`paypal_refund_permission_url` returns an URL for PayPal's
266+
`paypal_get_permission_url` returns an URL for PayPal's
260267
permissions request service containing the token PayPal gives
261268
us.
262269
"""
263270
_call.return_value = {'token': 'foo'}
264-
assert 'foo' in paypal.refund_permission_url(self.addon)
271+
assert 'foo' in paypal.get_permission_url(self.addon, '', [])
265272

266-
def test_refund_permissions_url_settings(self, _call):
273+
def test_get_permissions_url_settings(self, _call):
267274
settings.PAYPAL_PERMISSIONS_URL = ''
268-
assert not paypal.refund_permission_url(self.addon)
275+
assert not paypal.get_permission_url(self.addon, '', [])
269276

270-
def test_refund_permissions_url_malformed(self, _call):
277+
def test_get_permissions_url_malformed(self, _call):
271278
_call.side_effect = paypal.PaypalError(id='580028')
272-
assert 'wont-work' in paypal.refund_permission_url(self.addon)
279+
assert 'wont-work' in paypal.get_permission_url(self.addon)
273280

274-
def test_refund_permissions_url_error(self, _call):
281+
def test_get_permissions_url_error(self, _call):
275282
_call.side_effect = paypal.PaypalError
276283
with self.assertRaises(paypal.PaypalError):
277-
paypal.refund_permission_url(self.addon)
284+
paypal.get_permission_url(self.addon, '', [])
285+
286+
def test_get_permissions_url_scope(self, _call):
287+
_call.return_value = {'token': 'foo'}
288+
paypal.get_permission_url(self.addon, '', ['REFUND', 'FOO'])
289+
eq_(_call.call_args[0][1]['scope'], ['REFUND', 'FOO'])
278290

279-
def test_check_refund_permission_fail(self, _call):
291+
def test_check_permission_fail(self, _call):
280292
"""
281293
`check_paypal_refund_permission` returns False if PayPal
282294
doesn't put 'REFUND' in the permissions response.
283295
"""
284296
_call.return_value = {'scope(0)': 'HAM_SANDWICH'}
285-
assert not paypal.check_refund_permission('foo')
297+
assert not paypal.check_permission('foo', ['REFUND'])
286298

287-
def test_check_refund_permission(self, _call):
299+
def test_check_permission(self, _call):
288300
"""
289301
`check_paypal_refund_permission` returns True if PayPal
290302
puts 'REFUND' in the permissions response.
291303
"""
292304
_call.return_value = {'scope(0)': 'REFUND'}
293-
eq_(paypal.check_refund_permission('foo'), True)
305+
eq_(paypal.check_permission('foo', ['REFUND']), True)
294306

295-
def test_check_refund_permission_error(self, _call):
307+
def test_check_permission_error(self, _call):
296308
_call.side_effect = paypal.PaypalError
297-
assert not paypal.check_refund_permission('oh-noes')
309+
assert not paypal.check_permission('oh-noes', ['REFUND'])
298310

299-
def test_check_refund_permission_settings(self, _call):
311+
def test_check_permission_settings(self, _call):
300312
settings.PAYPAL_PERMISSIONS_URL = ''
301-
assert not paypal.check_refund_permission('oh-noes')
313+
assert not paypal.check_permission('oh-noes', ['REFUND'])
302314

303315
def test_get_permissions_token(self, _call):
304316
_call.return_value = {'token': 'FOO'}
305317
eq_(paypal.get_permissions_token('foo', ''), 'FOO')
306318

319+
def test_get_permissions_subset(self, _call):
320+
_call.return_value = {'scope(0)': 'REFUND', 'scope(1)': 'HAM'}
321+
eq_(paypal.check_permission('foo', ['REFUND', 'HAM']), True)
322+
eq_(paypal.check_permission('foo', ['REFUND', 'JAM']), False)
323+
eq_(paypal.check_permission('foo', ['REFUND']), True)
324+
307325

308326
good_refund_string = (
309327
'refundInfoList.refundInfo(0).receiver.amount=123.45'
@@ -415,3 +433,55 @@ def test_preapproval_url(self, _call):
415433
url = paypal.get_preapproval_url('foo')
416434
assert (url.startswith(settings.PAYPAL_CGI_URL) and
417435
url.endswith('foo')), 'Incorrect URL returned'
436+
437+
438+
# This data is truncated
439+
good_personal_basic = {
440+
'response.personalData(0).personalDataKey':
441+
'http://axschema.org/contact/country/home',
442+
'response.personalData(0).personalDataValue': 'US',
443+
'response.personalData(1).personalDataValue': 'batman@gmail.com',
444+
'response.personalData(1).personalDataKey':
445+
'http://axschema.org/contact/email',
446+
'response.personalData(2).personalDataValue': 'man'}
447+
448+
good_personal_advanced = {
449+
'response.personalData(0).personalDataKey':
450+
'http://schema.openid.net/contact/street1',
451+
'response.personalData(0).personalDataValue': '1 Main St',
452+
'response.personalData(1).personalDataKey':
453+
'http://schema.openid.net/contact/street2',
454+
'response.personalData(2).personalDataValue': 'San Jose',
455+
'response.personalData(2).personalDataKey':
456+
'http://axschema.org/contact/city/home'}
457+
458+
459+
@mock.patch('paypal._call')
460+
class TestPersonalLookup(amo.tests.TestCase):
461+
462+
def setUp(self):
463+
self.data = {'GetBasicPersonalData': good_personal_basic,
464+
'GetAdvancedPersonalData': good_personal_advanced}
465+
466+
def _call(self, *args, **kw):
467+
return self.data[args[0]]
468+
469+
def test_preapproval_works(self, _call):
470+
_call.side_effect = self._call
471+
eq_(paypal.get_personal_data('foo')['email'], 'batman@gmail.com')
472+
eq_(_call.call_count, 2)
473+
474+
def test_preapproval_absent(self, _call):
475+
_call.side_effect = self._call
476+
eq_(paypal.get_personal_data('foo')['street2'], '')
477+
478+
def test_preapproval_unicode(self, _call):
479+
key = 'response.personalData(2).personalDataValue'
480+
value = u'Österreich'
481+
self.data['GetAdvancedPersonalData'][key] = value
482+
_call.side_effect = self._call
483+
eq_(paypal.get_personal_data('foo')['city'], value)
484+
485+
def test_preapproval_error(self, _call):
486+
_call.side_effect = paypal.PaypalError
487+
self.assertRaises(paypal.PaypalError, paypal.get_personal_data, 'foo')

0 commit comments

Comments
 (0)