Merge lp:~cjcurran/friends/facebook-contacts into lp:friends
- facebook-contacts
- Merge into trunk
Proposed by
Conor Curran
Status: | Merged |
---|---|
Merged at revision: | 25 |
Proposed branch: | lp:~cjcurran/friends/facebook-contacts |
Merge into: | lp:friends |
Diff against target: |
440 lines (+293/-11) (has conflicts) 6 files modified
README.rst (+3/-0) friends/protocols/facebook.py (+71/-6) friends/testing/mocks.py (+70/-0) friends/tests/data/facebook-contacts.dat (+36/-0) friends/tests/test_facebook.py (+62/-5) friends/utils/base.py (+51/-0) Text conflict in friends/utils/base.py |
To merge this branch: | bzr merge lp:~cjcurran/friends/facebook-contacts |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Super Friends | Pending | ||
Review via email: mp+131006@code.launchpad.net |
Commit message
Description of the change
initial facebook contacts work plus tests
To post a comment you must log in.
Revision history for this message
Robert Bruce Park (robru) wrote : | # |
Revision history for this message
Conor Curran (cjcurran) wrote : | # |
re deps not sure what happened there. I had a conflict on that file a while ago when I rebased maybe it happened then. Definitely don't remember adding python2 deps.
Yup, will remove that method and will delete those files.
- 26. By Conor Curran
-
applying merge comments
- 27. By Conor Curran
-
bug fixes...
- 28. By Conor Curran
-
add logging to make contacts and the search method
- 29. By Conor Curran
-
make base search method parameters generic
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'README.rst' |
2 | --- README.rst 2012-10-15 18:56:26 +0000 |
3 | +++ README.rst 2012-10-24 13:59:19 +0000 |
4 | @@ -17,6 +17,9 @@ |
5 | * python3 (>= 3.2, although 3.3 will soon be required) |
6 | * python3-distutils-extra |
7 | * python3-dbus |
8 | + * gir1.2-ebook-1.2 |
9 | + * gir1.2-edataserver-1.2 |
10 | + * python3-mock |
11 | |
12 | |
13 | Installation |
14 | |
15 | === modified file 'friends/protocols/facebook.py' |
16 | --- friends/protocols/facebook.py 2012-10-19 18:32:18 +0000 |
17 | +++ friends/protocols/facebook.py 2012-10-24 13:59:19 +0000 |
18 | @@ -19,7 +19,6 @@ |
19 | 'Facebook', |
20 | ] |
21 | |
22 | - |
23 | import logging |
24 | |
25 | from datetime import datetime, timedelta |
26 | @@ -27,7 +26,7 @@ |
27 | from friends.utils.base import Base, feature |
28 | from friends.utils.download import get_json |
29 | from friends.utils.time import parsetime, iso8601utc |
30 | - |
31 | +from gi.repository import EBook |
32 | |
33 | # 'id' can be the id of *any* Facebook object |
34 | # https://developers.facebook.com/docs/reference/api/ |
35 | @@ -35,11 +34,10 @@ |
36 | PERMALINK = URL_BASE.format(subdomain='www') + '{id}' |
37 | API_BASE = URL_BASE.format(subdomain='graph') + '{id}' |
38 | ME_URL = API_BASE.format(id='me') |
39 | - |
40 | - |
41 | +LIMIT = 100 |
42 | +FACEBOOK_ADDRESS_BOOK = "friends-facebook-contacts" |
43 | log = logging.getLogger(__name__) |
44 | |
45 | - |
46 | class Facebook(Base): |
47 | def _whoami(self, authdata): |
48 | """Identify the authenticating user.""" |
49 | @@ -149,7 +147,7 @@ |
50 | # https://developers.facebook.com/docs/reference/api/post/ |
51 | for entry in entries: |
52 | self._publish_entry(entry) |
53 | - |
54 | + |
55 | def _like(self, obj_id, method): |
56 | url = API_BASE.format(id=obj_id) + '/likes' |
57 | token = self._get_access_token() |
58 | @@ -220,3 +218,70 @@ |
59 | log.error('Failed to delete {} on Facebook'.format(obj_id)) |
60 | else: |
61 | self._unpublish(obj_id) |
62 | + |
63 | + def fetch_contacts(self): |
64 | + """ Retrieve a list of Facebook friends |
65 | + A maximum of 100 friends are requested. |
66 | + """ |
67 | + access_token = self._get_access_token() |
68 | + contacts = [] |
69 | + url = ME_URL + '/friends' |
70 | + params = dict(access_token=access_token, |
71 | + limit=LIMIT) |
72 | + # Now access Facebook and follow pagination until we have at least |
73 | + # LIMIT number of entries, or we've reached the end of pages. |
74 | + while True: |
75 | + response = get_json(url, params) |
76 | + if self._is_error(response): |
77 | + # We'll just use what we have so far, if anything. |
78 | + break |
79 | + data = response.get('data') |
80 | + if data is None: |
81 | + # I guess we're done. |
82 | + break |
83 | + contacts.extend(data) |
84 | + if len(contacts) >= LIMIT: |
85 | + break |
86 | + # We haven't gotten the requested number of entries. Follow the |
87 | + # next page if there is one to try to get more. |
88 | + pages = response.get('paging') |
89 | + if pages is None: |
90 | + break |
91 | + # The 'next' key has the full link to follow; no additional |
92 | + # parameters are needed. Specifically, this link will already |
93 | + # include the access_token, and any since/limit values. |
94 | + url = pages.get('next') |
95 | + params = None |
96 | + if url is None: |
97 | + # I guess there are no more next pages. |
98 | + break |
99 | + return contacts |
100 | + |
101 | + # This method can take the minimal contact information or full contact info |
102 | + # For now we only cache ID and the name in the addressbook. |
103 | + # Using custom field for name because I can't figure out how econtact name works. |
104 | + def create_contact(self, contact_json): |
105 | + vcafid = EBook.VCardAttribute.new("social-networking-attributes", "facebook-id") |
106 | + vcafid.add_value(contact_json["id"]) |
107 | + vcafn = EBook.VCardAttribute.new("social-networking-attributes", "facebook-name") |
108 | + vcafn.add_value(contact_json["name"]) |
109 | + vcard = EBook.VCard.new() |
110 | + vcard.add_attribute(vcafid) |
111 | + vcard.add_attribute(vcafn) |
112 | + c = EBook.Contact.new_from_vcard(vcard.to_string(EBook.VCardFormat(1))) |
113 | + log.debug("Creating new contact for {}".format(contact_json['name'])) |
114 | + return c |
115 | + |
116 | + def contacts(self): |
117 | + contacts = self.fetch_contacts() |
118 | + source = self._get_eds_source(FACEBOOK_ADDRESS_BOOK) |
119 | + for contact in contacts: |
120 | + if source != None and Base.previously_stored_contact(source, "facebook-id", contact['id']) == True: |
121 | + continue |
122 | + #Let's not query the full contact info for now |
123 | + #Show some respect for facebook and the chances are we won't be blocked. |
124 | + eds_contact = self.create_contact(contact) |
125 | + if(self._push_to_eds(FACEBOOK_ADDRESS_BOOK, eds_contact) == False): |
126 | + log.error("Warning: Unable to save facebook contact {}".format(contact["name"])) |
127 | + |
128 | + |
129 | |
130 | === modified file 'friends/testing/mocks.py' |
131 | --- friends/testing/mocks.py 2012-10-19 19:27:21 +0000 |
132 | +++ friends/testing/mocks.py 2012-10-24 13:59:19 +0000 |
133 | @@ -247,3 +247,73 @@ |
134 | def __exit__(self, *exception_info): |
135 | self.stop() |
136 | return False |
137 | + |
138 | +class EDSBookClientMock: |
139 | + """A Mocker object to simulate use of BookClient |
140 | + """ |
141 | + def __init__(self): |
142 | + pass |
143 | + |
144 | + def open_sync(val1, val2, val3): |
145 | + pass |
146 | + |
147 | + def add_contact_sync(val1, contact, cancellable): |
148 | + return True |
149 | + |
150 | + def get_contacts_sync(val1, val2, val3): |
151 | + if val1: |
152 | + return [True, [{'name':'john doe', 'id': 11111}]] |
153 | + else: |
154 | + return [True, []] |
155 | + |
156 | +class EDSExtension: |
157 | + """A Extension mocker object for testing create source""" |
158 | + def __init__(self): |
159 | + pass |
160 | + |
161 | + def set_backend_name(self, name): |
162 | + pass |
163 | + |
164 | +class EDSSource: |
165 | + """A Mocker object to simulate use of a Source object to create address books in EDS |
166 | + """ |
167 | + def __init__(self, val1, val2): |
168 | + pass |
169 | + |
170 | + def set_display_name(self,name): |
171 | + self.name = name |
172 | + |
173 | + def get_display_name(self): |
174 | + return self.name |
175 | + |
176 | + def set_parent(self,parent): |
177 | + pass |
178 | + |
179 | + def get_uid(self): |
180 | + return "test-source-uid" |
181 | + |
182 | + def get_extension(self,extension_name): |
183 | + return EDSExtension() |
184 | + |
185 | +class EDSRegistry: |
186 | + """A Mocker object for the registry""" |
187 | + def __init__(self): |
188 | + pass |
189 | + |
190 | + def commit_source_sync(self, source, val1): |
191 | + return True |
192 | + |
193 | + def list_sources(self, category): |
194 | + res = [] |
195 | + s1 = EDSSource(None, None) |
196 | + s1.set_display_name("test-facebook-contacts") |
197 | + res.append(s1) |
198 | + s2 = EDSSource(None, None) |
199 | + s2.set_display_name("test-twitter-contacts") |
200 | + res.append(s2) |
201 | + return res |
202 | + |
203 | + def ref_source(self, src_uid): |
204 | + s1 = EDSSource(None, None) |
205 | + s1.set_display_name("test-facebook-contacts") |
206 | + return s1 |
207 | \ No newline at end of file |
208 | |
209 | === added file 'friends/tests/data/facebook-contacts.dat' |
210 | --- friends/tests/data/facebook-contacts.dat 1970-01-01 00:00:00 +0000 |
211 | +++ friends/tests/data/facebook-contacts.dat 2012-10-24 13:59:19 +0000 |
212 | @@ -0,0 +1,36 @@ |
213 | +{ |
214 | + "data": [ |
215 | + { |
216 | + "name": "Jane Doe", |
217 | + "id": "123456" |
218 | + }, |
219 | + { |
220 | + "name": "John Doe", |
221 | + "id": "654321" |
222 | + }, |
223 | + { |
224 | + "name": "Jim Doe", |
225 | + "id": "987654" |
226 | + }, |
227 | + { |
228 | + "name": "Joe Doe", |
229 | + "id": "876543" |
230 | + }, |
231 | + { |
232 | + "name": "Tim Tom", |
233 | + "id": "765432" |
234 | + }, |
235 | + { |
236 | + "name": "Tom Dunne", |
237 | + "id": "111111" |
238 | + }, |
239 | + { |
240 | + "name": "Pete Wilson", |
241 | + "id": "222222" |
242 | + }, |
243 | + { |
244 | + "name": "John Smith", |
245 | + "id": "444444" |
246 | + } |
247 | + ] |
248 | +} |
249 | |
250 | === modified file 'friends/tests/test_facebook.py' |
251 | --- friends/tests/test_facebook.py 2012-10-19 01:35:36 +0000 |
252 | +++ friends/tests/test_facebook.py 2012-10-24 13:59:19 +0000 |
253 | @@ -19,31 +19,28 @@ |
254 | 'TestFacebook', |
255 | ] |
256 | |
257 | - |
258 | import unittest |
259 | |
260 | from gi.repository import Dee |
261 | +from gi.repository import EBook, EDataServer, Gio, GLib |
262 | |
263 | from friends.protocols.facebook import Facebook |
264 | from friends.testing.helpers import FakeAccount |
265 | -from friends.testing.mocks import FakeSoupMessage, LogMock |
266 | +from friends.testing.mocks import FakeSoupMessage, LogMock, EDSBookClientMock, EDSSource, EDSRegistry |
267 | from friends.utils.base import Base |
268 | from friends.utils.model import COLUMN_TYPES |
269 | |
270 | - |
271 | try: |
272 | # Python 3.3 |
273 | from unittest import mock |
274 | except ImportError: |
275 | import mock |
276 | |
277 | - |
278 | # Create a test model that will not interfere with the user's environment. |
279 | # We'll use this object as a mock of the real model. |
280 | TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel') |
281 | TestModel.set_schema_full(COLUMN_TYPES) |
282 | |
283 | - |
284 | @mock.patch('friends.utils.download._soup', mock.Mock()) |
285 | class TestFacebook(unittest.TestCase): |
286 | """Test the Facebook API.""" |
287 | @@ -51,6 +48,7 @@ |
288 | def setUp(self): |
289 | self.account = FakeAccount() |
290 | self.protocol = Facebook(self.account) |
291 | + self.protocol.source_registry = EDSRegistry() |
292 | # Enable sub-thread synchronization, and mock out the loggers. |
293 | Base._SYNCHRONIZE = True |
294 | |
295 | @@ -314,3 +312,62 @@ |
296 | method='DELETE', |
297 | params=dict(access_token='face')) |
298 | unpublish.assert_called_once_with('post_id') |
299 | + |
300 | + |
301 | + |
302 | + @mock.patch('friends.utils.download.Soup.Message', |
303 | + FakeSoupMessage('friends.tests.data', 'facebook-contacts.dat')) |
304 | + @mock.patch('friends.protocols.facebook.Facebook._login', |
305 | + return_value=True) |
306 | + def test_fetch_contacts(self, *mocks): |
307 | + # Receive the users friends. |
308 | + results = self.protocol.fetch_contacts() |
309 | + self.assertEqual(len(results), 8) |
310 | + self.assertEqual(results[7]["name"], "John Smith") |
311 | + self.assertEqual(results[7]["id"], "444444") |
312 | + |
313 | + def test_create_contact(self, *mocks): |
314 | + # Receive the users friends. |
315 | + bare_contact = {"name": "Lucy Baron", "id": "555555555"} |
316 | + eds_contact = self.protocol.create_contact(bare_contact) |
317 | + facebook_id_attr = eds_contact.get_attribute("facebook-id") |
318 | + self.assertEqual(facebook_id_attr.get_value(), "555555555") |
319 | + facebook_name_attr = eds_contact.get_attribute("facebook-name") |
320 | + self.assertEqual(facebook_name_attr.get_value(), "Lucy Baron") |
321 | + |
322 | + @mock.patch('friends.utils.base.Base._get_eds_source', |
323 | + return_value=True) |
324 | + @mock.patch('gi.repository.EBook.BookClient.new', return_value=EDSBookClientMock()) |
325 | + def test_successfull_push_to_eds(self, *mocks): |
326 | + bare_contact = {"name": "Lucy Baron", "id": "555555555"} |
327 | + eds_contact = self.protocol.create_contact(bare_contact) |
328 | + result = self.protocol._push_to_eds("test-address-book", eds_contact) |
329 | + self.assertEqual(result, True) |
330 | + |
331 | + @mock.patch('friends.utils.base.Base._get_eds_source', |
332 | + return_value=None) |
333 | + @mock.patch('friends.utils.base.Base._create_eds_source', |
334 | + return_value=None) |
335 | + def test_unsuccessfull_push_to_eds(self, *mocks): |
336 | + bare_contact = {"name": "Lucy Baron", "id": "555555555"} |
337 | + eds_contact = self.protocol.create_contact(bare_contact) |
338 | + result = self.protocol._push_to_eds("test-address-book", eds_contact) |
339 | + self.assertEqual(result, False) |
340 | + |
341 | + @mock.patch('gi.repository.EDataServer.Source.new', return_value=EDSSource("foo", "bar")) |
342 | + def test_create_eds_source(self, *mocks): |
343 | + res = self.protocol._create_eds_source('facebook-test-address') |
344 | + self.assertEqual(res, "test-source-uid") |
345 | + |
346 | + @mock.patch('gi.repository.EBook.BookClient.new', return_value=EDSBookClientMock()) |
347 | + def test_successful_previously_stored_contact(self, *mocks): |
348 | + result = Facebook.previously_stored_contact(True, "facebook-id", "11111") |
349 | + self.assertEqual(result, True) |
350 | + |
351 | + def test_successful_get_eds_source(self, *mocks): |
352 | + result = self.protocol._get_eds_source("test-facebook-contacts") |
353 | + self.assertEqual(result.get_display_name(), "test-facebook-contacts") |
354 | + |
355 | + def test_unsuccessful_get_eds_source(self, *mocks): |
356 | + result = self.protocol._get_eds_source("test-incorrect-contacts") |
357 | + self.assertEqual(result, None) |
358 | \ No newline at end of file |
359 | |
360 | === modified file 'friends/utils/base.py' |
361 | --- friends/utils/base.py 2012-10-23 21:54:58 +0000 |
362 | +++ friends/utils/base.py 2012-10-24 13:59:19 +0000 |
363 | @@ -27,12 +27,17 @@ |
364 | import string |
365 | import logging |
366 | import threading |
367 | +import time |
368 | |
369 | from datetime import datetime |
370 | |
371 | from friends.utils.authentication import Authentication |
372 | from friends.utils.model import COLUMN_INDICES, SCHEMA, DEFAULTS, Model |
373 | +<<<<<<< TREE |
374 | from friends.utils.time import ISO8601_FORMAT |
375 | +======= |
376 | +from gi.repository import EDataServer, EBook, Gio |
377 | +>>>>>>> MERGE-SOURCE |
378 | |
379 | |
380 | IGNORED = string.punctuation + string.whitespace |
381 | @@ -175,6 +180,7 @@ |
382 | _SYNCHRONIZE = False |
383 | |
384 | def __init__(self, account): |
385 | + self.source_registry = EDataServer.SourceRegistry.new_sync(None) |
386 | self._account = account |
387 | |
388 | def __call__(self, operation, *args, **kwargs): |
389 | @@ -365,6 +371,51 @@ |
390 | self._whoami(result) |
391 | log.debug('{} UID: {}'.format(protocol, self._account.user_id)) |
392 | |
393 | + def _push_to_eds(self, online_service, contact): |
394 | + source_match = self._get_eds_source(online_service) |
395 | + if source_match == None: |
396 | + new_source_uid = self._create_eds_source(online_service) |
397 | + if new_source_uid == None: |
398 | + log.error('Could not create a new source for {}'.format(online_service)) |
399 | + return False |
400 | + else: |
401 | + #Potential race condition - need to sleep for a couple of cycles |
402 | + #to ensure the registry will return a valid source object after commiting |
403 | + #Evolution fix on the way but for now we need to have this in place. |
404 | + #https://bugzilla.gnome.org/show_bug.cgi?id=685986 |
405 | + time.sleep(1) |
406 | + source_match = self.source_registry.ref_source(new_source_uid) |
407 | + client = EBook.BookClient.new(source_match) |
408 | + client.open_sync(False, None) |
409 | + return client.add_contact_sync(contact, Gio.Cancellable()); |
410 | + |
411 | + def _create_eds_source(self, online_service): |
412 | + source = EDataServer.Source.new(None, None) |
413 | + source.set_display_name(online_service) |
414 | + source.set_parent("local-stub") |
415 | + extension = source.get_extension(EDataServer.SOURCE_EXTENSION_ADDRESS_BOOK) |
416 | + extension.set_backend_name("local") |
417 | + if (self.source_registry.commit_source_sync(source, Gio.Cancellable())): |
418 | + return source.get_uid() |
419 | + return None |
420 | + |
421 | + def _get_eds_source(self, online_service): |
422 | + for previous_source in self.source_registry.list_sources(None): |
423 | + if previous_source.get_display_name() == online_service: |
424 | + return self.source_registry.ref_source(previous_source.get_uid()) |
425 | + return None |
426 | + |
427 | + @classmethod |
428 | + def previously_stored_contact(cls, source, field, search_term): |
429 | + client = EBook.BookClient.new(source) |
430 | + client.open_sync(False, None) |
431 | + q = EBook.book_query_vcard_field_test(field, EBook.BookQueryTest(0), search_term) |
432 | + cs = client.get_contacts_sync(q.to_string(), Gio.Cancellable()) |
433 | + log.debug("search string %s", q.to_string()) |
434 | + if cs[0] == False: |
435 | + return False # is this right ... |
436 | + return len(cs[1]) > 0 |
437 | + |
438 | @classmethod |
439 | def get_features(cls): |
440 | """Report what public operations we expose over DBus.""" |
On 12-10-23 08:52 AM, Conor Curran wrote: distutils- extra edataserver- 1.2 distutils- extra
> === modified file 'README.rst'
> --- README.rst 2012-10-15 18:56:26 +0000
> +++ README.rst 2012-10-23 13:51:19 +0000
> @@ -17,6 +17,13 @@
> * python3 (>= 3.2, although 3.3 will soon be required)
> * python3-
> * python3-dbus
> + * gir1.2-ebook-1.2
> + * gir1.2-
> + * python (3.2)
> + * python-
> + * python-dbus (0.80.2)
> + * python-virtualenv
> + * python3-mock
Why did you add deps on python2 stuff? We already have python3 and distutils- extra and python3-dbus listed immediately above what
python3-
you've added here.
> + # Fetch the full contact info from facebook access_ token() token=access_ token)
> + # Not being used now - all we need for now is the id and name
> + def fetch_contact(self, contact_details):
> + access_token = self._get_
> + url = BASE_URL + '/' + details['id']
> + params = dict(access_
> + return get_json(url, params)
If this isn't being used, can you delete it? Or is the comment wrong?
> +class EDSBookClientMock:
> + """A Mocker object to simulate use of BookClient
> + """
Ok, these mocks are looking better.
> === added file 'test-eds- book-searching. py' book-searching. py 1970-01-01 00:00:00 +0000 book-searching. py 2012-10-23 13:51:19 +0000 contact- creation. py' contact- creation. py 1970-01-01 00:00:00 +0000 contact- creation. py 2012-10-23 13:51:19 +0000 create- book.py' create- book.py 1970-01-01 00:00:00 +0000 create- book.py 2012-10-23 13:51:19 +0000
> --- test-eds-
> +++ test-eds-
> === added file 'test-eds-
> --- test-eds-
> +++ test-eds-
> === added file 'test-eds-
> --- test-eds-
> +++ test-eds-
We don't need these extra test files anymore, right? Did you write
enough unittests to make these redundant?