Merge lp:~robru/friends/linkedin-protocol into lp:friends
- linkedin-protocol
- Merge into trunk
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 |
Related bugs: |
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.
- 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( |