Merge lp:~cjcurran/friends/facebook-contacts into lp:friends

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
Reviewer Review Type Date Requested Status
Super Friends Pending
Review via email: mp+131006@code.launchpad.net

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 :

On 12-10-23 08:52 AM, Conor Curran wrote:
> === 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-distutils-extra
> * python3-dbus
> + * gir1.2-ebook-1.2
> + * gir1.2-edataserver-1.2
> + * python (3.2)
> + * python-distutils-extra
> + * python-dbus (0.80.2)
> + * python-virtualenv
> + * python3-mock

Why did you add deps on python2 stuff? We already have python3 and
python3-distutils-extra and python3-dbus listed immediately above what
you've added here.

> + # Fetch the full contact info from facebook
> + # Not being used now - all we need for now is the id and name
> + def fetch_contact(self, contact_details):
> + access_token = self._get_access_token()
> + url = BASE_URL + '/' + details['id']
> + params = dict(access_token=access_token)
> + 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'
> --- test-eds-book-searching.py 1970-01-01 00:00:00 +0000
> +++ test-eds-book-searching.py 2012-10-23 13:51:19 +0000
> === added file 'test-eds-contact-creation.py'
> --- test-eds-contact-creation.py 1970-01-01 00:00:00 +0000
> +++ test-eds-contact-creation.py 2012-10-23 13:51:19 +0000
> === added file 'test-eds-create-book.py'
> --- test-eds-create-book.py 1970-01-01 00:00:00 +0000
> +++ test-eds-create-book.py 2012-10-23 13:51:19 +0000

We don't need these extra test files anymore, right? Did you write
enough unittests to make these redundant?

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."""

Subscribers

People subscribed via source and target branches

to all changes: