Merge lp:~robru/friends/linkedin-protocol into lp:friends

Proposed by Robert Bruce Park
Status: Merged
Approved by: Robert Bruce Park
Approved revision: 205
Merged at revision: 223
Proposed branch: lp:~robru/friends/linkedin-protocol
Merge into: lp:friends
Diff against target: 884 lines (+599/-56)
10 files modified
debian/control (+16/-7)
debian/friends-linkedin.install (+1/-0)
friends/protocols/facebook.py (+4/-10)
friends/protocols/linkedin.py (+137/-0)
friends/protocols/twitter.py (+4/-12)
friends/tests/data/linkedin_contacts.json (+53/-0)
friends/tests/data/linkedin_receive.json (+177/-0)
friends/tests/test_facebook.py (+6/-12)
friends/tests/test_linkedin.py (+182/-0)
friends/utils/base.py (+19/-15)
To merge this branch: bzr merge lp:~robru/friends/linkedin-protocol
Reviewer Review Type Date Requested Status
PS Jenkins bot (community) continuous-integration Approve
Robert Bruce Park Approve
Review via email: mp+175416@code.launchpad.net

Commit message

Add LinkedIn support.

Description of the change

So close to landing this! Just need to write a test or two for the contacts implementation.

To post a comment you must log in.
lp:~robru/friends/linkedin-protocol updated
204. By Robert Bruce Park

Safer default value for result.get().

205. By Robert Bruce Park

Scrubbed demo data and test coverage for LinkedIn contacts.

Revision history for this message
Robert Bruce Park (robru) :
review: Approve
Revision history for this message
PS Jenkins bot (ps-jenkins) :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'debian/control'
2--- debian/control 2013-07-08 15:17:44 +0000
3+++ debian/control 2013-07-18 03:36:30 +0000
4@@ -78,7 +78,7 @@
5
6 Package: friends-facebook
7 Architecture: all
8-Depends: friends,
9+Depends: friends,
10 ${misc:Depends},
11 ${python3:Depends},
12 account-plugin-facebook,
13@@ -87,7 +87,7 @@
14
15 Package: friends-twitter
16 Architecture: all
17-Depends: friends,
18+Depends: friends,
19 ${misc:Depends},
20 ${python3:Depends},
21 account-plugin-twitter,
22@@ -96,7 +96,7 @@
23
24 Package: friends-identica
25 Architecture: all
26-Depends: friends,
27+Depends: friends,
28 ${misc:Depends},
29 ${python3:Depends},
30 account-plugin-identica,
31@@ -105,7 +105,7 @@
32
33 Package: friends-foursquare
34 Architecture: all
35-Depends: friends,
36+Depends: friends,
37 ${misc:Depends},
38 ${python3:Depends},
39 account-plugin-foursquare,
40@@ -114,7 +114,7 @@
41
42 Package: friends-flickr
43 Architecture: all
44-Depends: friends,
45+Depends: friends,
46 ${misc:Depends},
47 ${python3:Depends},
48 account-plugin-flickr,
49@@ -123,9 +123,18 @@
50
51 Package: friends-instagram
52 Architecture: all
53-Depends: friends,
54+Depends: account-plugin-instagram,
55+ friends,
56 ${misc:Depends},
57 ${python3:Depends},
58- account-plugin-instagram,
59 Description: Social integration with the desktop - Instagram
60 Provides social networking integration with the desktop
61+
62+Package: friends-linkedin
63+Architecture: all
64+Depends: account-plugin-linkedin,
65+ friends,
66+ ${misc:Depends},
67+ ${python3:Depends},
68+Description: Social integration with the desktop - LinkedIn
69+ Provides social networking integration with the desktop
70
71=== added file 'debian/friends-linkedin.install'
72--- debian/friends-linkedin.install 1970-01-01 00:00:00 +0000
73+++ debian/friends-linkedin.install 2013-07-18 03:36:30 +0000
74@@ -0,0 +1,1 @@
75+usr/lib/python3/dist-packages/friends/protocols/linkedin*
76
77=== modified file 'friends/protocols/facebook.py'
78--- friends/protocols/facebook.py 2013-06-18 22:00:44 +0000
79+++ friends/protocols/facebook.py 2013-07-18 03:36:30 +0000
80@@ -37,7 +37,6 @@
81 PERMALINK = URL_BASE.format(subdomain='www') + '{id}'
82 API_BASE = URL_BASE.format(subdomain='graph') + '{id}'
83 ME_URL = API_BASE.format(id='me')
84-FACEBOOK_ADDRESS_BOOK = 'friends-facebook-contacts'
85 STORY_PERMALINK = PERMALINK + '/posts/{post_id}'
86
87
88@@ -364,18 +363,13 @@
89 if gender is not None:
90 attrs['X-GENDER'] = gender
91
92- contact = Base._create_contact(
93- self, user_fullname, user_nickname, attrs)
94-
95- return contact
96+ return super()._create_contact(user_fullname, user_nickname, attrs)
97
98 @feature
99 def contacts(self):
100 contacts = self._fetch_contacts()
101 log.debug('Size of the contacts returned {}'.format(len(contacts)))
102- source = self._get_eds_source(FACEBOOK_ADDRESS_BOOK)
103- if source is None:
104- source = self._create_eds_source(FACEBOOK_ADDRESS_BOOK)
105+ source = self._get_eds_source()
106
107 for contact in contacts:
108 if self._previously_stored_contact(
109@@ -386,12 +380,12 @@
110 contact['name'], contact['id']))
111 full_contact = self._fetch_contact(contact['id'])
112 eds_contact = self._create_contact(full_contact)
113- self._push_to_eds(FACEBOOK_ADDRESS_BOOK, eds_contact)
114+ self._push_to_eds(eds_contact)
115
116 return len(contacts)
117
118 def delete_contacts(self):
119- source = self._get_eds_source(FACEBOOK_ADDRESS_BOOK)
120+ source = self._get_eds_source()
121 return self._delete_service_contacts(source)
122
123
124
125=== added file 'friends/protocols/linkedin.py'
126--- friends/protocols/linkedin.py 1970-01-01 00:00:00 +0000
127+++ friends/protocols/linkedin.py 2013-07-18 03:36:30 +0000
128@@ -0,0 +1,137 @@
129+# friends-dispatcher -- send & receive messages from any social network
130+# Copyright (C) 2013 Canonical Ltd
131+#
132+# This program is free software: you can redistribute it and/or modify
133+# it under the terms of the GNU General Public License as published by
134+# the Free Software Foundation, version 3 of the License.
135+#
136+# This program is distributed in the hope that it will be useful,
137+# but WITHOUT ANY WARRANTY; without even the implied warranty of
138+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
139+# GNU General Public License for more details.
140+#
141+# You should have received a copy of the GNU General Public License
142+# along with this program. If not, see <http://www.gnu.org/licenses/>.
143+
144+"""The LinkedIn protocol plugin."""
145+
146+
147+__all__ = [
148+ 'LinkedIn',
149+ ]
150+
151+
152+import logging
153+
154+from friends.utils.base import Base, feature
155+from friends.utils.http import Downloader
156+from friends.utils.time import iso8601utc
157+from friends.errors import FriendsError
158+
159+
160+log = logging.getLogger(__name__)
161+
162+
163+class LinkedIn(Base):
164+ _api_base = ('https://api.linkedin.com/v1/{endpoint}?format=json' +
165+ '&secure-urls=true&oauth2_access_token={token}')
166+
167+ def _whoami(self, authdata):
168+ """Identify the authenticating user."""
169+ # http://developer.linkedin.com/documents/profile-fields
170+ url = self._api_base.format(
171+ endpoint='people/~:(id,first-name,last-name)',
172+ token=self._get_access_token())
173+ result = Downloader(url).get_json()
174+ self._account.user_id = result.get('id')
175+ self._account.user_name = '{firstName} {lastName}'.format(**result)
176+
177+ def _publish_entry(self, entry, stream='messages'):
178+ """Publish a single update into the Dee.SharedModel."""
179+ message_id = entry.get('updateKey')
180+
181+ content = entry.get('updateContent', {})
182+ person = content.get('person', {})
183+ name = '{firstName} {lastName}'.format(**person)
184+ person_id = person.get('id', '')
185+ status = person.get('currentStatus')
186+ picture = person.get('pictureUrl', '')
187+ url = person.get('siteStandardProfileRequest', {}).get('url', '')
188+ timestamp = entry.get('timestamp', 0)
189+ # We need to divide by 1000 here, as LinkedIn's timestamps have
190+ # milliseconds.
191+ iso_time = iso8601utc(int(timestamp/1000))
192+
193+ likes = entry.get('numLikes', 0)
194+
195+ if None in (message_id, status):
196+ # Something went wrong; just ignore this malformed message.
197+ return
198+
199+ args = dict(
200+ message_id=message_id,
201+ stream=stream,
202+ message=status,
203+ likes=likes,
204+ sender_id=person_id,
205+ sender=name,
206+ icon_uri=picture,
207+ url=url,
208+ timestamp=iso_time
209+ )
210+
211+ self._publish(**args)
212+
213+ @feature
214+ def home(self):
215+ """Gather and publish public timeline messages."""
216+ url = self._api_base.format(
217+ endpoint='people/~/network/updates',
218+ token=self._get_access_token()) + '&type=STAT'
219+ result = Downloader(url).get_json()
220+ for update in result.get('values', []):
221+ self._publish_entry(update)
222+ return self._get_n_rows()
223+
224+ @feature
225+ def receive(self):
226+ """Gather and publish all incoming messages."""
227+ return self.home()
228+
229+ def _create_contact(self, connection_json):
230+ """Build a VCard based on a dict representation of a contact."""
231+ user_id = connection_json.get('id', '')
232+
233+ user_fullname = '{firstName} {lastName}'.format(**connection_json)
234+ user_link = connection_json.get(
235+ 'siteStandardProfileRequest', {}).get('url', '')
236+
237+ attrs = { 'linkedin-id': user_id,
238+ 'linkedin-name': user_fullname,
239+ 'X-URIS': user_link }
240+
241+ return super()._create_contact(user_fullname, None, attrs)
242+
243+ @feature
244+ def contacts(self):
245+ """Retrieve a list of up to 500 LinkedIn connections."""
246+ # http://developer.linkedin.com/documents/connections-api
247+ url = self._api_base.format(
248+ endpoint='people/~/connections',
249+ token=self._get_access_token())
250+ result = Downloader(url).get_json()
251+ connections = result.get('values', [])
252+ source = self._get_eds_source()
253+
254+ for connection in connections:
255+ connection_id = connection.get('id')
256+ if connection_id != 'private':
257+ if not self._previously_stored_contact(
258+ source, 'linkedin-id', connection_id):
259+ self._push_to_eds(self._create_contact(connection))
260+
261+ return len(connections)
262+
263+ def delete_contacts(self):
264+ source = self._get_eds_source()
265+ return self._delete_service_contacts(source)
266
267=== modified file 'friends/protocols/twitter.py'
268--- friends/protocols/twitter.py 2013-06-18 22:00:44 +0000
269+++ friends/protocols/twitter.py 2013-07-18 03:36:30 +0000
270@@ -34,9 +34,6 @@
271 from friends.errors import FriendsError, ignored
272
273
274-TWITTER_ADDRESS_BOOK = 'friends-twitter-contacts'
275-
276-
277 log = logging.getLogger(__name__)
278
279
280@@ -388,18 +385,13 @@
281 'twitter-id': str(userdata['id']),
282 }
283
284- contact = Base._create_contact(
285- self, user_fullname, user_nickname, attrs)
286-
287- return contact
288+ return super()._create_contact(user_fullname, user_nickname, attrs)
289
290 @feature
291 def contacts(self):
292 contacts = self._getfriendsids()
293 log.debug('Size of the contacts returned {}'.format(len(contacts)))
294- source = self._get_eds_source(TWITTER_ADDRESS_BOOK)
295- if source is None:
296- source = self._create_eds_source(TWITTER_ADDRESS_BOOK)
297+ source = self._get_eds_source()
298
299 for contact in contacts:
300 twitterid = str(contact)
301@@ -410,11 +402,11 @@
302 eds_contact = self._create_contact(full_contact)
303 except FriendsError:
304 continue
305- self._push_to_eds(TWITTER_ADDRESS_BOOK, eds_contact)
306+ self._push_to_eds(eds_contact)
307 return len(contacts)
308
309 def delete_contacts(self):
310- source = self._get_eds_source(TWITTER_ADDRESS_BOOK)
311+ source = self._get_eds_source()
312 return self._delete_service_contacts(source)
313
314
315
316=== added file 'friends/tests/data/linkedin_contacts.json'
317--- friends/tests/data/linkedin_contacts.json 1970-01-01 00:00:00 +0000
318+++ friends/tests/data/linkedin_contacts.json 2013-07-18 03:36:30 +0000
319@@ -0,0 +1,53 @@
320+{"_total": 4,
321+ "values": [{"apiStandardProfileRequest": {"headers": {"_total": 1,
322+ "values": [{"name": "x-li-auth-token",
323+ "value": "name:"}]},
324+ "url": "http://api.linkedin.com"},
325+ "firstName": "H",
326+ "headline": "Unix Administrator at NVIDIA",
327+ "id": "IFDI",
328+ "industry": "Computer Network Security",
329+ "lastName": "A",
330+ "location": {"country": {"code": "in"},
331+ "name": "Pune Area, India"},
332+ "pictureUrl": "http://m.c.lnkd.licdn.com",
333+ "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}},
334+ {"apiStandardProfileRequest": {"headers": {"_total": 1,
335+ "values": [{"name": "x-li-auth-token",
336+ "value": "name:"}]},
337+ "url": "http://api.linkedin.com"},
338+ "firstName": "C",
339+ "headline": "Recent Graduate, Simon Fraser University",
340+ "id": "AefF",
341+ "industry": "Food Production",
342+ "lastName": "A",
343+ "location": {"country": {"code": "ca"},
344+ "name": "Vancouver, Canada Area"},
345+ "pictureUrl": "http://m.c.lnkd.licdn.com",
346+ "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}},
347+ {"apiStandardProfileRequest": {"headers": {"_total": 1,
348+ "values": [{"name": "x-li-auth-token",
349+ "value": "name:"}]},
350+ "url": "http://api.linkedin.com"},
351+ "firstName": "R",
352+ "headline": "Technical Lead at Canonical Ltd.",
353+ "id": "DFdV",
354+ "industry": "Computer Software",
355+ "lastName": "A",
356+ "location": {"country": {"code": "nz"},
357+ "name": "Auckland, New Zealand"},
358+ "pictureUrl": "http://m.c.lnkd.licdn.com",
359+ "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}},
360+ {"apiStandardProfileRequest": {"headers": {"_total": 1,
361+ "values": [{"name": "x-li-auth-token",
362+ "value": "name:"}]},
363+ "url": "http://api.linkedin.com"},
364+ "firstName": "A",
365+ "headline": "Sales manager at McBain Camera",
366+ "id": "xkBU",
367+ "industry": "Photography",
368+ "lastName": "Z",
369+ "location": {"country": {"code": "ca"},
370+ "name": "Edmonton, Canada Area"},
371+ "pictureUrl": "http://m.c.lnkd.licdn.com",
372+ "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}}]}
373
374=== added file 'friends/tests/data/linkedin_receive.json'
375--- friends/tests/data/linkedin_receive.json 1970-01-01 00:00:00 +0000
376+++ friends/tests/data/linkedin_receive.json 2013-07-18 03:36:30 +0000
377@@ -0,0 +1,177 @@
378+{"_count": 10,
379+ "_start": 0,
380+ "_total": 23,
381+ "values": [{"isCommentable": true,
382+ "isLikable": true,
383+ "isLiked": false,
384+ "likes": {"_total": 1,
385+ "values": [{"person": {"firstName": "Tigran",
386+ "headline": "Software Engineer at Cornerstone OnDemand",
387+ "id": "6f7TDUv",
388+ "lastName": "K",
389+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_zR-8KkCNtotClCYzyiPKFr9rqDrlCYBM60KFPQftJhzvOMjGuObFeOSfTn_tq4rx8jhPrK"}}]},
390+ "numLikes": 1,
391+ "timestamp": 1373935626874,
392+ "updateComments": {"_total": 0},
393+ "updateContent": {"person": {"apiStandardProfileRequest": {"headers": {"_total": 1,
394+ "values": [{"name": "x-li-auth-token",
395+ "value": "name:-LNy"}]},
396+ "url": "http://api.linkedin.com/v1/people/Pa0L6dU"},
397+ "currentStatus": "I'm looking forward to the Udacity Global meetup event here in Portland: http://lnkd.in/dh5MQz\nGreat way to support the next big thing in c…",
398+ "firstName": "Hobson",
399+ "headline": "Developer at Building Energy, Inc",
400+ "id": "ma0LLid",
401+ "lastName": "L",
402+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_mVxsC0BnN52aqc24yWvoyA5haqc2ZwLCgzLv0EiBGp7n2jTwX-ls_dzgkSVIZu0",
403+ "siteStandardProfileRequest": {"url": "https://www.linkedin.com/profile/view?id=7375&authType=name&authToken=-LNy&trk=api*a26127*s26893*"}}},
404+ "updateKey": "UNIU-73705-576270369559388-SHARE",
405+ "updateType": "STAT"},
406+ {"isCommentable": true,
407+ "isLikable": true,
408+ "isLiked": false,
409+ "numLikes": 0,
410+ "timestamp": 1373900948273,
411+ "updateComments": {"_total": 0},
412+ "updateContent": {"person": {"firstName": "private",
413+ "id": "private",
414+ "lastName": "private"}},
415+ "updateKey": "UNIU-1060864-576255824267264-SHARE",
416+ "updateType": "STAT"},
417+ {"isCommentable": true,
418+ "isLikable": true,
419+ "isLiked": false,
420+ "numLikes": 0,
421+ "timestamp": 1373899922654,
422+ "updateComments": {"_total": 0},
423+ "updateContent": {"person": {"firstName": "private",
424+ "id": "private",
425+ "lastName": "private"}},
426+ "updateKey": "UNIU-106088-5769411789496-SHARE",
427+ "updateType": "STAT"},
428+ {"isCommentable": true,
429+ "isLikable": true,
430+ "isLiked": false,
431+ "likes": {"_total": 3,
432+ "values": [{"person": {"firstName": "J. P.",
433+ "headline": "Software Technologist",
434+ "id": "GUUXiHdd40",
435+ "lastName": "N",
436+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_3Y7iZs_hWCTi0d-ZxAJn8XpT_pU-ZxGoW6GSZxT90nXZSnShgSPB4rdoNQi"}},
437+ {"person": {"firstName": "Tyler",
438+ "headline": "Senior Associate at Toffler Associates",
439+ "id": "RCYVRJ",
440+ "lastName": "S",
441+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_i7W-Lvgud5pur7I-eBpecy7IlZe0yFr_zWdOUMNHHuhiLkRWj8ufDpQBz"}},
442+ {"person": {"firstName": "Paweł",
443+ "headline": "Senior programmer at EMG Systems",
444+ "id": "FjbEJi",
445+ "lastName": "S",
446+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_SEgTdwePXDoInf9XIm-XfHanGqqoLk3uoLT0aMWtuW4ch4ekYCqF"}}]},
447+ "numLikes": 3,
448+ "timestamp": 1373638523241,
449+ "updateComments": {"_total": 0},
450+ "updateContent": {"person": {"firstName": "private",
451+ "id": "private",
452+ "lastName": "private"}},
453+ "updateKey": "UNIU-106764-5761479649536-SHARE",
454+ "updateType": "STAT"},
455+ {"isCommentable": true,
456+ "isLikable": true,
457+ "isLiked": false,
458+ "likes": {"_total": 2,
459+ "values": [{"person": {"firstName": "João",
460+ "headline": "System Engineer",
461+ "id": "DXmxRB",
462+ "lastName": "M",
463+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_Vu55rTWdTtWwT-xSwZiP4alVriEVYAwgNpq1vWY_xR7bU0kZ8mjE9"}},
464+ {"person": {"firstName": "private",
465+ "id": "private",
466+ "lastName": "private"}}]},
467+ "numLikes": 2,
468+ "timestamp": 1373638247645,
469+ "updateComments": {"_total": 1,
470+ "values": [{"comment": "Forgot to mention: Floor Drees wrote the article and was the workshop coach",
471+ "id": 155125,
472+ "person": {"firstName": "private",
473+ "id": "private",
474+ "lastName": "private"},
475+ "sequenceNumber": 0,
476+ "timestamp": 1373638357000}]},
477+ "updateContent": {"person": {"firstName": "private",
478+ "id": "private",
479+ "lastName": "private"}},
480+ "updateKey": "UNIU-10664-57614646232064-SHARE",
481+ "updateType": "STAT"},
482+ {"isCommentable": true,
483+ "isLikable": true,
484+ "isLiked": false,
485+ "likes": {"_total": 2,
486+ "values": [{"person": {"firstName": "Tomáš",
487+ "headline": "Invisible software writer, amateur storyteller and wannabe clown.",
488+ "id": "37f-Kc",
489+ "lastName": "J",
490+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_C9Sz75iM9tWx_54nX12tErnWLYY3-joGjcY6V85pwujCGB7"}},
491+ {"person": {"firstName": "Miguel",
492+ "headline": "Senior Consultant at Red Hat",
493+ "id": "RsdH",
494+ "lastName": "P",
495+ "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_9-9rsGcir_ciT05XHD8i2Asq4AM_oi4k1ly6SD"}}]},
496+ "numLikes": 2,
497+ "timestamp": 1373530350025,
498+ "updateComments": {"_total": 1,
499+ "values": [{"comment": "Interesting read.....",
500+ "id": 1369,
501+ "person": {"firstName": "private",
502+ "id": "private",
503+ "lastName": "private"},
504+ "sequenceNumber": 0,
505+ "timestamp": 1373533027000}]},
506+ "updateContent": {"person": {"firstName": "private",
507+ "id": "private",
508+ "lastName": "private"}},
509+ "updateKey": "UNIU-1064-51227071488-SHARE",
510+ "updateType": "STAT"},
511+ {"isCommentable": true,
512+ "isLikable": true,
513+ "isLiked": false,
514+ "numLikes": 0,
515+ "timestamp": 1373465844294,
516+ "updateComments": {"_total": 0},
517+ "updateContent": {"person": {"firstName": "private",
518+ "id": "private",
519+ "lastName": "private"}},
520+ "updateKey": "UNIU-10664-33284581519360-SHARE",
521+ "updateType": "STAT"},
522+ {"isCommentable": true,
523+ "isLikable": true,
524+ "isLiked": false,
525+ "numLikes": 0,
526+ "timestamp": 1373358138541,
527+ "updateComments": {"_total": 0},
528+ "updateContent": {"person": {"firstName": "private",
529+ "id": "private",
530+ "lastName": "private"}},
531+ "updateKey": "UNIU-106088-3910880256-SHARE",
532+ "updateType": "STAT"},
533+ {"isCommentable": true,
534+ "isLikable": true,
535+ "isLiked": false,
536+ "numLikes": 0,
537+ "timestamp": 1373354287104,
538+ "updateComments": {"_total": 0},
539+ "updateContent": {"person": {"firstName": "private",
540+ "id": "private",
541+ "lastName": "private"}},
542+ "updateKey": "UNIU-10607-13277696-SHARE",
543+ "updateType": "STAT"},
544+ {"isCommentable": true,
545+ "isLikable": true,
546+ "isLiked": false,
547+ "numLikes": 0,
548+ "timestamp": 1373273818071,
549+ "updateComments": {"_total": 0},
550+ "updateContent": {"person": {"firstName": "private",
551+ "id": "private",
552+ "lastName": "private"}},
553+ "updateKey": "UNIU-1060884-868226281472-SHARE",
554+ "updateType": "STAT"}]}
555
556=== modified file 'friends/tests/test_facebook.py'
557--- friends/tests/test_facebook.py 2013-06-18 22:00:44 +0000
558+++ friends/tests/test_facebook.py 2013-07-18 03:36:30 +0000
559@@ -531,8 +531,9 @@
560 'username': 'lucy.baron5',
561 'link': 'http:www.facebook.com/lucy.baron5'}
562 eds_contact = self.protocol._create_contact(bare_contact)
563+ self.protocol._address_book = 'test-address-book'
564 # Implicitely fail test if the following raises any exceptions
565- self.protocol._push_to_eds('test-address-book', eds_contact)
566+ self.protocol._push_to_eds(eds_contact)
567
568 @mock.patch('friends.utils.base.Base._get_eds_source',
569 return_value=None)
570@@ -544,10 +545,10 @@
571 'username': 'lucy.baron5',
572 'link': 'http:www.facebook.com/lucy.baron5'}
573 eds_contact = self.protocol._create_contact(bare_contact)
574+ self.protocol._address_book = 'test-address-book'
575 self.assertRaises(
576 ContactsError,
577 self.protocol._push_to_eds,
578- 'test-address-book',
579 eds_contact,
580 )
581
582@@ -557,7 +558,7 @@
583 regmock = self.protocol._source_registry = mock.Mock()
584 regmock.ref_source = lambda x: x
585
586- result = self.protocol._create_eds_source('facebook-test-address')
587+ result = self.protocol._create_eds_source()
588 self.assertEqual(result, 'test-source-uid')
589
590 @mock.patch('gi.repository.EBook.BookClient.new',
591@@ -577,13 +578,6 @@
592 reg_mock = self.protocol._source_registry = mock.Mock()
593 reg_mock.list_sources.return_value = [FakeSource()]
594 reg_mock.ref_source = lambda x: x
595- result = self.protocol._get_eds_source('test-facebook-contacts')
596+ self.protocol._address_book = 'test-facebook-contacts'
597+ result = self.protocol._get_eds_source()
598 self.assertEqual(result, 1345245)
599-
600- @mock.patch('friends.utils.base.Base._get_eds_source_registry',
601- mock.Mock())
602- @mock.patch('friends.utils.base.Base._source_registry',
603- mock.Mock(**{'list_sources.return_value': []}))
604- def test_unsuccessful_get_eds_source(self, *mocks):
605- result = self.protocol._get_eds_source('test-incorrect-contacts')
606- self.assertIsNone(result)
607
608=== added file 'friends/tests/test_linkedin.py'
609--- friends/tests/test_linkedin.py 1970-01-01 00:00:00 +0000
610+++ friends/tests/test_linkedin.py 2013-07-18 03:36:30 +0000
611@@ -0,0 +1,182 @@
612+# friends-dispatcher -- send & receive messages from any social network
613+# Copyright (C) 2012 Canonical Ltd
614+#
615+# This program is free software: you can redistribute it and/or modify
616+# it under the terms of the GNU General Public License as published by
617+# the Free Software Foundation, version 3 of the License.
618+#
619+# This program is distributed in the hope that it will be useful,
620+# but WITHOUT ANY WARRANTY; without even the implied warranty of
621+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
622+# GNU General Public License for more details.
623+#
624+# You should have received a copy of the GNU General Public License
625+# along with this program. If not, see <http://www.gnu.org/licenses/>.
626+
627+"""Test the LinkedIn plugin."""
628+
629+
630+__all__ = [
631+ 'TestLinkedIn',
632+ ]
633+
634+
635+import unittest
636+
637+from friends.protocols.linkedin import LinkedIn
638+from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
639+from friends.tests.mocks import TestModel, mock
640+from friends.errors import AuthorizationError
641+
642+
643+@mock.patch('friends.utils.http._soup', mock.Mock())
644+@mock.patch('friends.utils.base.notify', mock.Mock())
645+class TestLinkedIn(unittest.TestCase):
646+ """Test the LinkedIn API."""
647+
648+ def setUp(self):
649+ TestModel.clear()
650+ self.account = FakeAccount()
651+ self.protocol = LinkedIn(self.account)
652+ self.log_mock = LogMock('friends.utils.base',
653+ 'friends.protocols.linkedin')
654+
655+ def tearDown(self):
656+ # Ensure that any log entries we haven't tested just get consumed so
657+ # as to isolate out test logger from other tests.
658+ self.log_mock.stop()
659+
660+ @mock.patch('friends.utils.authentication.manager')
661+ @mock.patch('friends.utils.authentication.Accounts')
662+ @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
663+ @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
664+ @mock.patch('friends.protocols.linkedin.Downloader.get_json',
665+ return_value=None)
666+ def test_unsuccessful_authentication(self, dload, login, *mocks):
667+ self.assertRaises(AuthorizationError, self.protocol._login)
668+ self.assertIsNone(self.account.user_name)
669+ self.assertIsNone(self.account.user_id)
670+
671+ @mock.patch('friends.utils.authentication.manager')
672+ @mock.patch('friends.utils.authentication.Accounts')
673+ @mock.patch('friends.utils.authentication.Authentication.__init__',
674+ return_value=None)
675+ @mock.patch('friends.utils.authentication.Authentication.login',
676+ return_value=dict(AccessToken='some clever fake data'))
677+ @mock.patch('friends.protocols.linkedin.Downloader.get_json',
678+ return_value=dict(id='blerch', firstName='Bob', lastName='Loblaw'))
679+ def test_successful_authentication(self, *mocks):
680+ self.assertTrue(self.protocol._login())
681+ self.assertEqual(self.account.user_name, 'Bob Loblaw')
682+ self.assertEqual(self.account.user_id, 'blerch')
683+ self.assertEqual(self.account.access_token, 'some clever fake data')
684+
685+ @mock.patch('friends.utils.base.Model', TestModel)
686+ @mock.patch('friends.utils.http.Soup.Message',
687+ FakeSoupMessage('friends.tests.data', 'linkedin_receive.json'))
688+ @mock.patch('friends.protocols.linkedin.LinkedIn._login',
689+ return_value=True)
690+ @mock.patch('friends.utils.base._seen_ids', {})
691+ def test_home(self, *mocks):
692+ self.account.access_token = 'access'
693+ self.assertEqual(0, TestModel.get_n_rows())
694+ self.assertEqual(self.protocol.home(), 1)
695+ self.assertEqual(1, TestModel.get_n_rows())
696+ self.maxDiff = None
697+
698+ self.assertEqual(
699+ list(TestModel.get_row(0)),
700+ ['linkedin', 88, 'UNIU-73705-576270369559388-SHARE', 'messages',
701+ 'Hobson L', 'ma0LLid', '', False, '2013-07-16T00:47:06Z',
702+ 'I\'m looking forward to the Udacity Global meetup event here in '
703+ 'Portland: <a href="http://lnkd.in/dh5MQz">http://lnkd.in/dh5MQz'
704+ '</a>\nGreat way to support the next big thing in c…',
705+ 'http://m.c.lnkd.licdn.com/mpr/mprx/0_mVxsC0BnN52aqc24yWvoyA5haqc2Z'
706+ 'wLCgzLv0EiBGp7n2jTwX-ls_dzgkSVIZu0',
707+ 'https://www.linkedin.com/profile/view?id=7375&authType=name'
708+ '&authToken=-LNy&trk=api*a26127*s26893*',
709+ 1, False, '', '', '', '', '', '', '', 0.0, 0.0])
710+
711+ @mock.patch('friends.utils.base.Base._create_contact')
712+ def test_create_contact(self, base_mock):
713+ self.protocol._create_contact(
714+ dict(id='jb89', firstName='Joe', lastName='Blow'))
715+ base_mock.assert_called_once_with(
716+ 'Joe Blow', None,
717+ {'X-URIS': '', 'linkedin-id': 'jb89', 'linkedin-name': 'Joe Blow'})
718+
719+ @mock.patch('friends.utils.http.Soup.Message',
720+ FakeSoupMessage('friends.tests.data', 'linkedin_contacts.json'))
721+ @mock.patch('friends.protocols.linkedin.LinkedIn._login',
722+ return_value=True)
723+ def test_contacts(self, *mocks):
724+ push = self.protocol._push_to_eds = mock.Mock()
725+ prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False)
726+ token = self.protocol._get_access_token = mock.Mock(return_value='foo')
727+ self.protocol._create_contact = lambda arg:arg
728+ self.assertEqual(self.protocol.contacts(), 4)
729+ self.assertEqual(
730+ push.mock_calls,
731+ [mock.call(
732+ {'siteStandardProfileRequest':
733+ {'url': 'https://www.linkedin.com'},
734+ 'pictureUrl': 'http://m.c.lnkd.licdn.com',
735+ 'apiStandardProfileRequest':
736+ {'url': 'http://api.linkedin.com',
737+ 'headers': {'_total': 1, 'values':
738+ [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
739+ 'industry': 'Computer Network Security',
740+ 'lastName': 'A',
741+ 'firstName': 'H',
742+ 'headline': 'Unix Administrator at NVIDIA',
743+ 'location': {'name': 'Pune Area, India',
744+ 'country': {'code': 'in'}},
745+ 'id': 'IFDI'}),
746+
747+ mock.call(
748+ {'siteStandardProfileRequest':
749+ {'url': 'https://www.linkedin.com'},
750+ 'pictureUrl': 'http://m.c.lnkd.licdn.com',
751+ 'apiStandardProfileRequest':
752+ {'url': 'http://api.linkedin.com',
753+ 'headers': {'_total': 1, 'values':
754+ [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
755+ 'industry': 'Food Production',
756+ 'lastName': 'A',
757+ 'firstName': 'C',
758+ 'headline': 'Recent Graduate, Simon Fraser University',
759+ 'location': {'name': 'Vancouver, Canada Area',
760+ 'country': {'code': 'ca'}},
761+ 'id': 'AefF'}),
762+
763+ mock.call(
764+ {'siteStandardProfileRequest':
765+ {'url': 'https://www.linkedin.com'},
766+ 'pictureUrl': 'http://m.c.lnkd.licdn.com',
767+ 'apiStandardProfileRequest':
768+ {'url': 'http://api.linkedin.com',
769+ 'headers': {'_total': 1, 'values':
770+ [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
771+ 'industry': 'Computer Software',
772+ 'lastName': 'A',
773+ 'firstName': 'R',
774+ 'headline': 'Technical Lead at Canonical Ltd.',
775+ 'location': {'name': 'Auckland, New Zealand',
776+ 'country': {'code': 'nz'}},
777+ 'id': 'DFdV'}),
778+
779+ mock.call(
780+ {'siteStandardProfileRequest':
781+ {'url': 'https://www.linkedin.com'},
782+ 'pictureUrl': 'http://m.c.lnkd.licdn.com',
783+ 'apiStandardProfileRequest':
784+ {'url': 'http://api.linkedin.com',
785+ 'headers': {'_total': 1, 'values':
786+ [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
787+ 'industry': 'Photography',
788+ 'lastName': 'Z',
789+ 'firstName': 'A',
790+ 'headline': 'Sales manager at McBain Camera',
791+ 'location': {'name': 'Edmonton, Canada Area',
792+ 'country': {'code': 'ca'}},
793+ 'id': 'xkBU'})])
794
795=== modified file 'friends/utils/base.py'
796--- friends/utils/base.py 2013-06-21 20:17:07 +0000
797+++ friends/utils/base.py 2013-07-18 03:36:30 +0000
798@@ -201,6 +201,7 @@
799 self._account = account
800 self._Name = self.__class__.__name__
801 self._name = self._Name.lower()
802+ self._address_book = 'friends-{}-contacts'.format(self._name)
803
804 def _whoami(self, result):
805 """Use OAuth login results to identify the authenticating user.
806@@ -347,7 +348,7 @@
807 account_id=self._account.id
808 )
809 )
810-# linkify the message
811+ # linkify the message
812 orig_message = kwargs.get('message', '')
813 kwargs['message'] = linkify_string(orig_message)
814 args = []
815@@ -544,12 +545,12 @@
816 client.open_sync(False, None)
817 return client
818
819- def _push_to_eds(self, online_service, contact):
820- source_match = self._get_eds_source(online_service)
821+ def _push_to_eds(self, contact):
822+ source_match = self._get_eds_source()
823 if source_match is None:
824 raise ContactsError(
825 '{} does not have an address book.'.format(
826- online_service))
827+ self._address_book))
828 client = self._new_book_client(source_match)
829 success = client.add_contact_sync(contact, None)
830 if not success:
831@@ -559,10 +560,10 @@
832 if self._source_registry is None:
833 self._source_registry = EDataServer.SourceRegistry.new_sync(None)
834
835- def _create_eds_source(self, online_service):
836+ def _create_eds_source(self):
837 self._get_eds_source_registry()
838 source = EDataServer.Source.new(None, None)
839- source.set_display_name(online_service)
840+ source.set_display_name(self._address_book)
841 source.set_parent('local-stub')
842 extension = source.get_extension(
843 EDataServer.SOURCE_EXTENSION_ADDRESS_BOOK)
844@@ -576,12 +577,14 @@
845 time.sleep(2)
846 return self._source_registry.ref_source(source.get_uid())
847
848- def _get_eds_source(self, online_service):
849+ def _get_eds_source(self):
850 self._get_eds_source_registry()
851 for previous_source in self._source_registry.list_sources(None):
852- if previous_source.get_display_name() == online_service:
853+ if previous_source.get_display_name() == self._address_book:
854 return self._source_registry.ref_source(
855 previous_source.get_uid())
856+ # if we got this far, then the source doesn't exist; create it
857+ return self._create_eds_source()
858
859 def _previously_stored_contact(self, source, field, search_term):
860 client = self._new_book_client(source)
861@@ -612,15 +615,16 @@
862 vcard = EBookContacts.VCard.new()
863 info = social_network_attrs
864
865- for i in info:
866- attr = EBookContacts.VCardAttribute.new('social-networking-attributes', i)
867- if type(info[i]) == type(dict()):
868- for j in info[i]:
869- param = EBookContacts.VCardAttributeParam.new(j)
870- param.add_value(info[i][j])
871+ for key, val in info.items():
872+ attr = EBookContacts.VCardAttribute.new(
873+ 'social-networking-attributes', key)
874+ if isinstance(val, dict):
875+ for subkey, subval in val.items():
876+ param = EBookContacts.VCardAttributeParam.new(subkey)
877+ param.add_value(subval)
878 attr.add_param(param);
879 else:
880- attr.add_value(info[i])
881+ attr.add_value(val)
882 vcard.add_attribute(attr)
883
884 contact = EBookContacts.Contact.new_from_vcard(

Subscribers

People subscribed via source and target branches