def test_merge(config): """Test the basic logic of the merge() function.""" base = Contact(name='Original Name', email_address='*****@*****.**') remote = Contact(name='New Name', email_address='*****@*****.**') dest = Contact(name='Original Name', email_address='*****@*****.**') merge(base, remote, dest) assert dest.name == 'New Name' assert dest.email_address == '*****@*****.**'
def add_contact(self, account_id, contact_info): """Add a new contact to the specified IMAP account. Returns the ID of the added contact.""" with session_scope() as db_session: contact = Contact(account_id=account_id, source='local', provider_name=INBOX_PROVIDER_NAME, uid=uuid.uuid4()) contact.from_cereal(contact_info) db_session.add(contact) db_session.commit() log.info("Added contact {0}".format(contact.id)) return contact.id
def test_merge_conflict(config): """Test that merge() raises an error on conflict.""" base = Contact(name='Original Name', email_address='*****@*****.**') remote = Contact(name='New Name', email_address='*****@*****.**') dest = Contact(name='Some Other Name', email_address='*****@*****.**') with pytest.raises(MergeError): merge(base, remote, dest) # Check no update in case of conflict assert dest.name == 'Some Other Name' assert dest.email_address == '*****@*****.**'
def get_contact_objects(db_session, account_id, addresses): """Given a list `addresses` of (name, email) pairs, return existing contacts with matching email. Create and also return contact objects for any email without a match.""" if addresses is None: return [] contacts = [] for addr in addresses: if addr is None: continue name, email = addr canonical_email = canonicalize_address(email) existing_contacts = db_session.query(Contact). \ filter(Contact.email_address == canonical_email, Contact.account_id == account_id).all() if not existing_contacts: new_contact = Contact(name=name, email_address=canonical_email, account_id=account_id, source='local', provider_name='inbox', uid=uuid.uuid4().hex) contacts.append(new_contact) db_session.add(new_contact) else: contacts.extend(existing_contacts) return contacts
def supply_contact(self, name, email_address, deleted=False): self._contacts.append( Contact(account_id=ACCOUNT_ID, uid=str(self._next_uid), source='remote', provider_name=self.PROVIDER_NAME, name=name, email_address=email_address, deleted=deleted)) self._next_uid += 1
def create(namespace, db_session, name, email): contact = Contact(account_id=namespace.account_id, source='local', provider_name=INBOX_PROVIDER_NAME, uid=uuid.uuid4().hex, name=name, email_address=email) db_session.add(contact) db_session.commit() return contact
class GoogleContactsProvider(object): """A utility class to fetch and parse Google contact data for the specified account using the Google Contacts API. Parameters ---------- db_session: sqlalchemy.orm.session.Session Database session. account: ..models.tables.ImapAccount The user account for which to fetch contact data. Attributes ---------- google_client: gdata.contacts.client.ContactsClient Google API client to do the actual data fetching. log: logging.Logger Logging handler. """ PROVIDER_NAME = 'google' def __init__(self, account_id): self.account_id = account_id self.log = configure_logging(account_id, 'googlecontacts') def _get_google_client(self): """Return the Google API client.""" # TODO(emfree) figure out a better strategy for refreshing OAuth # credentials as needed with session_scope() as db_session: try: account = db_session.query(ImapAccount).get(self.account_id) account = verify_imap_account(db_session, account) two_legged_oauth_token = gdata.gauth.OAuth2Token( client_id=GOOGLE_OAUTH_CLIENT_ID, client_secret=GOOGLE_OAUTH_CLIENT_SECRET, scope=OAUTH_SCOPE, user_agent=SOURCE_APP_NAME, access_token=account.o_access_token, refresh_token=account.o_refresh_token) google_client = gdata.contacts.client.ContactsClient( source=SOURCE_APP_NAME) google_client.auth_token = two_legged_oauth_token return google_client except gdata.client.BadAuthentication: self.log.error('Invalid user credentials given') return None def _parse_contact_result(self, google_contact): """Constructs a Contact object from a Google contact entry. Parameters ---------- google_contact: gdata.contacts.entry.ContactEntry The Google contact entry to parse. Returns ------- ..models.tables.base.Contact A corresponding Inbox Contact instance. Raises ------ AttributeError If the contact data could not be parsed correctly. """ email_addresses = [ email for email in google_contact.email if email.primary ] if email_addresses and len(email_addresses) > 1: self.log.error( "Should not have more than one email per entry! {0}".format( email_addresses)) try: # The id.text field of a ContactEntry object takes the form # 'http://www.google.com/m8/feeds/contacts/<useremail>/base/<uid>'. # We only want the <uid> part. raw_google_id = google_contact.id.text _, g_id = posixpath.split(raw_google_id) name = (google_contact.name.full_name.text if (google_contact.name and google_contact.name.full_name) else None) email_address = (email_addresses[0].address if email_addresses else None) # The entirety of the raw contact data in XML string # representation. raw_data = google_contact.to_string() except AttributeError, e: self.log.error( 'Something is wrong with contact: {0}'.format(google_contact)) raise e deleted = google_contact.deleted is not None return Contact(account_id=self.account_id, source='remote', uid=g_id, name=name, provider_name=self.PROVIDER_NAME, email_address=email_address, deleted=deleted, raw_data=raw_data)
def poll(account_id, provider): """Query a remote contacts provider for updates and persist them to the database. Parameters ---------- account_id: int ID for the account whose contacts should be queried. db_session: sqlalchemy.orm.session.Session Database session provider: Interface to the remote contact data provider. Must have a PROVIDER_NAME attribute and implement the get_contacts() method. """ provider_name = provider.PROVIDER_NAME with session_scope() as db_session: account = db_session.query(Account).get(account_id) # Contact data reflecting any local modifications since the last sync # with the remote provider. local_contacts = db_session.query(Contact).filter_by( source='local', account=account, provider_name=provider_name).all() # Snapshot of contact data from immediately after last sync. cached_contacts = db_session.query(Contact).filter_by( source='remote', account=account, provider_name=provider_name).all() log.info('Query: have {0} contacts, {1} cached.'.format( len(local_contacts), len(cached_contacts))) cached_contact_dict = {contact.uid: contact for contact in cached_contacts} local_contact_dict = {contact.uid: contact for contact in local_contacts} change_counter = Counter() last_sync = or_none(account.last_synced_contacts, datetime.datetime.isoformat) to_commit = [] for remote_contact in provider.get_contacts(last_sync): remote_contact.account = account assert remote_contact.uid is not None, \ 'Got remote contact with null uid' assert isinstance(remote_contact.uid, str) cached_contact = cached_contact_dict.get(remote_contact.uid) local_contact = local_contact_dict.get(remote_contact.uid) # If the remote contact was deleted, purge the corresponding # database entries. if remote_contact.deleted: if cached_contact is not None: db_session.delete(cached_contact) change_counter['deleted'] += 1 if local_contact is not None: db_session.delete(local_contact) continue # Otherwise, update the database. if cached_contact is not None: # The provider gave an update to a contact we already have. if local_contact is not None: try: # Attempt to merge remote updates into local_contact merge(cached_contact, remote_contact, local_contact) # And update cached_contact to reflect both local and # remote updates cached_contact.copy_from(local_contact) except MergeError: log.error('Conflicting local and remote updates to ' 'contact.\nLocal: {0}\ncached: {1}\n ' 'remote: {2}'.format(local_contact, cached_contact, remote_contact)) # TODO(emfree): Come up with a strategy for handling # merge conflicts. For now, just don't update if there # is a conflict. continue else: log.warning('Contact {0} already present as remote but ' 'not local contact'.format(cached_contact)) cached_contact.copy_from(remote_contact) change_counter['updated'] += 1 else: # This is a new contact, create both local and remote DB # entries. local_contact = Contact() local_contact.copy_from(remote_contact) local_contact.source = 'local' to_commit.append(local_contact) to_commit.append(remote_contact) change_counter['added'] += 1 account.last_synced_contacts = datetime.datetime.now() log.info('Added {0} contacts.'.format(change_counter['added'])) log.info('Updated {0} contacts.'.format(change_counter['updated'])) log.info('Deleted {0} contacts.'.format(change_counter['deleted'])) db_session.add_all(to_commit) db_session.commit()
def poll(account_id, provider): """Query a remote contacts provider for updates and persist them to the database. Parameters ---------- account_id: int ID for the account whose contacts should be queried. db_session: sqlalchemy.orm.session.Session Database session provider: Interface to the remote contact data provider. Must have a PROVIDER_NAME attribute and implement the get_contacts() method. """ provider_name = provider.PROVIDER_NAME with session_scope() as db_session: account = db_session.query(Account).get(account_id) change_counter = Counter() last_sync = or_none(account.last_synced_contacts, datetime.datetime.isoformat) to_commit = [] for remote_contact in provider.get_contacts(last_sync): remote_contact.account = account assert remote_contact.uid is not None, \ 'Got remote contact with null uid' assert isinstance(remote_contact.uid, str) matching_contacts = db_session.query(Contact).filter( Contact.account == account, Contact.provider_name == provider_name, Contact.uid == remote_contact.uid) # Snapshot of contact data from immediately after last sync: cached_contact = matching_contacts. \ filter(Contact.source == 'remote').first() # Contact data reflecting any local modifications since the last # sync with the remote provider: local_contact = matching_contacts. \ filter(Contact.source == 'local').first() # If the remote contact was deleted, purge the corresponding # database entries. if remote_contact.deleted: if cached_contact is not None: db_session.delete(cached_contact) change_counter['deleted'] += 1 if local_contact is not None: db_session.delete(local_contact) continue # Otherwise, update the database. if cached_contact is not None: # The provider gave an update to a contact we already have. if local_contact is not None: try: # Attempt to merge remote updates into local_contact merge(cached_contact, remote_contact, local_contact) # And update cached_contact to reflect both local and # remote updates cached_contact.copy_from(local_contact) except MergeError: log.error('Conflicting local and remote updates to ' 'contact.\nLocal: {0}\ncached: {1}\n ' 'remote: {2}'.format(local_contact, cached_contact, remote_contact)) # TODO(emfree): Come up with a strategy for handling # merge conflicts. For now, just don't update if there # is a conflict. continue else: log.warning('Contact {0} already present as remote but ' 'not local contact'.format(cached_contact)) cached_contact.copy_from(remote_contact) change_counter['updated'] += 1 else: # This is a new contact, create both local and remote DB # entries. local_contact = Contact() local_contact.copy_from(remote_contact) local_contact.source = 'local' to_commit.append(local_contact) to_commit.append(remote_contact) change_counter['added'] += 1 account.last_synced_contacts = datetime.datetime.now() log.info('Added {0} contacts.'.format(change_counter['added'])) log.info('Updated {0} contacts.'.format(change_counter['updated'])) log.info('Deleted {0} contacts.'.format(change_counter['deleted'])) db_session.add_all(to_commit) db_session.commit()