diff --git a/.gitignore b/.gitignore index f21e36b69c0b6..8309f03cfcfd1 100644 --- a/.gitignore +++ b/.gitignore @@ -59,5 +59,8 @@ target/ */pid .idea/ + trail.py sample_run.py + + diff --git a/.travis.yml b/.travis.yml index 73cc97cc0de06..90cfbae89d0e9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,9 @@ language: python -#python: -# - "2.7" -# - "3.4" -# - "3.5" -# - "3.6" -#install: -# - pip install -r requirements.txt +python: + - "3.5" + - "3.6" + +install: pip install -r requirements-dev.txt script: pytest - + diff --git a/MANIFEST b/MANIFEST deleted file mode 100644 index 711ba708fadfc..0000000000000 --- a/MANIFEST +++ /dev/null @@ -1,16 +0,0 @@ -# file GENERATED by distutils, do NOT edit -setup.cfg -setup.py -O365/__init__.py -O365/attachment.py -O365/cal.py -O365/connection.py -O365/contact.py -O365/event.py -O365/fluent_inbox.py -O365/fluent_message.py -O365/group.py -O365/handlers.py -O365/inbox.py -O365/message.py -O365/schedule.py diff --git a/O365/__init__.py b/O365/__init__.py index 68c80e100d7a7..244c2e796342b 100644 --- a/O365/__init__.py +++ b/O365/__init__.py @@ -1,33 +1,12 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -''' -Python library for interfacing with the Microsoft Office 365 online. -''' -#__all__ = ['attachment','cal','contact','event','group','inbox','message','schedule'] - -# This imports all the libraries into the local namespace. This makes it easy to work with. - -from .contact import Contact -from .group import Group -from .cal import Calendar -from .event import Event -from .attachment import Attachment -from .inbox import Inbox -from .message import Message -from .schedule import Schedule -from .connection import Connection -from .fluent_inbox import FluentInbox - - -#To the King! +""" +A simple python library to interact with Microsoft Graph and Office 365 API +""" + +from .account import Account +from .connection import Connection, Protocol, MSGraphProtocol, MSOffice365Protocol, oauth_authentication_flow +from .mailbox import MailBox +from .message import Message, MessageAttachment, Recipient +from .address_book import AddressBook, Contact, RecipientType +from .calendar import Schedule, Calendar, Event, EventResponse, AttendeeType, EventSensitivity, EventShowAs, CalendarColors, EventAttachment +from .drive import Storage, Drive, Folder, File, Image, Photo +from .utils import OneDriveWellKnowFolderNames, OutlookWellKnowFolderNames, ImportanceLevel diff --git a/O365/account.py b/O365/account.py new file mode 100644 index 0000000000000..f6f52923edc71 --- /dev/null +++ b/O365/account.py @@ -0,0 +1,96 @@ +from O365.connection import Connection, Protocol, MSGraphProtocol, oauth_authentication_flow +from O365.drive import Storage +from O365.utils import ME_RESOURCE +from O365.message import Message +from O365.mailbox import MailBox +from O365.address_book import AddressBook, GlobalAddressList +from O365.calendar import Schedule + + +class Account(object): + """ Class helper to integrate all components into a single object """ + + def __init__(self, credentials, *, protocol=None, main_resource=ME_RESOURCE, **kwargs): + """ + Account constructor. + :param credentials: a tuple containing the client_id and client_secret + :param protocol: the protocol to be used in this account instance + :param main_resource: the resource to be used by this account + :param kwargs: any extra args to be passed to the Connection instance + """ + + protocol = protocol or MSGraphProtocol # defaults to Graph protocol + self.protocol = protocol(default_resource=main_resource, **kwargs) if isinstance(protocol, type) else protocol + + if not isinstance(self.protocol, Protocol): + raise ValueError("'protocol' must be a subclass of Protocol") + + self.con = Connection(credentials, **kwargs) + self.main_resource = main_resource + + def __repr__(self): + if self.con.auth: + return 'Account Client Id: {}'.format(self.con.auth[0]) + else: + return 'Unidentified Account' + + def authenticate(self, *, scopes, **kwargs): + """ + Performs the oauth authentication flow resulting in a stored token. + It uses the credentials passed on instantiation + :param scopes: a list of protocol user scopes to be converted by the protocol + :param kwargs: other configuration to be passed to the Connection instance + """ + kwargs.setdefault('token_file_name', self.con.token_path.name) + + return oauth_authentication_flow(*self.con.auth, scopes=scopes, protocol=self.protocol, **kwargs) + + @property + def connection(self): + """ Alias for self.con """ + return self.con + + def new_message(self, resource=None): + """ + Creates a new message to be send or stored + :param resource: Custom resource to be used in this message. Defaults to parent main_resource. + """ + return Message(parent=self, main_resource=resource, is_draft=True) + + def mailbox(self, resource=None): + """ + Creates MailBox Folder instance + :param resource: Custom resource to be used in this mailbox. Defaults to parent main_resource. + """ + return MailBox(parent=self, main_resource=resource, name='MailBox') + + def address_book(self, *, resource=None, address_book='personal'): + """ + Creates Address Book instance + :param resource: Custom resource to be used in this address book. Defaults to parent main_resource. + :param address_book: Choose from Personal or Gal (Global Address List) + """ + if address_book == 'personal': + return AddressBook(parent=self, main_resource=resource, name='Personal Address Book') + elif address_book == 'gal': + return GlobalAddressList(parent=self) + else: + raise RuntimeError('Addres_book must be either "personal" (resource address book) or "gal" (Global Address List)') + + def schedule(self, *, resource=None): + """ + Creates Schedule instance to handle calendars + :param resource: Custom resource to be used in this schedule object. Defaults to parent main_resource. + """ + return Schedule(parent=self, main_resource=resource) + + def storage(self, *, resource=None): + """ + Creates a Storage instance to handle file storage like OneDrive or Sharepoint document libraries + :param resource: Custom resource to be used in this drive object. Defaults to parent main_resource. + """ + if not isinstance(self.protocol, MSGraphProtocol): + # TODO: a custom protocol accessing OneDrive or Sharepoint Api will fail here. + raise RuntimeError('Drive options only works on Microsoft Graph API') + + return Storage(parent=self, main_resource=resource) diff --git a/O365/address_book.py b/O365/address_book.py new file mode 100644 index 0000000000000..adad7eb65ae6f --- /dev/null +++ b/O365/address_book.py @@ -0,0 +1,563 @@ +import logging +from dateutil.parser import parse +from enum import Enum + +from O365.message import HandleRecipientsMixin, Recipients, Message +from O365.utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent, AttachableMixin + +GAL_MAIN_RESOURCE = 'users' + +log = logging.getLogger(__name__) + + +class RecipientType(Enum): + TO = 'to' + CC = 'cc' + BCC = 'bcc' + + +class Contact(ApiComponent, AttachableMixin, HandleRecipientsMixin): + """ Contact manages lists of events on an associated contact on office365. """ + + _mapping = {'display_name': 'displayName', 'name': 'givenName', 'surname': 'surname', 'title': 'title', 'job_title': 'jobTitle', + 'company_name': 'companyName', 'department': 'department', 'office_location': 'officeLocation', + 'business_phones': 'businessPhones', 'mobile_phone': 'mobilePhone', 'home_phones': 'homePhones', + 'emails': 'emailAddresses', 'business_addresses': 'businessAddress', 'home_addresses': 'homesAddress', + 'other_addresses': 'otherAddress', 'categories': 'categories'} + + _endpoints = { + 'root_contact': '/contacts/{id}', + 'child_contact': '/contactFolders/{id}/contacts' + } + message_constructor = Message + + def __init__(self, *, parent=None, con=None, **kwargs): + + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + cc = self._cc # alias to shorten the code + + self.object_id = cloud_data.get(cc('id'), None) + self.created = cloud_data.get(cc('createdDateTime'), None) + self.modified = cloud_data.get(cc('lastModifiedDateTime'), None) + + local_tz = self.protocol.timezone + self.created = parse(self.created).astimezone(local_tz) if self.created else None + self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None + + self.display_name = cloud_data.get(cc('displayName'), '') + self.name = cloud_data.get(cc('givenName'), '') + self.surname = cloud_data.get(cc('surname'), '') + + self.title = cloud_data.get(cc('title'), '') + self.job_title = cloud_data.get(cc('jobTitle'), '') + self.company_name = cloud_data.get(cc('companyName'), '') + self.department = cloud_data.get(cc('department'), '') + self.office_location = cloud_data.get(cc('officeLocation'), '') + self.business_phones = cloud_data.get(cc('businessPhones'), []) or [] + self.mobile_phone = cloud_data.get(cc('mobilePhone'), '') + self.home_phones = cloud_data.get(cc('homePhones'), []) or [] + self.__emails = self._recipients_from_cloud(cloud_data.get(cc('emailAddresses'), [])) + email = cloud_data.get(cc('email')) + if email and email not in self.__emails: + # a Contact from OneDrive? + self.__emails.add(email) + self.business_addresses = cloud_data.get(cc('businessAddress'), {}) + self.home_addresses = cloud_data.get(cc('homesAddress'), {}) + self.other_addresses = cloud_data.get(cc('otherAddress'), {}) + self.preferred_language = cloud_data.get(cc('preferredLanguage'), None) + + self.categories = cloud_data.get(cc('categories'), []) + self.folder_id = cloud_data.get(cc('parentFolderId'), None) + + # when using Users endpoints (GAL) : missing keys: ['mail', 'userPrincipalName'] + mail = cloud_data.get(cc('mail'), None) + user_principal_name = cloud_data.get(cc('userPrincipalName'), None) + if mail and mail not in self.emails: + self.emails.add(mail) + if user_principal_name and user_principal_name not in self.emails: + self.emails.add(user_principal_name) + + @property + def emails(self): + return self.__emails + + @property + def main_email(self): + """ Returns the first email on the emails""" + if not self.emails: + return None + return self.emails[0].address + + @property + def full_name(self): + """ Returns name + surname """ + return '{} {}'.format(self.name, self.surname).strip() + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return self.display_name or self.full_name or 'Unknwon Name' + + def to_api_data(self): + """ Returns a dictionary in cloud format """ + + data = { + 'displayName': self.display_name, + 'givenName': self.name, + 'surname': self.surname, + 'title': self.title, + 'jobTitle': self.job_title, + 'companyName': self.company_name, + 'department': self.department, + 'officeLocation': self.office_location, + 'businessPhones': self.business_phones, + 'mobilePhone': self.mobile_phone, + 'homePhones': self.home_phones, + 'emailAddresses': self.emails.to_api_data(), + 'businessAddress': self.business_addresses, + 'homesAddress': self.home_addresses, + 'otherAddress': self.other_addresses, + 'categories': self.categories} + return data + + def delete(self): + """ Deletes this contact """ + + if not self.object_id: + raise RuntimeError('Attemping to delete an usaved Contact') + + url = self.build_url(self._endpoints.get('contact').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response) + + def update(self, fields): + """ Updates a contact + :param fields: a dict of fields to update (field: value). + """ + + if not self.object_id: + raise RuntimeError('Attemping to update an usaved Contact') + + if fields is None or not isinstance(fields, (list, tuple)): + raise ValueError('Must provide fields to update as a list or tuple') + + data = {} + for field in fields: + mapping = self._mapping.get(field) + if mapping is None: + raise ValueError('{} is not a valid updatable field from Contact'.format(field)) + update_value = getattr(self, field) + if isinstance(update_value, Recipients): + data[self._cc(mapping)] = [self._recipient_to_cloud(recipient) for recipient in update_value] + else: + data[self._cc(mapping)] = update_value + + url = self.build_url(self._endpoints.get('contact'.format(id=self.object_id))) + + response = self.con.patch(url, data=data) + + return bool(response) + + def save(self): + """ Saves this Contact to the cloud """ + if self.object_id: + raise RuntimeError("Can't save an existing Contact. Use Update instead. ") + + if self.folder_id: + url = self.build_url(self._endpoints.get('child_contact').format(self.folder_id)) + else: + url = self.build_url(self._endpoints.get('root_contact')) + + response = self.con.post(url, data=self.to_api_data()) + if not response: + return False + + contact = response.json() + + self.object_id = contact.get(self._cc('id'), None) + self.created = contact.get(self._cc('createdDateTime'), None) + self.modified = contact.get(self._cc('lastModifiedDateTime'), None) + + local_tz = self.protocol.timezone + self.created = parse(self.created).astimezone(local_tz) if self.created else None + self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None + + return True + + def new_message(self, recipient=None, *, recipient_type=RecipientType.TO): + """ + This method returns a new draft Message instance with this contact first email as a recipient + :param recipient: a Recipient instance where to send this message. If None, first recipient with address. + :param recipient_type: a RecipientType Enum. + :return: a new draft Message or None if recipient has no addresses + """ + if self.main_resource == GAL_MAIN_RESOURCE: + # preventing the contact lookup to explode for big organizations.. + raise RuntimeError('Sending a message to all users within an Organization is not allowed') + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipient = recipient or self.emails.get_first_recipient_with_address() + if not recipient: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipient) + + return new_message + + +class BaseContactFolder(ApiComponent): + """ Base Contact Folder Grouping Functionality """ + + _endpoints = { + 'gal': '', + 'root_contacts': '/contacts', + 'folder_contacts': '/contactFolders/{id}/contacts', + 'get_folder': '/contactFolders/{id}', + 'root_folders': '/contactFolders', + 'child_folders': '/contactFolders/{id}/childFolders' + } + + contact_constructor = Contact + message_constructor = Message + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + self.root = kwargs.pop('root', False) # This folder has no parents if root = True. + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name', None)) # Fallback to manual folder + self.folder_id = cloud_data.get(self._cc('id'), None) + self.parent_id = cloud_data.get(self._cc('parentFolderId'), None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Contact Folder: {}'.format(self.name) + + def get_contacts(self, limit=100, *, query=None, order_by=None, batch=None): + """ + Gets a list of contacts from this address book + + When quering the Global Address List the Users enpoint will be used. + Only a limited set of information will be available unless you have acces to + scope 'User.Read.All' wich requires App Administration Consent. + Also using the Users enpoint has some limitations on the quering capabilites. + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html + + :param limit: Number of elements to return. Over 999 uses batch. + :param query: a OData valid filter clause + :param order_by: OData valid order by clause + :param batch: Returns a custom iterator that retrieves items in batches allowing + to retrieve more items than the limit. + """ + + if self.main_resource == GAL_MAIN_RESOURCE: + # using Users endpoint to access the Global Address List + url = self.build_url(self._endpoints.get('gal')) + else: + if self.root: + url = self.build_url(self._endpoints.get('root_contacts')) + else: + url = self.build_url(self._endpoints.get('folder_contacts').format(self.folder_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + contacts = [self.contact_constructor(parent=self, **{self._cloud_data_key: contact}) + for contact in data.get('value', [])] + + next_link = data.get(NEXT_LINK_KEYWORD, None) + + if batch and next_link: + return Pagination(parent=self, data=contacts, constructor=self.contact_constructor, + next_link=next_link, limit=limit) + else: + return contacts + + +class ContactFolder(BaseContactFolder): + """ A Contact Folder representation """ + + def get_folder(self, folder_id=None, folder_name=None): + """ + Returns a ContactFolder by it's id or name + :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not) + :param folder_name: the folder name to be retrieved. Must be a child of this folder. + """ + + if folder_id and folder_name: + raise RuntimeError('Provide only one of the options') + + if not folder_id and not folder_name: + raise RuntimeError('Provide one of the options') + + if folder_id: + # get folder by it's id, independent of the parent of this folder_id + url = self.build_url(self._endpoints.get('get_folder').format(id=folder_id)) + params = None + else: + # get folder by name. Only looks up in child folders. + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) + + params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), folder_name), '$top': 1} + + response = self.con.get(url, params=params) + if not response: + return None + + if folder_id: + folder = response.json() + else: + folder = response.json().get('value') + folder = folder[0] if folder else None + if folder is None: + return None + + # Everything received from the cloud must be passed with self._cloud_data_key + # we don't pass parent, as this folder may not be a child of self. + return ContactFolder(con=self.con, protocol=self.protocol, main_resource=self.main_resource, **{self._cloud_data_key: folder}) + + def get_folders(self, limit=None, *, query=None, order_by=None): + """ + Returns a list of child folders + + :param limit: Number of elements to return. + :param query: a OData valid filter clause + :param order_by: OData valid order by clause + """ + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url(self._endpoints.get('child_folders').format(self.folder_id)) + + params = {} + + if limit: + params['$top'] = limit + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + return [ContactFolder(parent=self, **{self._cloud_data_key: folder}) + for folder in data.get('value', [])] + + def create_child_folder(self, folder_name): + """ + Creates a new child folder + :return the new Folder Object or None + """ + + if not folder_name: + return None + + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) + + response = self.con.post(url, data={self._cc('displayName'): folder_name}) + if not response: + return None + + folder = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return ContactFolder(parent=self, **{self._cloud_data_key: folder}) + + def update_folder_name(self, name): + """ Change this folder name """ + if self.root: + return False + if not name: + return False + + url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.patch(url, data={self._cc('displayName'): name}) + if not response: + return False + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + + return True + + def move_folder(self, to_folder): + """ + Change this folder name + :param to_folder: a folder_id str or a ContactFolder + """ + if self.root: + return False + if not to_folder: + return False + + url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) + + if isinstance(to_folder, ContactFolder): + folder_id = to_folder.folder_id + elif isinstance(to_folder, str): + folder_id = to_folder + else: + return False + + response = self.con.patch(url, data={self._cc('parentFolderId'): folder_id}) + if not response: + return False + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + + return True + + def delete(self): + """ Deletes this folder """ + + if self.root or not self.folder_id: + return False + + url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + + return True + + def new_contact(self): + """ Creates a new contact to be saved into it's parent folder """ + contact = self.contact_constructor(parent=self) + if not self.root: + contact.folder_id = self.folder_id + + return contact + + def new_message(self, recipient_type=RecipientType.TO, *, query=None): + """ + This method returns a new draft Message instance with all the contacts first email as a recipient + :param recipient_type: a RecipientType Enum. + :param query: a query to filter the contacts (passed to get_contacts) + :return: a draft Message or None if no contacts could be retrieved + """ + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipients = [contact.emails[0] + for contact in self.get_contacts(limit=None, query=query) + if contact.emails and contact.emails[0].address] + + if not recipients: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipients) + + return new_message + + +class AddressBook(ContactFolder): + """ A class representing an address book """ + + def __init__(self, *, parent=None, con=None, **kwargs): + # set instance to be a root instance + super().__init__(parent=parent, con=con, root=True, **kwargs) + + def __repr__(self): + return 'Address Book resource: {}'.format(self.main_resource) + + +class GlobalAddressList(BaseContactFolder): + """ A class representing the Global Address List (Users API) """ + + def __init__(self, *, parent=None, con=None, **kwargs): + # set instance to be a root instance and the main_resource to be the GAL_MAIN_RESOURCE + super().__init__(parent=parent, con=con, root=True, main_resource=GAL_MAIN_RESOURCE, + name='Global Address List', **kwargs) + + def __repr__(self): + return 'Global Address List' + + def get_contact_by_email(self, email): + """ Returns a Contact by it's email """ + + if not email: + return None + + email = email.strip() + + url = self.build_url('{}/{}'.format(self._endpoints.get('gal'), email)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.contact_constructor(parent=self, **{self._cloud_data_key: data}) diff --git a/O365/attachment.py b/O365/attachment.py deleted file mode 100644 index 7edabf4efca80..0000000000000 --- a/O365/attachment.py +++ /dev/null @@ -1,169 +0,0 @@ -''' -This file contains the functions for working with attachments. Including the ability to work with the -binary of the file directly. The file is stored locally as a string using base64 encoding. -''' - -import base64 -import logging -import json -import requests -import sys -from os import path - -log = logging.getLogger(__name__) - -class Attachment( object ): - ''' - Attachment class is the object for dealing with attachments in your messages. To add one to - a message, simply append it to the message's attachment list (message.attachments). - - these are stored locally in base64 encoded strings. You can pass either a byte string or a - base64 encoded string tot he appropriate set function to bring your attachment into the - instance, which will of course need to happen before it could be mailed. - - Methods: - isType - compares file extension to extension given. not case sensative. - getType - returns file extension. - save - save attachment locally. - getByteString - returns the attached file as a byte string. - setByteString - set the attached file using a byte string. - getBase64 - returns the attached file as a base64 encoded string. - setBase64 - set the attached file using a base64 encoded string. - ''' - - create_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}/attachments' - - def __init__(self,json=None,path=None,verify=True): - ''' - Creates a new attachment class, optionally from existing JSON. - - Keyword Arguments: - json -- json to create the class from. this is mostly used by the class internally when an - attachment is downloaded from the cloud. If you want to create a new attachment, leave this - empty. (default = None) - path -- a string giving the path to a file. it is cross platform as long as you break - windows convention and use '/' instead of '\'. Passing this argument will tend to - the rest of the process of making an attachment. Note that passing in json as well - will cause this argument to be ignored. - ''' - if json: - self.json = json - self.isPDF = '.pdf' in self.json['Name'].lower() - elif path: - with open(path,'rb') as val: - self.json = {'@odata.type':'#Microsoft.OutlookServices.FileAttachment'} - self.isPDF = '.pdf' in path.lower() - - self.setByteString(val.read()) - try: - self.setName(path[path.rindex('/')+1:]) - except: - self.setName(path) - else: - self.json = {'@odata.type':'#Microsoft.OutlookServices.FileAttachment'} - - self.verify = verify - - def isType(self,typeString): - '''Test to if the attachment is the same type as you are seeking. Do not include a period.''' - return '.'+typeString.lower() in self.json['Name'].lower() - - def getType(self): - '''returns the file extension''' - try: - return self.json['Name'][self.json['Name'].rindex('.'):] - except ValueError: - log.debug('No file extension found on file ', self.json['Name']) - return "" - - def save(self,location): - '''Save the attachment locally to disk. - - location -- path to where the file is to be saved. - ''' - try: - outs = open(path.join(location, self.json['Name']),'wb') - outs.write(base64.b64decode(self.json['ContentBytes'])) - outs.close() - log.debug('file saved locally.') - - except Exception as e: - log.debug('file failed to be saved: %s',str(e)) - return False - - log.debug('file saving successful') - return True - - def attach(self,message): - ''' - This does the actual creating of the attachment as well as attaching to a message. - - message -- a Message type, the message to be attached to. - ''' - mid = message.json['Id'] - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = requests.post(self.create_url.format(mid),data,header=headers,auth=message.auth,verify=self.verify) - log.debug('Response from server for attaching: {0}'.format(str(response))) - - return response - - def getByteString(self): - '''Fetch the binary representation of the file. useful for times you want to - skip the step of saving before sending it to another program. This allows - you to make scripts that use linux pipe lines in their execution. - ''' - try: - return base64.b64decode(self.json['ContentBytes']) - - except Exception as e: - log.debug('what? no clue what went wrong here. cannot decode attachment.') - - return False - - def getBase64(self): - '''Returns the base64 encoding representation of the attachment.''' - try: - return self.json['ContentBytes'] - except Exception as e: - log.debug('what? no clue what went wrong here. probably no attachment.') - return False - - def getName(self): - '''Returns the file name.''' - try: - return self.json['Name'] - except Exception as e: - log.error('The attachment does not appear to have a name.') - return False - - def setName(self,val): - '''Set the name for the file.''' - self.json['Name'] = val - - def setByteString(self,val): - '''Sets the file for this attachment from a byte string.''' - try: - if sys.version_info[0] == 2: - self.json['ContentBytes'] = base64.b64encode(val) - else: - self.json['ContentBytes'] = str(base64.encodebytes(val),'utf-8') - except Exception as e: - log.debug('error encoding attachment: {0}'.format(e)) - return False - return True - - def setBase64(self,val): - '''Sets the file for this attachment from a base64 encoding.''' - try: - base64.decodestring(val) - except: - log.error('tried to give me an attachment as a base64 and it is not.') - raise - self.json['ContentBytes'] = val - return True - -#To the King! diff --git a/O365/cal.py b/O365/cal.py deleted file mode 100644 index 0b08f1d896c63..0000000000000 --- a/O365/cal.py +++ /dev/null @@ -1,123 +0,0 @@ -import requests -import base64 -import json -import logging -import time - -from O365.event import Event - -log = logging.getLogger(__name__) - -class Calendar( object ): - ''' - Calendar manages lists of events on an associated calendar on office365. - - Methods: - getName - Returns the name of the calendar. - getCalendarId - returns the GUID that identifies the calendar on office365 - getId - synonym of getCalendarId - getEvents - kicks off the process of fetching events. - fetchEvents - legacy duplicate of getEvents - - Variable: - events_url - the url that is actually called to fetch events. takes an ID, start, and end date. - time_string - used for converting between struct_time and json's time format. - ''' - events_url = 'https://outlook.office365.com/api/v1.0/me/calendars/{0}/calendarview?startDateTime={1}&endDateTime={2}&$top={3}' - time_string = '%Y-%m-%dT%H:%M:%SZ' - - def __init__(self, json=None, auth=None, verify=True): - ''' - Wraps all the information for managing calendars. - ''' - self.json = json - self.auth = auth - self.events = [] - - if json: - log.debug('translating calendar information into local variables.') - self.calendarId = json['Id'] - self.name = json['Name'] - - self.verify = verify - - def __str__(self): - if self.json and 'Name' in self.json: - return self.json['Name'] - return "Unnamed calendar" - - def __str__(self): - '''Return informal, nicely printable string.''' - return self.getName() - - def getName(self): - '''Get the calendar's Name.''' - return self.json['Name'] - - def getCalendarId(self): - '''Get calendar's GUID for office 365. mostly used internally in this library.''' - return self.json['Id'] - - def getId(self): - '''Get calendar's GUID for office 365. mostly used internally in this library.''' - return self.getCalendarId() - - def fetchEvents(self,start=None,end=None): - ''' - So I originally made this function "fetchEvents" which was a terrible idea. Everything else - is "getX" except events which were appearenty to good for that. So this function is just a - pass through for legacy sake. - ''' - return self.getEvents(start,end) - - - def getEvents(self,start=None,end=None, eventCount=10): - ''' - Pulls events in for this calendar. default range is today to a year now. - - Keyword Arguments: - start -- The starting date from where you want to begin requesting events. The expected - type is a struct_time. Default is today. - end -- The ending date to where you want to end requesting events. The expected - type is a struct_time. Default is a year from start. - ''' - - # If no start time has been supplied, it is assumed you want to start as of now. - if not start: - start = time.strftime(self.time_string) - - # If no end time has been supplied, it is assumed you want the end time to be a year - # from what ever the start date was. - if not end: - end = time.time() - end += 3600*24*365 - end = time.gmtime(end) - end = time.strftime(self.time_string,end) - - # This is where the actual call to Office365 happens. - response = requests.get(self.events_url.format(self.json['Id'],start,end,eventCount) ,auth=self.auth, verify=self.verify) - log.info('Response from O365: %s', str(response)) - - #This takes that response and then parses it into individual calendar events. - for event in response.json()['value']: - try: - duplicate = False - - # checks to see if the event is a duplicate. if it is local changes are clobbered. - for i,e in enumerate(self.events): - if e.json['Id'] == event['Id']: - self.events[i] = Event(event,self.auth,self) - duplicate = True - break - - if not duplicate: - self.events.append(Event(event,self.auth,self)) - - log.debug('appended event: %s',event['Subject']) - except Exception as e: - log.info('failed to append calendar: %',str(e)) - - log.debug('all events retrieved and put in to the list.') - return True - -# To the King! diff --git a/O365/calendar.py b/O365/calendar.py new file mode 100644 index 0000000000000..9d8d6a6753a7d --- /dev/null +++ b/O365/calendar.py @@ -0,0 +1,1326 @@ +import logging +from enum import Enum +from dateutil.parser import parse +import datetime as dt +import pytz +import calendar +from bs4 import BeautifulSoup as bs + +from O365.utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent, BaseAttachments, BaseAttachment, \ + AttachableMixin, ImportanceLevel, TrackerSet +from O365.message import HandleRecipientsMixin + +log = logging.getLogger(__name__) + +MONTH_NAMES = [calendar.month_name[x] for x in range(1, 13)] + + +class EventResponse(Enum): + Organizer = 'organizer' + TentativelyAccepted = 'tentativelyAccepted' + Accepted = 'accepted' + Declined = 'declined' + NotResponded = 'notResponded' + + +class AttendeeType(Enum): + Required = 'required' + Optional = 'optional' + Resource = 'resource' + + +class EventSensitivity(Enum): + Normal = 'normal' + Personal = 'personal' + Private = 'private' + Confidential = 'confidential' + + +class EventShowAs(Enum): + Free = 'free' + Tentative = 'tentative' + Busy = 'busy' + Oof = 'oof' + WorkingElsewhere = 'workingElsewhere' + Unknown = 'unknown' + + +class CalendarColors(Enum): + LightBlue = 0 + LightGreen = 1 + LightOrange = 2 + LightGray = 3 + LightYellow = 4 + LightTeal = 5 + LightPink = 6 + LightBrown = 7 + LightRed = 8 + MaxColor = 9 + Auto = -1 + + +class EventAttachment(BaseAttachment): + _endpoints = {'attach': '/events/{id}/attachments'} + + +class EventAttachments(BaseAttachments): + _endpoints = {'attachments': '/events/{id}/attachments'} + + _attachment_constructor = EventAttachment + + +class DailyEventFrequency: + + def __init__(self, recurrence_type, interval): + self.recurrence_type = recurrence_type + self.interval = interval + + +class EventRecurrence(ApiComponent): + """ A representation of an event recurrence properties """ + + def __init__(self, event, recurrence=None): + super().__init__(protocol=event.protocol, main_resource=event.main_resource) + + self._event = event + recurrence = recurrence or {} + # recurrence pattern + recurrence_pattern = recurrence.get(self._cc('pattern'), {}) + + self.__interval = recurrence_pattern.get(self._cc('interval'), None) + self.__days_of_week = recurrence_pattern.get(self._cc('daysOfWeek'), set()) + self.__first_day_of_week = recurrence_pattern.get(self._cc('firstDayOfWeek'), None) + self.__day_of_month = recurrence_pattern.get(self._cc('dayOfMonth'), None) + self.__month = recurrence_pattern.get(self._cc('month'), None) + self.__index = recurrence_pattern.get(self._cc('index'), 'first') + + # recurrence range + recurrence_range = recurrence.get(self._cc('range'), {}) + + self.__ocurrences = recurrence_range.get(self._cc('numberOfOccurrences'), None) + self.__start_date = recurrence_range.get(self._cc('startDate'), None) + self.__end_date = recurrence_range.get(self._cc('endDate'), None) + self.__recurrence_time_zone = recurrence_range.get(self._cc('recurrenceTimeZone'), self.protocol.get_windows_tz()) + # time and time zones are not considered in recurrence ranges... + # I don't know why 'recurrenceTimeZone' is present here + # Sending a startDate datetime to the server results in an Error: + # "Cannot convert the literal 'datetime' to the expected type 'Edm.Date'" + if recurrence_range: + self.__start_date = parse(self.__start_date).date() if self.__start_date else None + self.__end_date = parse(self.__end_date).date() if self.__end_date else None + + def __repr__(self): + if self.__interval: + pattern = 'Daily: every {} day/s'.format(self.__interval) + if self.__days_of_week: + days = ' or '.join(list(self.__days_of_week)) + pattern = 'Relative Monthly: {} {} every {} month/s'.format(self.__index, days, self.__interval) + if self.__first_day_of_week: + pattern = 'Weekly: every {} week/s on {}'.format(self.__interval, days) + elif self.__month: + pattern = 'Relative Yearly: {} {} every {} year/s on {}'.format(self.__index, days, + self.__interval, + MONTH_NAMES[self.__month - 1]) + elif self.__day_of_month: + pattern = 'Absolute Monthly: on day {} every {} month/s'.format(self.__day_of_month, self.__interval) + if self.__month: + pattern = 'Absolute Yearly: on {} {} every {} year/s'.format(MONTH_NAMES[self.__month - 1], + self.__day_of_month, + self.__interval) + + r_range = '' + if self.__start_date: + r_range = 'Starting on {}'.format(self.__start_date) + ends_on = 'with no end' + if self.__end_date: + ends_on = 'ending on {}'.format(self.__end_date) + elif self.__ocurrences: + ends_on = 'up to {} ocurrences'.format(self.__ocurrences) + r_range = '{} {}'.format(r_range, ends_on) + return '{}. {}'.format(pattern, r_range) + else: + return 'No recurrence enabled' + + def __str__(self): + return self.__repr__() + + def __bool__(self): + return bool(self.__interval) + + def _track_changes(self): + """ Update the track_changes on the event to reflect a needed update on this field """ + self._event._track_changes.add('recurrence') + + @property + def interval(self): + return self.__interval + + @interval.setter + def interval(self, value): + self.__interval = value + self._track_changes() + + @property + def days_of_week(self): + return self.__days_of_week + + @days_of_week.setter + def days_of_week(self, value): + self.__days_of_week = value + self._track_changes() + + @property + def first_day_of_week(self): + return self.__first_day_of_week + + @first_day_of_week.setter + def first_day_of_week(self, value): + self.__first_day_of_week = value + self._track_changes() + + @property + def day_of_month(self): + return self.__day_of_month + + @day_of_month.setter + def day_of_month(self, value): + self.__day_of_month = value + self._track_changes() + + @property + def month(self): + return self.__month + + @month.setter + def month(self, value): + self.__month = value + self._track_changes() + + @property + def index(self): + return self.__index + + @index.setter + def index(self, value): + self.__index = value + self._track_changes() + + @property + def ocurrences(self): + return self.__ocurrences + + @ocurrences.setter + def ocurrences(self, value): + self.__ocurrences = value + self._track_changes() + + @property + def recurrence_time_zone(self): + return self.__recurrence_time_zone + + @recurrence_time_zone.setter + def recurrence_time_zone(self, value): + self.__recurrence_time_zone = value + self._track_changes() + + @property + def start_date(self): + return self.__start_date + + @start_date.setter + def start_date(self, value): + if not isinstance(value, dt.date): + raise ValueError('start_date value must be a valid date object') + if isinstance(value, dt.datetime): + value = value.date() + self.__start_date = value + self._track_changes() + + @property + def end_date(self): + return self.__start_date + + @end_date.setter + def end_date(self, value): + if not isinstance(value, dt.date): + raise ValueError('end_date value must be a valid date object') + if isinstance(value, dt.datetime): + value = value.date() + self.__end_date = value + self._track_changes() + + def to_api_data(self): + data = {} + # recurrence pattern + if self.__interval and isinstance(self.__interval, int): + recurrence_pattern = data[self._cc('pattern')] = {} + recurrence_pattern[self._cc('type')] = 'daily' + recurrence_pattern[self._cc('interval')] = self.__interval + if self.__days_of_week and isinstance(self.__days_of_week, (list, tuple, set)): + recurrence_pattern[self._cc('type')] = 'relativeMonthly' + recurrence_pattern[self._cc('daysOfWeek')] = list(self.__days_of_week) + if self.__first_day_of_week: + recurrence_pattern[self._cc('type')] = 'weekly' + recurrence_pattern[self._cc('firstDayOfWeek')] = self.__first_day_of_week + elif self.__month and isinstance(self.__month, int): + recurrence_pattern[self._cc('type')] = 'relativeYearly' + recurrence_pattern[self._cc('month')] = self.__month + if self.__index: + recurrence_pattern[self._cc('index')] = self.__index + else: + if self.__index: + recurrence_pattern[self._cc('index')] = self.__index + + elif self.__day_of_month and isinstance(self.__day_of_month, int): + recurrence_pattern[self._cc('type')] = 'absoluteMonthly' + recurrence_pattern[self._cc('dayOfMonth')] = self.__day_of_month + if self.__month and isinstance(self.__month, int): + recurrence_pattern[self._cc('type')] = 'absoluteYearly' + recurrence_pattern[self._cc('month')] = self.__month + + # recurrence range + if self.__start_date: + recurrence_range = data[self._cc('range')] = {} + recurrence_range[self._cc('type')] = 'noEnd' + recurrence_range[self._cc('startDate')] = self.__start_date.isoformat() + recurrence_range[self._cc('recurrenceTimeZone')] = self.__recurrence_time_zone + + if self.__end_date: + recurrence_range[self._cc('type')] = 'endDate' + recurrence_range[self._cc('endDate')] = self.__end_date.isoformat() + elif self.__ocurrences is not None and isinstance(self.__ocurrences, int): + recurrence_range[self._cc('type')] = 'numbered' + recurrence_range[self._cc('numberOfOccurrences')] = self.__ocurrences + + return data + + def _clear_pattern(self): + """ Clears this event recurrence """ + # pattern group + self.__interval = None + self.__days_of_week = set() + self.__first_day_of_week = None + self.__day_of_month = None + self.__month = None + self.__index = 'first' + # range group + self.__start_date = None + self.__end_date = None + self.__ocurrences = None + + def set_range(self, start=None, end=None, ocurrences=None): + if start is None: + if self.__start_date is None: + self.__start_date = dt.date.today() + else: + self.start_date = start + + if end: + self.end_date = end + elif ocurrences: + self.__ocurrences = ocurrences + self._track_changes() + + def set_daily(self, interval, **kwargs): + self._clear_pattern() + self.__interval = interval + self.set_range(**kwargs) + + def set_weekly(self, interval, *, days_of_week, first_day_of_week, **kwargs): + self.set_daily(interval, **kwargs) + self.__days_of_week = set(days_of_week) + self.__first_day_of_week = first_day_of_week + + def set_monthly(self, interval, *, day_of_month=None, days_of_week=None, index=None, **kwargs): + if not day_of_month and not days_of_week: + raise ValueError('Must provide day_of_month or days_of_week values') + if day_of_month and days_of_week: + raise ValueError('Must provide only one of the two options') + self.set_daily(interval, **kwargs) + if day_of_month: + self.__day_of_month = day_of_month + elif days_of_week: + self.__days_of_week = set(days_of_week) + if index: + self.__index = index + + def set_yearly(self, interval, month, *, day_of_month=None, days_of_week=None, index=None, **kwargs): + self.set_monthly(interval, day_of_month=day_of_month, days_of_week=days_of_week, index=index, **kwargs) + self.__month = month + + +class ResponseStatus(ApiComponent): + """ An event response status (status, time) """ + + def __init__(self, parent, response_status): + super().__init__(protocol=parent.protocol, main_resource=parent.main_resource) + self.status = response_status.get(self._cc('response'), None) + self.status = None if self.status == 'none' else self.status + if self.status: + self.response_time = response_status.get(self._cc('time'), None) + if self.response_time: + self.response_time = parse(self.response_time).astimezone(self.protocol.timezone) + else: + self.response_time = None + + def __repr__(self): + return self.status + + def __str__(self): + return self.__repr__() + + +class Attendee: + """ A Event attendee """ + + def __init__(self, address, *, name=None, attendee_type=None, response_status=None, event=None): + self._address = address + self._name = name + self._event = event + if isinstance(response_status, ResponseStatus): + self.__response_status = response_status + else: + self.__response_status = None + self.__attendee_type = AttendeeType.Required + if attendee_type: + self.attendee_type = attendee_type + + def __repr__(self): + if self.name: + return '{}: {} ({})'.format(self.attendee_type.name, self.name, self.address) + else: + return '{}: {}'.format(self.attendee_type.name, self.address) + + def __str__(self): + return self.__repr__() + + @property + def address(self): + return self._address + + @address.setter + def address(self, value): + self._address = value + self._name = '' + self._track_changes() + + @property + def name(self): + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes() + + def _track_changes(self): + """ Update the track_changes on the event to reflect a needed update on this field """ + self._event._track_changes.add('attendees') + + @property + def response_status(self): + return self.__response_status + + @property + def attendee_type(self): + return self.__attendee_type + + @attendee_type.setter + def attendee_type(self, value): + if isinstance(value, AttendeeType): + self.__attendee_type = value + else: + self.__attendee_type = AttendeeType(value) + self._track_changes() + + +class Attendees(ApiComponent): + """ A Collection of Attendees """ + + def __init__(self, event, attendees=None): + super().__init__(protocol=event.protocol, main_resource=event.main_resource) + self._event = event + self.__attendees = [] + self.untrack = True + if attendees: + self.add(attendees) + self.untrack = False + + def __iter__(self): + return iter(self.__attendees) + + def __getitem__(self, key): + return self.__attendees[key] + + def __contains__(self, item): + return item in {attendee.email for attendee in self.__attendees} + + def __len__(self): + return len(self.__attendees) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Attendees Count: {}'.format(len(self.__attendees)) + + def clear(self): + self.__attendees = [] + self._track_changes() + + def _track_changes(self): + """ Update the track_changes on the event to reflect a needed update on this field """ + if self.untrack is False: + self._event._track_changes.add('attendees') + + def add(self, attendees): + """ Add attendees to the parent event """ + + if attendees: + if isinstance(attendees, str): + self.__attendees.append(Attendee(address=attendees, event=self._event)) + self._track_changes() + elif isinstance(attendees, Attendee): + self.__attendees.append(attendees) + self._track_changes() + elif isinstance(attendees, tuple): + name, address = attendees + if address: + self.__attendees.append(Attendee(address=address, name=name, event=self._event)) + self._track_changes() + elif isinstance(attendees, list): + for attendee in attendees: + self.add(attendee) + elif isinstance(attendees, dict) and self._cloud_data_key in attendees: + attendees = attendees.get(self._cloud_data_key) + for attendee in attendees: + email = attendee.get(self._cc('emailAddress'), {}) + address = email.get(self._cc('address'), None) + if address: + name = email.get(self._cc('name'), None) + attendee_type = attendee.get(self._cc('type'), 'required') # default value + self.__attendees.append( + Attendee(address=address, name=name, attendee_type=attendee_type, event=self._event, + response_status=ResponseStatus(parent=self, + response_status=attendee.get(self._cc('status'), {})))) + else: + raise ValueError('Attendees must be an address string, an' + ' Attendee instance, a (name, address) tuple or a list') + + def remove(self, attendees): + """ Remove the provided attendees from the event """ + if isinstance(attendees, (list, tuple)): + attendees = {attendee.address if isinstance(attendee, Attendee) else attendee for attendee in attendees} + elif isinstance(attendees, str): + attendees = {attendees} + elif isinstance(attendees, Attendee): + attendees = {attendees.address} + else: + raise ValueError('Incorrect parameter type for attendees') + + new_attendees = [] + for attendee in self.__attendees: + if attendee.address not in attendees: + new_attendees.append(attendee) + self.__attendees = new_attendees + self._track_changes() + + def to_api_data(self): + data = [] + for attendee in self.__attendees: + if attendee.address: + att_data = { + self._cc('emailAddress'): { + self._cc('address'): attendee.address, + self._cc('name'): attendee.name + }, + self._cc('type'): attendee.attendee_type.value + } + data.append(att_data) + return data + + +class Event(ApiComponent, AttachableMixin, HandleRecipientsMixin): + """ A Calendar event """ + + _endpoints = { + 'calendar': '/calendars/{id}', + 'event': '/events/{id}', + 'event_default': '/calendar/events', + 'event_calendar': '/calendars/{id}/events' + } + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + cc = self._cc # alias + self._track_changes = TrackerSet(casing=cc) # internal to know which properties need to be updated on the server + self.calendar_id = kwargs.get('calendar_id', None) + download_attachments = kwargs.get('download_attachments') + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get(cc('id'), None) + self.__subject = cloud_data.get(cc('subject'), kwargs.get('subject', '') or '') + body = cloud_data.get(cc('body'), {}) + self.__body = body.get(cc('content'), '') + self.body_type = body.get(cc('contentType'), 'HTML') # default to HTML for new messages + + self.__attendees = Attendees(event=self, attendees={self._cloud_data_key: cloud_data.get(cc('attendees'), [])}) + self.__categories = cloud_data.get(cc('categories'), []) + + self.__created = cloud_data.get(cc('createdDateTime'), None) + self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) + + local_tz = self.protocol.timezone + self.__created = parse(self.__created).astimezone(local_tz) if self.__created else None + self.__modified = parse(self.__modified).astimezone(local_tz) if self.__modified else None + + start_obj = cloud_data.get(cc('start'), {}) + if isinstance(start_obj, dict): + try: + timezone = pytz.timezone(self.protocol.get_iana_tz(start_obj.get(self._cc('timeZone'), 'UTC'))) + except pytz.UnknownTimeZoneError: + timezone = local_tz + start = start_obj.get(cc('dateTime'), None) + start = timezone.localize(parse(start)) if start else None + if start and timezone != local_tz: + start = start.astimezone(local_tz) + else: + # Outlook v1.0 api compatibility + start = local_tz.localize(parse(start_obj)) if start_obj else None + self.__start = start + + end_obj = cloud_data.get(cc('end'), {}) + if isinstance(end_obj, dict): + try: + timezone = pytz.timezone(self.protocol.get_iana_tz(end_obj.get(self._cc('timeZone'), 'UTC'))) + except pytz.UnknownTimeZoneError: + timezone = local_tz + end = end_obj.get(cc('dateTime'), None) + end = timezone.localize(parse(end)) if end else None + if end and timezone != local_tz: + end = end.astimezone(local_tz) + else: + # Outlook v1.0 api compatibility + end = local_tz.localize(parse(end_obj)) if end_obj else None + self.__end = end + + self.has_attachments = cloud_data.get(cc('hasAttachments'), False) + self.__attachments = EventAttachments(parent=self, attachments=[]) + if self.has_attachments and download_attachments: + self.attachments.download_attachments() + self.__categories = cloud_data.get(cc('categories'), []) + self.ical_uid = cloud_data.get(cc('iCalUId'), None) + self.__importance = ImportanceLevel(cloud_data.get(cc('importance'), 'normal') or 'normal') + self.__is_all_day = cloud_data.get(cc('isAllDay'), False) + self.is_cancelled = cloud_data.get(cc('isCancelled'), False) + self.is_organizer = cloud_data.get(cc('isOrganizer'), True) + self.__location = cloud_data.get(cc('location'), {}).get(cc('displayName'), '') + self.locations = cloud_data.get(cc('locations'), []) # TODO + self.online_meeting_url = cloud_data.get(cc('onlineMeetingUrl'), None) + self.__organizer = self._recipient_from_cloud(cloud_data.get(cc('organizer'), None), field='organizer') + self.__recurrence = EventRecurrence(event=self, recurrence=cloud_data.get(cc('recurrence'), None)) + self.__is_reminder_on = cloud_data.get(cc('isReminderOn'), True) + self.__remind_before_minutes = cloud_data.get(cc('reminderMinutesBeforeStart'), 15) + self.__response_requested = cloud_data.get(cc('responseRequested'), True) + self.__response_status = ResponseStatus(parent=self, response_status=cloud_data.get(cc('responseStatus'), {})) + self.__sensitivity = EventSensitivity(cloud_data.get(cc('sensitivity'), 'normal')) + self.series_master_id = cloud_data.get(cc('seriesMasterId'), None) + self.__show_as = EventShowAs(cloud_data.get(cc('showAs'), 'busy')) + self.event_type = cloud_data.get(cc('type'), None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Subject: {}'.format(self.subject) + + def to_api_data(self, restrict_keys=None): + """ Returns a dict to comunicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to. + """ + cc = self._cc # alias + data = { + cc('subject'): self.__subject, + cc('body'): { + cc('contentType'): self.body_type, + cc('content'): self.__body}, + cc('start'): { + cc('dateTime'): self.__start.strftime('%Y-%m-%dT%H:%M:%S'), + cc('timeZone'): self.protocol.get_windows_tz(self.__start.tzinfo.zone) + }, + cc('end'): { + cc('dateTime'): self.__end.strftime('%Y-%m-%dT%H:%M:%S'), + cc('timeZone'): self.protocol.get_windows_tz(self.__end.tzinfo.zone) + }, + cc('attendees'): self.__attendees.to_api_data(), + cc('location'): {cc('displayName'): self.__location}, + cc('categories'): self.__categories, + cc('isAllDay'): self.__is_all_day, + cc('importance'): self.__importance.value, + cc('isReminderOn'): self.__is_reminder_on, + cc('reminderMinutesBeforeStart'): self.__remind_before_minutes, + cc('responseRequested'): self.__response_requested, + cc('sensitivity'): self.__sensitivity.value, + cc('showAs'): self.__show_as.value, + } + + if self.__recurrence: + data[cc('recurrence')] = self.__recurrence.to_api_data() + + if self.has_attachments: + data[cc('attachments')] = self.__attachments.to_api_data() + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + @property + def created(self): + return self.__created + + @property + def modified(self): + return self.__modified + + @property + def body(self): + return self.__body + + @body.setter + def body(self, value): + self.__body = value + self._track_changes.add('body') + + @property + def subject(self): + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add('subject') + + @property + def start(self): + return self.__start + + @start.setter + def start(self, value): + if not isinstance(value, dt.date): + raise ValueError("'start' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = self.protocol.timezone.localize(value) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__start = value + if not self.end: + self.end = self.__start + dt.timedelta(minutes=30) + self._track_changes.add('start') + + @property + def end(self): + return self.__end + + @end.setter + def end(self, value): + if not isinstance(value, dt.date): + raise ValueError("'end' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = self.protocol.timezone.localize(value) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__end = value + self._track_changes.add('end') + + @property + def importance(self): + return self.__importance + + @importance.setter + def importance(self, value): + self.__importance = value if isinstance(value, ImportanceLevel) else ImportanceLevel(value) + self._track_changes.add('importance') + + @property + def is_all_day(self): + return self.__is_all_day + + @is_all_day.setter + def is_all_day(self, value): + self.__is_all_day = value + if value: + # Api requirement: start and end must be set to midnight + # is_all_day needs event.start included in the request on updates + # is_all_day needs event.end included in the request on updates + start = self.__start or dt.date.today() + end = self.__end or dt.date.today() + + if (start + dt.timedelta(hours=24)) > end: + # Api requires that under is_all_day=True start and end must be at least 24 hours away + end = start + dt.timedelta(hours=24) + + # set to midnight + start = dt.datetime(start.year, start.month, start.day) + end = dt.datetime(end.year, end.month, end.day) + + self.start = start + self.end = end + self._track_changes.add('isAllDay') + + @property + def location(self): + return self.__location + + @location.setter + def location(self, value): + self.__location = value + self._track_changes.add('location') + + @property + def is_reminder_on(self): + return self.__is_reminder_on + + @is_reminder_on.setter + def is_reminder_on(self, value): + self.__is_reminder_on = value + self._track_changes.add('isReminderOn') + self._track_changes.add('reminderMinutesBeforeStart') + + @property + def remind_before_minutes(self): + return self.__remind_before_minutes + + @remind_before_minutes.setter + def remind_before_minutes(self, value): + self.__is_reminder_on = True + self.__remind_before_minutes = int(value) + self._track_changes.add('isReminderOn') + self._track_changes.add('reminderMinutesBeforeStart') + + @property + def response_requested(self): + return self.__response_requested + + @response_requested.setter + def response_requested(self, value): + self.__response_requested = value + self._track_changes.add('responseRequested') + + @property + def recurrence(self): + return self.__recurrence + + @property + def organizer(self): + return self.__organizer + + @property + def show_as(self): + return self.__show_as + + @show_as.setter + def show_as(self, value): + self.__show_as = value if isinstance(value, EventShowAs) else EventShowAs(value) + self._track_changes.add('showAs') + + @property + def sensitivity(self): + return self.__sensitivity + + @sensitivity.setter + def sensitivity(self, value): + self.__sensitivity = value if isinstance(value, EventSensitivity) else EventSensitivity(value) + self._track_changes.add('sensitivity') + + @property + def response_status(self): + return self.__response_status + + @property + def attachments(self): + return self.__attachments + + @property + def attendees(self): + return self.__attendees + + @property + def categories(self): + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = value + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, tuple): + self.__categories = list(value) + else: + raise ValueError('categories must be a list') + self._track_changes.add('categories') + + def delete(self): + """ Deletes a stored event """ + if self.object_id is None: + raise RuntimeError('Attempting to delete an unsaved event') + + url = self.build_url(self._endpoints.get('event').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response) + + def save(self): + """ Create a new event or update an existing one by checking what + values have changed and update them on the server + """ + + if self.object_id: + # update event + if not self._track_changes: + return True # there's nothing to update + url = self.build_url(self._endpoints.get('event').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # new event + if self.calendar_id: + url = self.build_url(self._endpoints.get('event_calendar').format(id=self.calendar_id)) + else: + url = self.build_url(self._endpoints.get('event_default')) + method = self.con.post + data = self.to_api_data() + + response = method(url, data=data) + if not response: + return False + + if not self.object_id: + # new event + event = response.json() + + self.object_id = event.get(self._cc('id'), None) + + self.__created = event.get(self._cc('createdDateTime'), None) + self.__modified = event.get(self._cc('lastModifiedDateTime'), None) + + self.__created = parse(self.__created).astimezone(self.protocol.timezone) if self.__created else None + self.__modified = parse(self.__modified).astimezone(self.protocol.timezone) if self.__modified else None + else: + self.__modified = self.protocol.timezone.localize(dt.datetime.now()) + + return True + + def accept_event(self, comment=None, *, send_response=True, tentatively=False): + + if not self.object_id: + raise RuntimeError("Can't accept event that doesn't exist") + + url = self.build_url(self._endpoints.get('event').format(id=self.object_id)) + url = url + '/tentativelyAccept' if tentatively else '/accept' + + data = {} + if comment and isinstance(comment, str): + data[self._cc('comment')] = comment + if send_response is False: + data[self._cc('sendResponse')] = send_response + + response = self.con.post(url, data=data or None) + + return bool(response) + + def decline_event(self, comment=None, *, send_response=True): + + if not self.object_id: + raise RuntimeError("Can't accept event that doesn't exist") + + url = self.build_url(self._endpoints.get('event').format(id=self.object_id)) + url = url + '/decline' + + data = {} + if comment and isinstance(comment, str): + data[self._cc('comment')] = comment + if send_response is False: + data[self._cc('sendResponse')] = send_response + + response = self.con.post(url, data=data or None) + + return bool(response) + + def get_body_text(self): + """ Parse the body html and returns the body text using bs4 """ + if self.body_type != 'HTML': + return self.body + + try: + soup = bs(self.body, 'html.parser') + except Exception as e: + return self.body + else: + return soup.body.text + + def get_body_soup(self): + """ Returns the beautifulsoup4 of the html body""" + if self.body_type != 'HTML': + return None + else: + return bs(self.body, 'html.parser') + + +class Calendar(ApiComponent, HandleRecipientsMixin): + """ A Calendar Representation """ + + _endpoints = { + 'calendar': '/calendars/{id}', + 'get_events': '/calendars/{id}/events', + 'get_event': '/calendars/{id}/events/{ide}' + } + event_constructor = Event + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.name = cloud_data.get(self._cc('name'), '') + self.calendar_id = cloud_data.get(self._cc('id'), None) + self.__owner = self._recipient_from_cloud(cloud_data.get(self._cc('owner'), {}), field='owner') + color = cloud_data.get(self._cc('color'), -1) + if isinstance(color, str): + color = -1 if color == 'auto' else color + # TODO: other string colors? + self.color = CalendarColors(color) + self.can_edit = cloud_data.get(self._cc('canEdit'), False) + self.can_share = cloud_data.get(self._cc('canShare'), False) + self.can_view_private_items = cloud_data.get(self._cc('canViewPrivateItems'), False) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Calendar: {} from {}'.format(self.name, self.owner) + + @property + def owner(self): + return self.__owner + + def update(self): + """ Updates this calendar. Only name and color can be changed. """ + + if not self.calendar_id: + return False + + url = self.build_url(self._endpoints.get('calendar')) + + data = { + self._cc('name'): self.name, + self._cc('color'): self.color.value if isinstance(self.color, CalendarColors) else self.color + } + + response = self.con.patch(url, data=data) + + return bool(response) + + def delete(self): + """ Deletes this calendar """ + + if not self.calendar_id: + return False + + url = self.build_url(self._endpoints.get('calendar').format(id=self.calendar_id)) + + response = self.con.delete(url) + if not response: + return False + + self.calendar_id = None + + return True + + def get_events(self, limit=25, *, query=None, order_by=None, batch=None, download_attachments=False): + """ + Get events from the default Calendar + + :param limit: limits the result set. Over 999 uses batch. + :param query: applies a filter to the request such as 'displayName:HelloFolder' + :param order_by: orders the result set based on this condition + :param batch: Returns a custom iterator that retrieves items in batches allowing + to retrieve more items than the limit. Download_attachments is ignored. + :param download_attachments: downloads event attachments + """ + + url = self.build_url(self._endpoints.get('get_events').format(id=self.calendar_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + if batch: + download_attachments = False + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params, headers={'Prefer': 'outlook.timezone="UTC"'}) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + events = [self.event_constructor(parent=self, download_attachments=download_attachments, + **{self._cloud_data_key: event}) + for event in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=events, constructor=self.event_constructor, + next_link=next_link, limit=limit) + else: + return events + + def new_event(self, subject=None): + """ Returns a new (unsaved) Event object """ + return self.event_constructor(parent=self, subject=subject, calendar_id=self.calendar_id) + + def get_event(self, param): + """Returns an Event instance by it's id + :param param: an event_id or a Query instance + """ + + if param is None: + return None + if isinstance(param, str): + url = self.build_url(self._endpoints.get('get_event').format(id=self.calendar_id, ide=param)) + params = None + else: + url = self.build_url(self._endpoints.get('get_events').format(id=self.calendar_id)) + params = {'$top': 1} + params.update(param.as_params()) + + response = self.con.get(url, params=params, headers={'Prefer': 'outlook.timezone="UTC"'}) + if not response: + return None + + if isinstance(param, str): + event = response.json() + else: + event = response.json().get('value', []) + if event: + event = event[0] + else: + return None + return self.event_constructor(parent=self, **{self._cloud_data_key: event}) + + +class Schedule(ApiComponent): + """ A Wrapper around calendars and events""" + + _endpoints = { + 'root_calendars': '/calendars', + 'get_calendar': '/calendars/{id}', + 'default_calendar': '/calendar', + 'events': '/calendar/events' + } + + calendar_constructor = Calendar + event_constructor = Event + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Schedule resource: {}'.format(self.main_resource) + + def list_calendars(self, limit=None, *, query=None, order_by=None): + """ + Gets a list of calendars + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html + + :param limit: Number of elements to return. + :param query: a OData valid filter clause + :param order_by: OData valid order by clause + """ + + url = self.build_url(self._endpoints.get('root_calendars')) + + params = {} + if limit: + params['$top'] = limit + if query: + params['$filter'] = str(query) + if order_by: + params['$orderby'] = order_by + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + contacts = [self.calendar_constructor(parent=self, **{self._cloud_data_key: calendar}) + for calendar in data.get('value', [])] + + return contacts + + def new_calendar(self, calendar_name): + """ + Creates a new calendar + :return a new Calendar instance + """ + + if not calendar_name: + return None + + url = self.build_url(self._endpoints.get('root_calendars')) + + response = self.con.post(url, data={self._cc('name'): calendar_name}) + if not response: + return None + + calendar = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.calendar_constructor(parent=self, **{self._cloud_data_key: calendar}) + + def get_calendar(self, calendar_id=None, calendar_name=None): + """ + Returns a calendar by it's id or name + :param calendar_id: the calendar id to be retrieved. + :param calendar_name: the calendar name to be retrieved. + """ + if calendar_id and calendar_name: + raise RuntimeError('Provide only one of the options') + + if not calendar_id and not calendar_name: + raise RuntimeError('Provide one of the options') + + if calendar_id: + # get calendar by it's id + url = self.build_url(self._endpoints.get('get_calendar').format(id=calendar_id)) + params = None + else: + # get calendar by name + url = self.build_url(self._endpoints.get('root_calendars')) + params = {'$filter': "{} eq '{}'".format(self._cc('name'), calendar_name), '$top': 1} + + response = self.con.get(url, params=params) + if not response: + return None + + if calendar_id: + calendar = response.json() + else: + calendar = response.json().get('value') + calendar = calendar[0] if calendar else None + if calendar is None: + return None + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.calendar_constructor(parent=self, **{self._cloud_data_key: calendar}) + + def get_default_calendar(self): + """ Returns the default calendar for the current user """ + + url = self.build_url(self._endpoints.get('default_calendar')) + + response = self.con.get(url) + if not response: + return None + + calendar = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.calendar_constructor(parent=self, **{self._cloud_data_key: calendar}) + + def get_events(self, limit=25, *, query=None, order_by=None, batch=None, download_attachments=False): + """ + Get events from the default Calendar + + :param limit: limits the result set. Over 999 uses batch. + :param query: applies a filter to the request such as 'displayName:HelloFolder' + :param order_by: orders the result set based on this condition + :param batch: Returns a custom iterator that retrieves items in batches allowing + to retrieve more items than the limit. Download_attachments is ignored. + :param download_attachments: downloads event attachments + """ + + url = self.build_url(self._endpoints.get('events')) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + if batch: + download_attachments = False + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params, headers={'Prefer': 'outlook.timezone="UTC"'}) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + events = [self.event_constructor(parent=self, download_attachments=download_attachments, + **{self._cloud_data_key: event}) + for event in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=events, constructor=self.event_constructor, + next_link=next_link, limit=limit) + else: + return events + + def new_event(self, subject=None): + """ Returns a new (unsaved) Event object in the default calendar """ + return self.event_constructor(parent=self, subject=subject) diff --git a/O365/connection.py b/O365/connection.py index c5de0ecc311d9..abfaf30679fb8 100644 --- a/O365/connection.py +++ b/O365/connection.py @@ -1,242 +1,578 @@ import logging -import os -import os.path as path import json - -import requests +import os +import time +from pathlib import Path +from tzlocal import get_localzone +from datetime import tzinfo +import pytz + +from stringcase import pascalcase, camelcase, snakecase +from requests import Session +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry # dynamic loading of module Retry by requests.packages +from requests.exceptions import HTTPError, RequestException, ProxyError, SSLError, Timeout, ConnectionError from oauthlib.oauth2 import TokenExpiredError from requests_oauthlib import OAuth2Session -from future.utils import with_metaclass -log = logging.getLogger(__name__) +from O365.utils import ME_RESOURCE, IANA_TO_WIN, WIN_TO_IANA +log = logging.getLogger(__name__) -class MicroDict(dict): - def __getitem__(self, key): - result = super(MicroDict, self).get(key[:1].lower() + key[1:], None) - if result is None: - result = super(MicroDict, self).get(key[:1].upper() + key[1:]) - if type(result) is dict: - result = MicroDict(result) - return result +O365_API_VERSION = 'v2.0' # v2.0 does not allow basic auth +GRAPH_API_VERSION = 'v1.0' +OAUTH_REDIRECT_URL = 'https://outlook.office365.com/owa/' + +RETRIES_STATUS_LIST = (429, 500, 502, 503, 504) # 429 is the TooManyRequests status code. +RETRIES_BACKOFF_FACTOR = 0.5 + + +DEFAULT_SCOPES = { + 'basic': [('offline_access',), 'User.Read'], # wrap any scope in a 1 element tuple to avoid prefixing + 'mailbox': ['Mail.Read'], + 'mailbox_shared': ['Mail.Read.Shared'], + 'message_send': ['Mail.Send'], + 'message_send_shared': ['Mail.Send.Shared'], + 'message_all': ['Mail.ReadWrite', 'Mail.Send'], + 'message_all_shared': ['Mail.ReadWrite.Shared', 'Mail.Send.Shared'], + 'address_book': ['Contacts.Read'], + 'address_book_shared': ['Contacts.Read.Shared'], + 'address_book_all': ['Contacts.ReadWrite'], + 'address_book_all_shared': ['Contacts.ReadWrite.Shared'], + 'calendar': ['Calendars.ReadWrite'], + 'users': ['User.ReadBasic.All'], + 'onedrive': ['Files.ReadWrite.All'], + 'sharepoint_dl': ['Sites.ReadWrite.All'], +} + + +class Protocol: + """ Base class for all protocols """ + + _protocol_url = 'not_defined' # Main url to request. Override in subclass + _oauth_scope_prefix = '' # prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE) + _oauth_scopes = {} # dictionary of {scopes_name: [scope1, scope2]} + + def __init__(self, *, protocol_url=None, api_version=None, default_resource=ME_RESOURCE, + casing_function=None, protocol_scope_prefix=None, timezone=None, **kwargs): + """ + :param protocol_url: the base url used to comunicate with the server + :param api_version: the api version + :param default_resource: the default resource to use when there's no other option + :param casing_function: the casing transform function to be used on api keywords + :param protocol_scope_prefix: prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE) + :param timezone: prefered timezone, defaults to the system timezone + """ + if protocol_url is None or api_version is None: + raise ValueError('Must provide valid protocol_url and api_version values') + self.protocol_url = protocol_url or self._protocol_url + self.protocol_scope_prefix = protocol_scope_prefix or '' + self.api_version = api_version + self.service_url = '{}{}/'.format(protocol_url, api_version) + self.default_resource = default_resource + self.use_default_casing = True if casing_function is None else False # if true just returns the key without transform + self.casing_function = casing_function or camelcase + self.timezone = timezone or get_localzone() # pytz timezone + self.max_top_value = 500 # Max $top parameter value + + # define any keyword that can be different in this protocol + self.keyword_data_store = {} + + def get_service_keyword(self, keyword): + """ Returns the data set to the key in the internal data-key dict """ + return self.keyword_data_store.get(keyword, None) + + def convert_case(self, dict_key): + """ Returns a key converted with this protocol casing method + + Converts case to send/read from the cloud + When using Microsoft Graph API, the keywords of the API use lowerCamelCase Casing. + When using Office 365 API, the keywords of the API use PascalCase Casing. + + Default case in this API is lowerCamelCase. + + :param dict_key: a dictionary key to convert + """ + return dict_key if self.use_default_casing else self.casing_function(dict_key) + @staticmethod + def to_api_case(dict_key): + """ Converts keys to snake case """ + return snakecase(dict_key) -class Singleton(type): - _instance = None + def get_scopes_for(self, user_provided_scopes): + """ Returns a list of scopes needed for each of the scope_helpers provided + :param user_provided_scopes: a list of scopes or scope helpers + """ + if user_provided_scopes is None: + # return all available scopes + user_provided_scopes = [app_part for app_part in self._oauth_scopes] + elif isinstance(user_provided_scopes, str): + user_provided_scopes = [user_provided_scopes] + + if not isinstance(user_provided_scopes, (list, tuple)): + raise ValueError("'user_provided_scopes' must be a list or a tuple of strings") + + scopes = set() + for app_part in user_provided_scopes: + for scope in self._oauth_scopes.get(app_part, [app_part]): + scopes.add(self._prefix_scope(scope)) + + return list(scopes) + + def _prefix_scope(self, scope): + """ Inserts the protocol scope prefix """ + if self.protocol_scope_prefix: + if isinstance(scope, tuple): + return scope[0] + elif scope.startswith(self.protocol_scope_prefix): + return scope + else: + return '{}{}'.format(self.protocol_scope_prefix, scope) + else: + if isinstance(scope, tuple): + return scope[0] + else: + return scope - def __call__(cls, *args, **kwargs): - if not cls._instance: - cls._instance = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instance + @staticmethod + def get_iana_tz(windows_tz): + """ Returns a valid pytz TimeZone (Iana/Olson Timezones) from a given windows TimeZone + Note: Windows Timezones are SHIT! + """ + timezone = WIN_TO_IANA.get(windows_tz) + if timezone is None: + # Nope, that didn't work. Try adding "Standard Time", + # it seems to work a lot of times: + timezone = WIN_TO_IANA.get(windows_tz + ' Standard Time') -# def __new__(cls, *args, **kwargs): -# if not cls._instance: -# cls._instance = object.__new__(cls) -# return cls._instance + # Return what we have. + if timezone is None: + raise pytz.UnknownTimeZoneError("Can't find Windows TimeZone " + windows_tz) + return timezone -_default_token_file = '.o365_token' -_home_path = path.expanduser("~") -default_token_path = path.join(_home_path, _default_token_file) + def get_windows_tz(self, iana_tz=None): + """ Returns a valid windows TimeZone from a given pytz TimeZone (Iana/Olson Timezones) + Note: Windows Timezones are SHIT!... no ... really THEY ARE HOLY FUCKING SHIT!. + """ + iana_tz = iana_tz or self.timezone + timezone = IANA_TO_WIN.get(iana_tz.zone if isinstance(iana_tz, tzinfo) else iana_tz) + if timezone is None: + raise pytz.UnknownTimeZoneError("Can't find Iana TimeZone " + iana_tz.zone) + return timezone -def save_token(token, token_path=None): - """ Save the specified token dictionary to a specified file path - :param token: token dictionary returned by the oauth token request - :param token_path: path to where the files is to be saved +class MSGraphProtocol(Protocol): + """ A Microsoft Graph Protocol Implementation + https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook """ - if not token_path: - token_path = default_token_path - with open(token_path, 'w') as token_file: - json.dump(token, token_file, indent=True) + _protocol_url = 'https://graph.microsoft.com/' + _oauth_scope_prefix = 'https://graph.microsoft.com/' + _oauth_scopes = DEFAULT_SCOPES + def __init__(self, api_version='v1.0', default_resource=ME_RESOURCE, **kwargs): + super().__init__(protocol_url=self._protocol_url, api_version=api_version, + default_resource=default_resource, casing_function=camelcase, + protocol_scope_prefix=self._oauth_scope_prefix, **kwargs) -def load_token(token_path=None): - """ Save the specified token dictionary to a specified file path + self.keyword_data_store['message_type'] = 'microsoft.graph.message' + self.keyword_data_store['file_attachment_type'] = '#microsoft.graph.fileAttachment' + self.keyword_data_store['item_attachment_type'] = '#microsoft.graph.itemAttachment' + self.max_top_value = 999 # Max $top parameter value - :param token_path: path to the file with token information saved - """ - if not token_path: - token_path = default_token_path - token = None - if path.exists(token_path): - with open(token_path, 'r') as token_file: - token = json.load(token_file) - return token +class MSOffice365Protocol(Protocol): + """ A Microsoft Office 365 Protocol Implementation + https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook + """ + _protocol_url = 'https://outlook.office.com/api/' + _oauth_scope_prefix = 'https://outlook.office.com/' + _oauth_scopes = DEFAULT_SCOPES -def delete_token(token_path=None): - """ Save the specified token dictionary to a specified file path + def __init__(self, api_version='v2.0', default_resource=ME_RESOURCE, **kwargs): + super().__init__(protocol_url=self._protocol_url, api_version=api_version, + default_resource=default_resource, casing_function=pascalcase, + protocol_scope_prefix=self._oauth_scope_prefix, **kwargs) - :param token_path: path to where the token is saved - """ - if not token_path: - token_path = default_token_path + self.keyword_data_store['message_type'] = 'Microsoft.OutlookServices.Message' + self.keyword_data_store['file_attachment_type'] = '#Microsoft.OutlookServices.FileAttachment' + self.keyword_data_store['item_attachment_type'] = '#Microsoft.OutlookServices.ItemAttachment' + self.max_top_value = 999 # Max $top parameter value - if path.exists(token_path): - os.unlink(token_path) +class Connection: + """ Handles all comunication (requests) between the app and the server """ -class Connection(with_metaclass(Singleton)): _oauth2_authorize_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' _oauth2_token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' - default_headers = None - - def __init__(self): - """ Creates a O365 connection object """ - self.api_version = None - self.auth = None + _default_token_file = 'o365_token.txt' + _default_token_path = Path() / _default_token_file + _allowed_methods = ['get', 'post', 'put', 'patch', 'delete'] + + def __init__(self, credentials, *, scopes=None, + proxy_server=None, proxy_port=8080, proxy_username=None, proxy_password=None, + requests_delay=200, raise_http_errors=True, request_retries=3, token_file_name=None): + """ Creates an API connection object + + :param credentials: a tuple containing the credentials for this connection. + This could be either (username, password) using basic authentication or (client_id, client_secret) using oauth. + Generate client_id and client_secret in https://apps.dev.microsoft.com. + :param scopes: oauth2: a list of scopes permissions to request access to + :param proxy_server: the proxy server + :param proxy_port: the proxy port, defaults to 8080 + :param proxy_username: the proxy username + :param proxy_password: the proxy password + :param requests_delay: number of miliseconds to wait between api calls + The Api will respond with 429 Too many requests if more than 17 requests are made per second. + Defaults to 200 miliseconds just in case more than 1 connection is making requests across multiple processes. + :param raise_http_errors: If True Http 4xx and 5xx status codes will raise as exceptions + :param request_retries: number of retries done when the server responds with 5xx error codes. + :param token_file_name: custom token file name to be used when storing the token credentials. + """ + if not isinstance(credentials, tuple) or len(credentials) != 2 or (not credentials[0] and not credentials[1]): + raise ValueError('Provide valid auth credentials') - self.oauth = None - self.client_id = None - self.client_secret = None + self.auth = credentials + self.scopes = scopes + self.store_token = True + self.token_path = (Path() / token_file_name) if token_file_name else self._default_token_path self.token = None - self.token_path = None - self.proxy_dict = None - def is_valid(self): - valid = False + self.session = None # requests Oauth2Session object + + self.proxy = {} + self.set_proxy(proxy_server, proxy_port, proxy_username, proxy_password) + self.requests_delay = requests_delay or 0 + self.previous_request_at = None # store the time of the previous request + self.raise_http_errors = raise_http_errors + self.request_retries = request_retries + + self.naive_session = Session() # requests Session object + self.naive_session.proxies = self.proxy + + if self.request_retries: + retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries, + backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST) + adapter = HTTPAdapter(max_retries=retry) + self.naive_session.mount('http://', adapter) + self.naive_session.mount('https://', adapter) + + def set_proxy(self, proxy_server, proxy_port, proxy_username, proxy_password): + """ Sets a proxy on the Session """ + if proxy_server and proxy_port: + if proxy_username and proxy_password: + self.proxy = { + "http": "http://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port), + "https": "https://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port), + } + else: + self.proxy = { + "http": "http://{}:{}".format(proxy_server, proxy_port), + "https": "https://{}:{}".format(proxy_server, proxy_port), + } + + def check_token_file(self): + """ Checks if the token file exists at the given position""" + if self.token_path: + path = Path(self.token_path) + else: + path = self._default_token_path - if self.api_version == '1.0': - valid = True if self.auth else False - elif self.api_version == '2.0': - valid = True if self.oauth else False + return path.exists() - return valid + def get_authorization_url(self, requested_scopes=None, redirect_uri=OAUTH_REDIRECT_URL): + """ + Inicialices the oauth authorization flow, getting the authorization url that the user must approve. + This is a two step process, first call this function. Then get the url result from the user and then + call 'request_token' to get and store the access token. + """ - @staticmethod - def login(username, password): - """ Connect to office 365 using specified username and password + client_id, client_secret = self.auth - :param username: username to login with - :param password: password for authentication - """ - connection = Connection() + if requested_scopes: + scopes = requested_scopes + elif self.scopes is not None: + scopes = self.scopes + else: + raise ValueError('Must provide at least one scope') - connection.api_version = '1.0' - connection.auth = (username, password) - return connection + self.session = oauth = OAuth2Session(client_id=client_id, redirect_uri=redirect_uri, scope=scopes) + self.session.proxies = self.proxy + if self.request_retries: + retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries, + backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST) + adapter = HTTPAdapter(max_retries=retry) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) - @staticmethod - def oauth2(client_id, client_secret, store_token=True, token_path=None): - """ Connect to office 365 using specified Open Authentication protocol + # TODO: access_type='offline' has no effect acording to documentation. This is done through scope 'offline_access'. + auth_url, state = oauth.authorization_url(url=self._oauth2_authorize_url, access_type='offline') + + return auth_url - :param client_id: application_id generated by https://apps.dev.microsoft.com when you register your app - :param client_secret: secret password key generated for your application - :param store_token: whether or not to store the token in file system, so u don't have to keep opening - the auth link and authenticating every time + def request_token(self, authorizated_url, store_token=True, token_path=None): + """ + Returns and saves the token with the authorizated_url provided by the user + + :param authorizated_url: url given by the authorization flow + :param store_token: whether or not to store the token in file system, + so u don't have to keep opening the auth link and authenticating every time :param token_path: full path to where the token should be saved to """ - connection = Connection() - - connection.api_version = '2.0' - connection.client_id = client_id - connection.client_secret = client_secret - connection.token_path = token_path - - if not store_token: - delete_token(token_path) - - token = load_token(token_path) - - if not token: - connection.oauth = OAuth2Session(client_id=client_id, - redirect_uri='https://outlook.office365.com/owa/', - scope=[ - 'https://graph.microsoft.com/Mail.ReadWrite', - 'https://graph.microsoft.com/Mail.Send', - 'offline_access'], ) - oauth = connection.oauth - auth_url, state = oauth.authorization_url( - url=Connection._oauth2_authorize_url, - access_type='offline') - print( - 'Please open {} and authorize the application'.format(auth_url)) - auth_resp = input('Enter the full result url: ') - os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = 'Y' - token = oauth.fetch_token(token_url=Connection._oauth2_token_url, - authorization_response=auth_resp, - client_secret=client_secret) - save_token(token, token_path) - else: - connection.oauth = OAuth2Session(client_id=client_id, - token=token) - return connection + if self.session is None: + raise RuntimeError("Fist call 'get_authorization_url' to generate a valid oauth object") - @staticmethod - def proxy(url, port, username, password): - """ Connect to Office 365 though the specified proxy + client_id, client_secret = self.auth + + # Allow token scope to not match requested scope. (Other auth libraries allow + # this, but Requests-OAuthlib raises exception on scope mismatch by default.) + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' + os.environ['OAUTHLIB_IGNORE_SCOPE_CHANGE'] = '1' + + try: + self.token = self.session.fetch_token(token_url=self._oauth2_token_url, + authorization_response=authorizated_url, + client_id=client_id, + client_secret=client_secret) + except Exception as e: + log.error('Unable to fetch auth token. Error: {}'.format(str(e))) + return None + + if token_path: + self.token_path = token_path + self.store_token = store_token + if self.store_token: + self._save_token(self.token, self.token_path) + + return True + + def get_session(self, token_path=None): + """ Create a requests Session object - :param url: url of the proxy server - :param port: port to connect to proxy server - :param username: username for authentication in the proxy server - :param password: password for the specified username + :param token_path: Only oauth: full path to where the token should be load from """ - connection = Connection() + self.token = self.token or self._load_token(token_path or self.token_path) - connection.proxy_dict = { - "http": "http://{}:{}@{}:{}".format(username, password, url, port), - "https": "https://{}:{}@{}:{}".format(username, password, url, - port), - } - return connection + if self.token: + client_id, _ = self.auth + self.session = OAuth2Session(client_id=client_id, token=self.token) + else: + raise RuntimeError('No auth token found. Authentication Flow needed') - @staticmethod - def get_response(request_url, **kwargs): - """ Fetches the response for specified url and arguments, adding the auth and proxy information to the url + self.session.proxies = self.proxy + + if self.request_retries: + retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries, + backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST) + adapter = HTTPAdapter(max_retries=retry) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + + return self.session + + def refresh_token(self): + """ Gets another token """ - :param request_url: url to request - :param kwargs: any keyword arguments to pass to the requests api - :return: response object + client_id, client_secret = self.auth + self.token = token = self.session.refresh_token(self._oauth2_token_url, client_id=client_id, + client_secret=client_secret) + if self.store_token: + self._save_token(token) + + def _check_delay(self): + """ Checks if a delay is needed between requests and sleeps if True """ + if self.previous_request_at: + dif = round(time.time() - self.previous_request_at, 2) * 1000 # difference in miliseconds + if dif < self.requests_delay: + time.sleep((self.requests_delay - dif) / 1000) # sleep needs seconds + self.previous_request_at = time.time() + + def _internal_request(self, request_obj, url, method, **kwargs): """ - connection = Connection() + Internal handling of requests. Handles Exceptions. - if not connection.is_valid(): - raise RuntimeError( - 'Connection is not configured, please use "O365.Connection" ' - 'to set username and password or OAuth2 authentication') + :param request_obj: a requests session. + :param url: the url to be requested + :param method: the method used on the request + :param kwargs: any other payload to be passed to requests + """ - con_params = {} - if connection.proxy_dict: - con_params['proxies'] = connection.proxy_dict - if connection.default_headers: - con_params['headers'] = connection.default_headers - con_params.update(kwargs) + method = method.lower() + assert method in self._allowed_methods, 'Method must be one of the allowed ones' - log.info('Requesting URL: {}'.format(request_url)) + if method == 'get': + kwargs.setdefault('allow_redirects', True) + elif method in ['post', 'put', 'patch']: + if 'headers' not in kwargs: + kwargs['headers'] = {} + if kwargs.get('headers') is not None and kwargs['headers'].get('Content-type') is None: + kwargs['headers']['Content-type'] = 'application/json' + if 'data' in kwargs and kwargs['headers'].get('Content-type') == 'application/json': + kwargs['data'] = json.dumps(kwargs['data']) # autoconvert to json - if connection.api_version == '1.0': - con_params['auth'] = connection.auth - response = requests.get(request_url, **con_params) - else: + request_done = False + token_refreshed = False + + while not request_done: + self._check_delay() # sleeps if needed try: - response = connection.oauth.get(request_url, **con_params) + log.info('Requesting ({}) URL: {}'.format(method.upper(), url)) + log.info('Request parameters: {}'.format(kwargs)) + response = request_obj.request(method, url, **kwargs) # auto_retry will occur inside this funcion call if enabled + response.raise_for_status() # raise 4XX and 5XX error codes. + log.info('Received response ({}) from URL {}'.format(response.status_code, response.url)) + request_done = True + return response except TokenExpiredError: - log.info('Token is expired, fetching a new token') - token = connection.oauth.refresh_token( - Connection._oauth2_token_url, - client_id=connection.client_id, - client_secret=connection.client_secret) - log.info('New token fetched') - save_token(token, connection.token_path) - - response = connection.oauth.get(request_url, **con_params) - - log.info('Received response from URL {}'.format(response.url)) - - if response.status_code == 401: - raise RuntimeError( - 'API returned status code 401 Unauthorized, check the connection credentials') - - response_json = response.json(object_pairs_hook=MicroDict) - if 'value' not in response_json: - raise RuntimeError( - 'Something went wrong, received an unexpected result \n{}'.format( - response_json)) - - response_values = response_json['value'] - return response_values + # Token has expired refresh token and try again on the next loop + if token_refreshed: + # Refresh token done but still TolenExpiredError raise + raise RuntimeError('Token Refresh Operation not working') + log.info('Oauth Token is expired, fetching a new token') + self.refresh_token() + log.info('New oauth token fetched') + token_refreshed = True + except (ConnectionError, ProxyError, SSLError, Timeout) as e: + # We couldn't connect to the target url, raise error + log.debug('Connection Error calling: {}.{}'.format(url, 'Using proxy: {}'.format(self.proxy) if self.proxy else '')) + raise e # re-raise exception + except HTTPError as e: + # Server response with 4XX or 5XX error status codes + status_code = int(e.response.status_code / 100) + if status_code == 4: + # Client Error + log.error('Client Error: {}'.format(str(e))) # logged as error. Could be a library error or Api changes + else: + # Server Error + log.debug('Server Error: {}'.format(str(e))) + if self.raise_http_errors: + raise e + else: + return e.response + except RequestException as e: + # catch any other exception raised by requests + log.debug('Request Exception: {}'.format(str(e))) + raise e + + def naive_request(self, url, method, **kwargs): + """ A naive request without any Authorization headers """ + return self._internal_request(self.naive_session, url, method, **kwargs) + + def oauth_request(self, url, method, **kwargs): + """ Makes a request to url using an oauth session """ + + # oauth authentication + if not self.session: + self.get_session() + + return self._internal_request(self.session, url, method, **kwargs) + + def get(self, url, params=None, **kwargs): + """ Shorthand for self.request(url, 'get') """ + return self.oauth_request(url, 'get', params=params, **kwargs) + + def post(self, url, data=None, **kwargs): + """ Shorthand for self.request(url, 'post') """ + return self.oauth_request(url, 'post', data=data, **kwargs) + + def put(self, url, data=None, **kwargs): + """ Shorthand for self.request(url, 'put') """ + return self.oauth_request(url, 'put', data=data, **kwargs) + + def patch(self, url, data=None, **kwargs): + """ Shorthand for self.request(url, 'patch') """ + return self.oauth_request(url, 'patch', data=data, **kwargs) + + def delete(self, url, **kwargs): + """ Shorthand for self.request(url, 'delete') """ + return self.oauth_request(url, 'delete', **kwargs) + + def _save_token(self, token, token_path=None): + """ Save the specified token dictionary to a specified file path + + :param token: token dictionary returned by the oauth token request + :param token_path: Path object to where the file is to be saved + """ + if not token_path: + token_path = self.token_path or self._default_token_path + else: + if not isinstance(token_path, Path): + raise ValueError('token_path must be a valid Path from pathlib') + + with token_path.open('w') as token_file: + json.dump(token, token_file, indent=True) + + return True + + def _load_token(self, token_path=None): + """ Load the specified token dictionary from specified file path + + :param token_path: Path object to the file with token information saved + """ + if not token_path: + token_path = self.token_path or self._default_token_path + else: + if not isinstance(token_path, Path): + raise ValueError('token_path must be a valid Path from pathlib') + + token = None + if token_path.exists(): + with token_path.open('r') as token_file: + token = json.load(token_file) + return token + + def _delete_token(self, token_path=None): + """ Delete the specified token dictionary from specified file path + + :param token_path: Path object to where the token is saved + """ + if not token_path: + token_path = self.token_path or self._default_token_path + else: + if not isinstance(token_path, Path): + raise ValueError('token_path must be a valid Path from pathlib') + + if token_path.exists(): + token_path.unlink() + return True + return False + + +def oauth_authentication_flow(client_id, client_secret, scopes=None, protocol=None, **kwargs): + """ + A helper method to authenticate and get the oauth token + :param client_id: the client_id + :param client_secret: the client_secret + :param scopes: a list of protocol user scopes to be converted by the protocol + :param protocol: the protocol to be used. Defaults to MSGraphProtocol + :param kwargs: other configuration to be passed to the Connection instance + """ + + credentials = (client_id, client_secret) + + protocol = protocol or MSGraphProtocol() + + con = Connection(credentials, scopes=protocol.get_scopes_for(scopes), **kwargs) + + consent_url = con.get_authorization_url() + print('Visit the following url to give consent:') + print(consent_url) + + token_url = input('Paste the authenticated url here: ') + + if token_url: + result = con.request_token(token_url) + if result: + print('Authentication Flow Completed. Oauth Access Token Stored. You can now use the API.') + else: + print('Something go wrong. Please try again.') + + return bool(result) + else: + print('Authentication Flow aborted.') + return False diff --git a/O365/contact.py b/O365/contact.py deleted file mode 100644 index e122f42f5e4f9..0000000000000 --- a/O365/contact.py +++ /dev/null @@ -1,164 +0,0 @@ -import requests -import base64 -import json -import logging -import time - -log = logging.getLogger(__name__) - -class Contact( object ): - ''' - Contact manages lists of events on an associated contact on office365. - - Methods: - getName - Returns the name of the contact. - getContactId - returns the GUID that identifies the contact on office365 - getId - synonym of getContactId - getContacts - kicks off the process of fetching contacts. - - Variable: - events_url - the url that is actually called to fetch events. takes an ID, start, and end. - time_string - used for converting between struct_time and json's time format. - ''' - con_url = 'https://outlook.office365.com/api/v1.0/me/contacts/{0}' - time_string = '%Y-%m-%dT%H:%M:%SZ' - - def __init__(self, json=None, auth=None, verify=True): - ''' - Wraps all the informaiton for managing contacts. - ''' - self.json = json - self.auth = auth - - if json: - log.debug('translating contact information into local variables.') - self.contactId = json['Id'] - self.name = json['DisplayName'] - else: - log.debug('there was no json, putting in some dumby info.') - self.json = {'DisplayName':'Jebediah Kerman'} - - self.verify = verify - - def delete(self): - '''delete's a contact. cause who needs that guy anyway?''' - headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} - - log.debug('preparing to delete contact.') - response = requests.delete(self.con_url.format(str(self.contactId)),headers=headers,auth=self.auth,verify=self.verify) - log.debug('response from delete attempt: {0}'.format(str(response))) - - return response.status_code == 204 - - def update(self): - '''updates a contact with information in the local json.''' - if not self.auth: - log.debug('no authentication information, cannot update') - return false - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = None - try: - response = requests.patch(self.con_url.format(str(self.contactId)),data,headers=headers,auth=self.auth,verify=self.verify) - log.debug('sent update request') - except Exception as e: - if response: - log.debug('response to contact update: {0}'.format(str(response))) - else: - log.error('No response, something is very wrong with update: {0}'.format(str(e))) - return False - - log.debug('Response to contact update: {0}'.format(str(response))) - - return Contact(response.json(),self.auth) - - def create(self): - '''create a contact with information in the local json.''' - if not self.auth: - log.debug('no authentication information, cannot create') - return false - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = None - try: - response = requests.post(self.con_url.format(str(self.contactId)),data,headers=headers,auth=self.auth,verify=self.verify) - log.debug('sent create request') - except Exception as e: - if response: - log.debug('response to contact create: {0}'.format(str(response))) - else: - log.error('No response, something is very wrong with create: {0}'.format(str(e))) - return False - - log.debug('Response to contact create: {0}'.format(str(response))) - - return Contact(response.json(),self.auth) - - def getContactId(self): - '''Get contact's GUID for office 365. mostly used interally in this library.''' - return self.json['Id'] - - def getId(self): - '''Get contact's GUID for office 365. mostly used interally in this library.''' - return self.getContactId() - - def getName(self): - '''Get the contact's Name.''' - return self.json['DisplayName'] - - def setName(self,val): - '''sets the display name of the contact.''' - self.json['DisplayName'] = val - - def getFirstEmailAddress(self): - '''Get the contact's first Email address. returns just the email address.''' - return self.json['EmailAddresses'][0]['Address'] - - def getEmailAdresses(self): - '''Get's all the contacts email addresses. returns a list of strings.''' - ret = [] - for e in self.json['EmailAddresses']: - ret.append(e['Address']) - - def getEmailAddress(self,loc): - ''' - This method will return the email address, text only, from the specified location. - As the order in which the addresses may have downloaded is non-deterministic, it can - not be garunteed that the nth address will be in the same position each time. - ''' - return self.json['EmailAddresses'][loc]['Address'] - - def setEmailAddress(self,val,loc): - ''' - Sets the email address of the specified index. The download of this information may - not be the same each time, so besure you know which address you are editing before - you use this method. - ''' - self.json['EmailAddress'][loc]['Address'] - - def getFirstEmailInfo(self): - '''gets an email address and it's associated date for the first email address.''' - return self.json['EmailAddresses'][0] - - def getAllEmailInfo(self): - '''Gets email addresses and any data that goes with it such as name, returns dict''' - return self.json['EmaillAddresses'] - - def setEmailInfo(self,val): - '''set the list of email addresses. Must be formated as such: - [{"Address":"youremail@example.com","Name","your name"},{and the next] - this replaces current inplace email address information. - ''' - self.json['EmailAddresses'] = val - - def addEmail(self,address,name=None): - '''takes a plain string email, and optionally name, and appends it to list.''' - ins = {'Address':address,'Name':None} - -#To the King! diff --git a/O365/drive.py b/O365/drive.py new file mode 100644 index 0000000000000..51ca96de3d808 --- /dev/null +++ b/O365/drive.py @@ -0,0 +1,1328 @@ +import logging +import warnings +from time import sleep +from dateutil.parser import parse +from urllib.parse import urlparse +from pathlib import Path + +from O365.address_book import Contact +from O365.utils import ApiComponent, Pagination, NEXT_LINK_KEYWORD, OneDriveWellKnowFolderNames + +log = logging.getLogger(__name__) + +SIZE_THERSHOLD = 1024 * 1024 * 2 # 2 MB +UPLOAD_SIZE_LIMIT_SIMPLE = 1024 * 1024 * 4 # 4 MB +UPLOAD_SIZE_LIMIT_SESSION = 1024 * 1024 * 60 # 60 MB +CHUNK_SIZE_BASE = 1024 * 320 # 320 Kb +DEFAULT_UPLOAD_CHUNK_SIZE = 1024 * 1024 * 5 # 5 MB --> Must be a multiple of CHUNK_SIZE_BASE +ALLOWED_PDF_EXTENSIONS = {'.csv', '.doc', '.docx', '.odp', '.ods', '.odt', '.pot', '.potm', '.potx', + '.pps', '.ppsx', '.ppsxm', '.ppt', '.pptm', '.pptx', '.rtf', '.xls', '.xlsx'} + + +class DownloadableMixin: + + def download(self, to_path=None, name=None, chunk_size='auto', convert_to_pdf=False): + """ + Downloads this file to the local drive. Can download the file in chunks with multiple requests to the server. + :param to_path: a path to store the downloaded file + :param name: the name you want the stored file to have. + :param chunk_size: number of bytes to retrieve from each api call to the server. + if auto, files bigger than SIZE_THERSHOLD will be chunked (into memory, will be however only 1 request) + :param convert_to_pdf: will try to donwload the converted pdf if file extension in ALLOWED_PDF_EXTENSIONS + """ + # TODO: Add download with more than one request (chunk_requests) with header 'Range'. For example: 'Range': 'bytes=0-1024' + + if to_path is None: + to_path = Path() + else: + if not isinstance(to_path, Path): + to_path = Path(to_path) + + if not to_path.exists(): + raise FileNotFoundError('{} does not exist'.format(to_path)) + + if name and not Path(name).suffix and self.name: + name = name + Path(self.name).suffix + + name = name or self.name + to_path = to_path / name + + url = self.build_url(self._endpoints.get('download').format(id=self.object_id)) + + try: + if chunk_size is None: + stream = False + elif chunk_size == 'auto': + if self.size and self.size > SIZE_THERSHOLD: + stream = True + else: + stream = False + elif isinstance(chunk_size, int): + stream = True + else: + raise ValueError("Argument chunk_size must be either 'auto' or any integer number representing bytes") + + params = {} + if convert_to_pdf and Path(name).suffix in ALLOWED_PDF_EXTENSIONS: + params['format'] = 'pdf' + + with self.con.get(url, stream=stream, params=params) as response: + if not response: + log.debug('Donwloading driveitem Request failed: {}'.format(response.reason)) + return False + with to_path.open(mode='wb') as output: + if stream: + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + output.write(chunk) + else: + output.write(response.content) + except Exception as e: + log.error('Error downloading driveitem {}. Error: {}'.format(self.name, str(e))) + return False + + return True + + +class CopyOperation(ApiComponent): + """ + https://github.com/OneDrive/onedrive-api-docs/issues/762 + """ + + _endpoints = { + # all prefixed with /drives/{drive_id} on main_resource by default + 'item': '/items/{id}', + } + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + self.parent = parent # parent will be allways a DriveItem + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + self.monitor_url = kwargs.get('monitor_url', None) + self.item_id = kwargs.get('item_id', None) + if self.monitor_url is None and self.item_id is None: + raise ValueError('Must provide a valid monitor_url or item_id') + if self.monitor_url is not None and self.item_id is not None: + raise ValueError('Must provide a valid monitor_url or item_id, but not both') + + if self.item_id: + self.status = 'completed' + self.completition_percentage = 100.0 + else: + self.status = 'inProgress' + self.completition_percentage = 0.0 + + def _request_status(self): + """ Checks the api enpoint to check if the async job progress """ + if self.item_id: + return True + + response = self.con.get(self.monitor_url) + if not response: + return False + + data = response.json() + + self.status = data.get('status', 'inProgress') + self.completition_percentage = data.get(self._cc('percentageComplete'), 0) + self.item_id = data.get(self._cc('resourceId'), None) + + return self.item_id is not None + + def check_status(self, delay=0): + """ + Checks the api enpoint in a loop + :param delay: number of seconds to wait between api calls. Note Connection 'requests_delay' also apply. + """ + if not self.item_id: + while not self._request_status(): + # wait until _request_status returns True + yield self.status, self.completition_percentage + if self.item_id is None: + sleep(delay) + else: + yield self.status, self.completition_percentage + + def get_item(self): + """ Returns the item copied. """ + return self.parent.get_item(self.item_id) if self.item_id is not None else None + + +class DriveItemVersion(ApiComponent, DownloadableMixin): + """ A version of a DriveItem """ + + _endpoints = { + 'download': '/versions/{id}/content', + 'restore': '/versions/{id}/restoreVersion' + } + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + + protocol = parent.protocol if parent else kwargs.get('protocol') + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + + resource_prefix = '/items/{item_id}'.format(item_id=self._parent.object_id) + main_resource = '{}{}'.format(main_resource or (protocol.default_resource if protocol else ''), resource_prefix) + super().__init__(protocol=protocol, main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.driveitem_id = self._parent.object_id + self.object_id = cloud_data.get('id', '1.0') + self.name = self.object_id + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.modified = parse(modified).astimezone(local_tz) if modified else None + self.size = cloud_data.get('size', 0) + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', None) + self.modified_by = Contact(con=self.con, protocol=self.protocol, **{self._cloud_data_key: modified_by}) if modified_by else None + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Versión Id: {} | Modified on: {} | by: {}'.format(self.name, self.modified, self.modified_by.display_name if self.modified_by else None) + + def restore(self): + """ + Restores this DriveItem Version. + You can not restore the current version (last one). + """ + url = self.build_url(self._endpoints.get('restore').format(id=self.object_id)) + + response = self.con.post(url) + + return bool(response) + + def download(self, to_path=None, name=None, chunk_size='auto', convert_to_pdf=False): + """ + Downloads this version. + You can not download the current version (last one). + """ + return super().download(to_path=to_path, name=name, chunk_size=chunk_size, convert_to_pdf=convert_to_pdf) + + +class DriveItemPermission(ApiComponent): + """ A Permission representation for a DriveItem """ + _endpoints = { + 'permission': '/items/{driveitem_id}/permissions/{id}' + } + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + protocol = parent.protocol if parent else kwargs.get('protocol') + super().__init__(protocol=protocol, main_resource=main_resource) + + self.driveitem_id = self._parent.object_id + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.object_id = cloud_data.get(self._cc('id')) + self.inherited_from = cloud_data.get(self._cc('inheritedFrom'), None) + + link = cloud_data.get(self._cc('link'), None) + self.permission_type = 'owner' + if link: + self.permission_type = 'link' + self.share_type = link.get('type', 'view') + self.share_scope = link.get('scope', 'anonymous') + self.share_link = link.get('webUrl', None) + + invitation = cloud_data.get(self._cc('invitation'), None) + if invitation: + self.permission_type = 'invitation' + self.share_email = invitation.get('email', '') + invited_by = invitation.get('invitedBy', {}) + self.invited_by = invited_by.get('user', {}).get(self._cc('displayName'), None) or invited_by.get('application', {}).get(self._cc('displayName'), None) + self.require_sign_in = invitation.get(self._cc('signInRequired'), True) + + self.roles = cloud_data.get(self._cc('roles'), []) + granted_to = cloud_data.get(self._cc('grantedTo'), {}) + self.granted_to = granted_to.get('user', {}).get(self._cc('displayName')) or granted_to.get('application', {}).get(self._cc('displayName')) + self.share_id = cloud_data.get(self._cc('shareId'), None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Permission for {} of type: {}'.format(self._parent.name, self.permission_type) + + def update_roles(self, roles='view'): + """ Updates the roles of this permission""" + + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('permission').format(driveitem_id=self.driveitem_id, id=self.object_id)) + + if roles in {'view', 'read'}: + data = {'roles': ['read']} + elif roles == {'edit', 'write'}: + data = {'roles': ['write']} + else: + raise ValueError('"{}" is not a valid share_type'.format(roles)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.roles = data.get('roles', []) + return True + + def delete(self): + """ Deletes this permission. Only permissions that are not inherited can be deleted. """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('permission').format(driveitem_id=self.driveitem_id, id=self.object_id)) + + response = self.con.delete(url) + if not response: + return False + + self.object_id = None + return True + + +class DriveItem(ApiComponent): + """ A DriveItem representation. Groups all funcionality """ + + _endpoints = { + # all prefixed with /drives/{drive_id} on main_resource by default + 'list_items': '/items/{id}/children', + 'thumbnails': '/items/{id}/thumbnails', + 'item': '/items/{id}', + 'copy': '/items/{id}/copy', + 'download': '/items/{id}/content', + 'search': "/items/{id}/search(q='{search_text}')", + 'versions': '/items/{id}/versions', + 'version': '/items/{id}/versions/{version_id}', + 'simple_upload': '/items/{id}:/{filename}:/content', + 'create_upload_session': '/items/{id}:/{filename}:/createUploadSession', + 'share_link': '/items/{id}/createLink', + 'share_invite': '/items/{id}/invite', + 'permissions': '/items/{id}/permissions', + } + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + self.drive = parent if isinstance(parent, Drive) else (parent.drive if isinstance(parent.drive, Drive) else kwargs.get('drive', None)) + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + + protocol = parent.protocol if parent else kwargs.get('protocol') + if parent and not isinstance(parent, DriveItem): + # parent is a Drive so append the drive route to the main_resource + drive_id = (None if parent.object_id == 'root' else parent.object_id) or None + + # prefix with the current known drive or the default one + resource_prefix = '/drives/{drive_id}'.format(drive_id=drive_id) if drive_id else '/drive' + main_resource = '{}{}'.format(main_resource or (protocol.default_resource if protocol else ''), resource_prefix) + + super().__init__(protocol=protocol, main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get(self._cc('id')) + self.name = cloud_data.get(self._cc('name'), '') + self.web_url = cloud_data.get(self._cc('webUrl')) + created_by = cloud_data.get(self._cc('createdBy'), {}).get('user', None) + self.created_by = Contact(con=self.con, protocol=self.protocol, **{self._cloud_data_key: created_by}) if created_by else None + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', None) + self.modified_by = Contact(con=self.con, protocol=self.protocol, **{self._cloud_data_key: modified_by}) if modified_by else None + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone(local_tz) if modified else None + + self.description = cloud_data.get(self._cc('description'), '') + self.size = cloud_data.get(self._cc('size'), 0) + self.shared = cloud_data.get(self._cc('shared'), {}).get('scope', None) + + parent_reference = cloud_data.get(self._cc('parentReference'), {}) + self.parent_id = parent_reference.get('id', None) + self.drive_id = parent_reference.get(self._cc('driveId'), None) + + remote_item = cloud_data.get(self._cc('remoteItem'), None) + self.remote_item = self._classifier(remote_item)(parent=self, **{self._cloud_data_key: remote_item}) if remote_item else None + + # Thumbnails + self.thumbnails = cloud_data.get(self._cc('thumbnails'), []) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return '{}: {}'.format(self.__class__.__name__, self.name) + + @staticmethod + def _classifier(item): + """ Subclass to change factory clases """ + if 'folder' in item: + return Folder + elif 'image' in item: + return Image + elif 'photo' in item: + return Photo + else: + return File + + @property + def is_folder(self): + """ Returns if this DriveItem is a Folder """ + return isinstance(self, Folder) + + @property + def is_file(self): + """ Returns if this DriveItem is a File """ + return isinstance(self, File) + + @property + def is_image(self): + """ Returns if this DriveItem is a Image """ + return isinstance(self, Image) + + @property + def is_photo(self): + """ Returns if this DriveItem is a Photo """ + return isinstance(self, Photo) + + def get_parent(self): + """ Returns a Drive or Folder: the parent of this DriveItem """ + if self._parent and self._parent.object_id == self.parent_id: + return self._parent + else: + if self.parent_id: + return self.drive.get_item(self.parent_id) + else: + # return the drive + return self.drive + + def get_thumbnails(self, size=None): + """ + Returns this Item Thumbnails + Thumbnails are not supported on SharePoint Server 2016. + :param size: request only the specified size: ej: "small", Custom 300x400 px: "c300x400", Crop: "c300x400_Crop" + """ + if not self.object_id: + return [] + + url = self.build_url(self._endpoints.get('thumbnails').format(id=self.object_id)) + + params = {} + if size is not None: + params['select'] = size + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + if not self.thumbnails or size is None: + self.thumbnails = data + + return data + + def update(self, **kwargs): + """ + Updates this item + :param kwargs: all the properties to be updated. only name and description are allowed at the moment. + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('item').format(id=self.object_id)) + + data = {self._cc(key): value for key, value in kwargs.items() if key in {'name', 'description'}} # convert keys to protocol casing + if not data: + return False + + response = self.con.patch(url, data=data) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value: + setattr(self, self.protocol.to_api_case(key), value) + + return True + + def delete(self): + """ Moves this item to the Recycle Bin """ + + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('item').format(id=self.object_id)) + + response = self.con.delete(url) + if not response: + return False + + self.object_id = None + + return True + + def move(self, target): + """ + Moves this DriveItem to another Folder. Can't move between different Drives. + :param target: a Folder, Drive item or Item Id string. If it's a drive the item will be moved to the root folder. + """ + + if isinstance(target, Folder): + target_id = target.object_id + elif isinstance(target, Drive): + # we need the root folder id + root_folder = target.get_root_folder() + if not root_folder: + return False + target_id = root_folder.object_id + elif isinstance(target, str): + target_id = target + else: + raise ValueError('Target must be a Folder or Drive') + + if not self.object_id or not target_id: + raise ValueError('Both self, and target must have a valid object_id.') + + if target_id == 'root': + raise ValueError("When moving, target id can't be 'root'") + + url = self.build_url(self._endpoints.get('item').format(id=self.object_id)) + + data = {'parentReference': {'id': target_id}} + + response = self.con.patch(url, data=data) + if not response: + return False + + self.parent_id = target_id + + return True + + def copy(self, target=None, name=None): + """ + Asynchronously creates a copy of this DriveItem and all it's child elements. + :param target: a Folder or Drive item. If it's a drive the item will be moved to the root folder. + :param name: a new name for the copy. + """ + assert target or name, 'Must provide a target or a name (or both)' + + if isinstance(target, Folder): + target_id = target.object_id + drive_id = target.drive_id + elif isinstance(target, Drive): + # we need the root folder + root_folder = target.get_root_folder() + if not root_folder: + return None + target_id = root_folder.object_id + drive_id = root_folder.drive_id + elif target is None: + target_id = None + drive_id = None + else: + raise ValueError('Target, if provided, must be a Folder or Drive') + + if not self.object_id: + return None + + if target_id == 'root': + raise ValueError("When copying, target id can't be 'root'") + + url = self.build_url(self._endpoints.get('copy').format(id=self.object_id)) + + if target_id and drive_id: + data = {'parentReference': {'id': target_id, 'driveId': drive_id}} + else: + data = {} + if name: + # incorporate the extension if the name provided has none. + if not Path(name).suffix and self.name: + name = name + Path(self.name).suffix + data['name'] = name + + response = self.con.post(url, data=data) + if not response: + return None + + # Find out if the server has run a Sync or Async operation + location = response.headers.get('Location', None) + + if 'monitor' in location: + # Async operation + return CopyOperation(parent=self.drive, monitor_url=location) + else: + # Sync operation. Item is ready to be retrieved + path = urlparse(location).path + item_id = path.split('/')[-1] + return CopyOperation(parent=self.drive, item_id=item_id) + + def get_versions(self): + """ Returns a list of available versions for this item """ + + if not self.object_id: + return [] + url = self.build_url(self._endpoints.get('versions').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return [DriveItemVersion(parent=self, **{self._cloud_data_key: item}) for item in data.get('value', [])] + + def get_version(self, version_id): + """ Returns a DriveItem version """ + if not self.object_id: + return None + + url = self.build_url(self._endpoints.get('version').format(id=self.object_id, version_id=version_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return DriveItemVersion(parent=self, **{self._cloud_data_key: data}) + + def share_with_link(self, share_type='view', share_scope='anonymous'): + """ + Creates or returns a link you can share with others + :param share_type: 'view' to allow only view access, 'edit' to allow editions, and 'embed' to allow the DriveItem to be embeded + :param share_scope: 'anonymoues': anyone with the link can access. 'organization' Only organization members can access.s + """ + + if not self.object_id: + return None + + url = self.build_url(self._endpoints.get('share_link').format(id=self.object_id)) + + data = { + 'type': share_type, + 'scope': share_scope + } + + response = self.con.post(url, data=data) + if not response: + return None + + data = response.json() + + # return data.get('link', {}).get('webUrl') + return DriveItemPermission(parent=self, **{self._cloud_data_key: data}) + + def share_with_invite(self, recipients, require_sign_in=True, send_email=True, message=None, share_type='view'): + """ + Sends an invitation to access or edit this DriveItem + :param recipients: a string or Contact or a list of the former representing recipients of this invitation + :param require_sign_in: if True the recipients invited will need to log in to view the contents + :param send_email: if True an email will be send to the recipients + :param message: the body text of the message emailed + :param share_type: 'view': will allow to read the contents. 'edit' will allow to modify the contents + """ + if not self.object_id: + return None + + to = [] + if recipients is None: + raise ValueError('Provide a valir to parameter') + elif isinstance(recipients, (list, tuple)): + for x in recipients: + if isinstance(x, str): + to.append({'email': x}) + elif isinstance(x, Contact): + to.append({'email': x.main_email}) + else: + raise ValueError('All the recipients must be either strings or Contacts') + elif isinstance(recipients, str): + to.append({'email': recipients}) + elif isinstance(recipients, Contact): + to.append({'email': recipients.main_email}) + else: + raise ValueError('All the recipients must be either strings or Contacts') + + url = self.build_url(self._endpoints.get('share_invite').format(id=self.object_id)) + + data = { + 'recipients': to, + self._cc('requireSignIn'): require_sign_in, + self._cc('sendInvitation'): send_email, + } + if share_type in {'view', 'read'}: + data['roles'] = ['read'] + elif share_type == {'edit', 'write'}: + data['roles'] = ['write'] + else: + raise ValueError('"{}" is not a valid share_type'.format(share_type)) + if send_email and message: + data['message'] = message + + response = self.con.post(url, data=data) + if not response: + return None + + data = response.json() + + return DriveItemPermission(parent=self, **{self._cloud_data_key: data}) + + def get_permissions(self): + """ Returns a list of DriveItemPermissions with the permissions granted for this DriveItem. """ + if not self.object_id: + return [] + + url = self.build_url(self._endpoints.get('permissions').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return [DriveItemPermission(parent=self, **{self._cloud_data_key: item}) for item in data.get('value', [])] + + +class File(DriveItem, DownloadableMixin): + """ A File """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.mime_type = cloud_data.get(self._cc('file'), {}).get(self._cc('mimeType'), None) + + +class Image(File): + """ An Image """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + image = cloud_data.get(self._cc('image'), {}) + self.height = image.get(self._cc('height'), 0) + self.width = image.get(self._cc('width'), 0) + + @property + def dimenstions(self): + return '{}x{}'.format(self.width, self.height) + + +class Photo(Image): + """ Photo Object. Inherits from Image but has more attributes """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + photo = cloud_data.get(self._cc('photo'), {}) + + taken = photo.get(self._cc('takenDateTime'), None) + local_tz = self.protocol.timezone + self.taken_datetime = parse(taken).astimezone(local_tz) if taken else None + self.camera_make = photo.get(self._cc('cameraMake'), None) + self.camera_model = photo.get(self._cc('cameraModel'), None) + self.exposure_denominator = photo.get(self._cc('exposureDenominator'), None) + self.exposure_numerator = photo.get(self._cc('exposureNumerator'), None) + self.fnumber = photo.get(self._cc('fNumber'), None) + self.focal_length = photo.get(self._cc('focalLength'), None) + self.iso = photo.get(self._cc('iso'), None) + + +class Folder(DriveItem): + """ A Folder inside a Drive """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.child_count = cloud_data.get(self._cc('folder'), {}).get(self._cc('childCount'), 0) + self.special_folder = cloud_data.get(self._cc('specialFolder'), {}).get('name', None) + + def get_items(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns all the items inside this folder """ + + url = self.build_url(self._endpoints.get('list_items').format(id=self.object_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if query.has_filters: + warnings.warn('Filters are not allowed by the Api Provider in this method') + query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + items = [self._classifier(item)(parent=self, **{self._cloud_data_key: item}) for item in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + def create_child_folder(self, name, description=None): + """ + Creates a Child Folder + :param name: the name of the new child folder + :param description: the description of the new child folder + """ + + if not self.object_id: + return None + + url = self.build_url(self._endpoints.get('list_items').format(id=self.object_id)) + + data = {'name': name, 'folder': {}} + if description: + data['description'] = description + + response = self.con.post(url, data=data) + if not response: + return None + + folder = response.json() + + return self._classifier(folder)(parent=self, **{self._cloud_data_key: folder}) + + def download_contents(self, to_folder=None): + """ + This will download each file and folder sequencially. + Caution when downloading big folder structures + :param to_folder: folder where to store the contents + """ + to_folder = to_folder or Path() + if not to_folder.exists(): + to_folder.mkdir() + + for item in self.get_items(query=self.new_query().select('id', 'size')): + if item.is_folder and item.child_count > 0: + item.download_contents(to_folder=to_folder / item.name) + else: + item.download(to_folder) + + def search(self, search_text, limit=None, *, query=None, order_by=None, batch=None): + """ + Search for DriveItems under this folder + The search API uses a search service under the covers, which requires indexing of content. + As a result, there will be some time between creation of an item and when it will appear in search results. + :param search_text: The query text used to search for items. + Values may be matched across several fields including filename, metadata, and file content. + """ + if not isinstance(search_text, str) or not search_text: + raise ValueError('Provide a valid search_text') + + url = self.build_url(self._endpoints.get('search').format(id=self.object_id, search_text=search_text)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if query.has_filters: + warnings.warn('Filters are not allowed by the Api Provider in this method') + query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + items = [self._classifier(item)(parent=self, **{self._cloud_data_key: item}) for item in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + def upload_file(self, item, chunk_size=DEFAULT_UPLOAD_CHUNK_SIZE): + """ + Uploads a file + :param item: a Path instance or string path to the item you want to upload. + :param chunk_size: Only applies if file is bigger than 4MB. Chunk size for uploads. Must be a multiple of 327.680 bytes + """ + + if item is None: + raise ValueError('Item must be a valid path to file') + item = Path(item) if not isinstance(item, Path) else item + + if not item.exists(): + raise ValueError('Item must exist') + if not item.is_file(): + raise ValueError('Item must be a file') + + file_size = item.stat().st_size + + if file_size <= UPLOAD_SIZE_LIMIT_SIMPLE: + # Simple Upload + url = self.build_url(self._endpoints.get('simple_upload').format(id=self.object_id, filename=item.name)) + # headers = {'Content-type': 'text/plain'} + headers = {'Content-type': 'application/octet-stream'} + # headers = None + with item.open(mode='rb') as file: + data = file.read() + + response = self.con.put(url, headers=headers, data=data) + if not response: + return None + + data = response.json() + + return self._classifier(data)(parent=self, **{self._cloud_data_key: data}) + else: + # Resumable Upload + url = self.build_url(self._endpoints.get('create_upload_session').format(id=self.object_id, filename=item.name)) + + response = self.con.post(url) + if not response: + return None + + data = response.json() + + upload_url = data.get(self._cc('uploadUrl'), None) + if upload_url is None: + log.error('Create upload session response without upload_url for file {}'.format(item.name)) + return None + + current_bytes = 0 + with item.open(mode='rb') as file: + while True: + data = file.read(chunk_size) + if not data: + break + transfer_bytes = len(data) + headers = { + 'Content-type': 'application/octet-stream', + 'Content-Length': str(len(data)), + 'Content-Range': 'bytes {}-{}/{}'.format(current_bytes, current_bytes + transfer_bytes - 1, file_size) + } + current_bytes += transfer_bytes + + # this request mut NOT send the authorization header. so we use a naive simple request. + response = self.con.naive_request(upload_url, 'PUT', data=data, headers=headers) + if not response: + return None + + if response.status_code != 202: + # file is completed + data = response.json() + return self._classifier(data)(parent=self, **{self._cloud_data_key: data}) + + +class Drive(ApiComponent): + """ A Drive representation. A Drive is a Container of Folders and Files and act as a root item """ + + _endpoints = { + 'default_drive': '/drive', + 'get_drive': '/drives/{id}', + 'get_root_item_default': '/drive/root', + 'get_root_item': '/drives/{id}/root', + 'list_items_default': '/drive/root/children', + 'list_items': '/drives/{id}/root/children', + 'get_item_default': '/drive/items/{item_id}', + 'get_item': '/drives/{id}/items/{item_id}', + 'recent_default': '/drive/recent', + 'recent': '/drives/{id}/recent', + 'shared_with_me_default': '/drive/sharedWithMe', + 'shared_with_me': '/drives/{id}/sharedWithMe', + 'get_special_default': '/drive/special/{name}', + 'get_special': '/drives/{id}/special/{name}', + 'search_default': "/drive/search(q='{search_text}')", + 'search': "/drives/{id}/search(q='{search_text}')", + } + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + self.parent = parent if isinstance(parent, Drive) else None + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + self._update_data(kwargs) + + def _update_data(self, data): + cloud_data = data.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get(self._cc('id')) + self.name = cloud_data.get(self._cc('name'), data.get('name', '')) # Fallback to manual drive + self.description = cloud_data.get(self._cc('description')) + self.drive_type = cloud_data.get(self._cc('driveType')) + self.web_url = cloud_data.get(self._cc('webUrl')) + + owner = cloud_data.get(self._cc('owner'), {}).get('user', None) + self.owner = Contact(con=self.con, protocol=self.protocol, **{self._cloud_data_key: owner}) if owner else None + self.quota = cloud_data.get(self._cc('quota')) # dict + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone(local_tz) if modified else None + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Drive: {}'.format(self.name or self.object_id) + + def get_root_folder(self): + """ Returns the Root Folder of this drive """ + if self.object_id: + # reference the current drive_id + url = self.build_url(self._endpoints.get('get_root_item').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url(self._endpoints.get('get_root_item_default')) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self._classifier(data)(parent=self, **{self._cloud_data_key: data}) + + def _base_get_list(self, url, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of drive items """ + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if query.has_filters: + warnings.warn('Filters are not allowed by the Api Provider in this method') + query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + items = [self._classifier(item)(parent=self, **{self._cloud_data_key: item}) for item in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + def get_items(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of drive items from the root folder """ + + if self.object_id: + # reference the current drive_id + url = self.build_url(self._endpoints.get('list_items').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('list_items_default')) + + return self._base_get_list(url, limit=limit, query=query, order_by=order_by, batch=batch) + + def get_recent(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of recently used DriveItems """ + if self.object_id: + # reference the current drive_id + url = self.build_url(self._endpoints.get('recent').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('recent_default')) + + return self._base_get_list(url, limit=limit, query=query, order_by=order_by, batch=batch) + + def get_shared_with_me(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of DriveItems shared with me """ + + if self.object_id: + # reference the current drive_id + url = self.build_url(self._endpoints.get('shared_with_me').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('shared_with_me_default')) + + return self._base_get_list(url, limit=limit, query=query, order_by=order_by, batch=batch) + + def get_item(self, item_id): + """ Returns a DriveItem by it's Id""" + if self.object_id: + # reference the current drive_id + url = self.build_url(self._endpoints.get('get_item').format(id=self.object_id, item_id=item_id)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url(self._endpoints.get('get_item_default').format(item_id=item_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self._classifier(data)(parent=self, **{self._cloud_data_key: data}) + + def get_special_folder(self, name): + """ Returns the specified Special Folder """ + + name = name if isinstance(name, OneDriveWellKnowFolderNames) else OneDriveWellKnowFolderNames(name) + + if self.object_id: + # reference the current drive_id + url = self.build_url(self._endpoints.get('get_special').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('get_special_default')) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self._classifier(data)(parent=self, **{self._cloud_data_key: data}) + + @staticmethod + def _classifier(item): + """ Subclass to change factory clases """ + if 'folder' in item: + return Folder + elif 'image' in item: + return Image + elif 'photo' in item: + return Photo + else: + return File + + def refresh(self): + """ Updates this drive with data from the server """ + + if self.object_id is None: + url = self.build_url(self._endpoints.get('default_drive')) + else: + url = self.build_url(self._endpoints.get('get_drive').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return False + + drive = response.json() + + self._update_data({self._cloud_data_key: drive}) + return True + + def search(self, search_text, limit=None, *, query=None, order_by=None, batch=None): + """ + Search for DriveItems under this drive. + Your app can search more broadly to include items shared with the current user. + To broaden the search scope, use this seach instead the Folder Search. + The search API uses a search service under the covers, which requires indexing of content. + As a result, there will be some time between creation of an item and when it will appear in search results. + :param search_text: The query text used to search for items. + Values may be matched across several fields including filename, metadata, and file content. + """ + if not isinstance(search_text, str) or not search_text: + raise ValueError('Provide a valid search_text') + + if self.object_id is None: + url = self.build_url(self._endpoints.get('search_default').format(search_text=search_text)) + else: + url = self.build_url(self._endpoints.get('search').format(id=self.object_id, search_text=search_text)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if query.has_filters: + warnings.warn('Filters are not allowed by the Api Provider in this method') + query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + items = [self._classifier(item)(parent=self, **{self._cloud_data_key: item}) for item in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + +class Storage(ApiComponent): + """ Parent Class that holds drives """ + + _endpoints = { + 'default_drive': '/drive', + 'get_drive': '/drives/{id}', + 'list_drives': '/drives', + } + drive_constructor = Drive + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.name = cloud_data.get(self._cc('name'), kwargs.get('name', '')) # Fallback to manual drive + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Storage: {}'.format(self.name) + + def get_default_drive(self, request_drive=False): + """ + Returns a Drive instance + :param request_drive: True will make an api call to retrieve the drive data + """ + if request_drive is False: + return Drive(con=self.con, protocol=self.protocol, main_resource=self.main_resource, name=self.name) + + url = self.build_url(self._endpoints.get('default_drive')) + + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.drive_constructor(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, **{self._cloud_data_key: drive}) + + def get_drive(self, drive_id): + """ + Returns a Drive instance + :param drive_id: the drive_id to be retrieved. + """ + if not drive_id: + return None + + url = self.build_url(self._endpoints.get('get_drive').format(id=drive_id)) + + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.drive_constructor(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, **{self._cloud_data_key: drive}) + + def get_drives(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of drives """ + + url = self.build_url(self._endpoints.get('list_drives')) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + drives = [self.drive_constructor(parent=self, **{self._cloud_data_key: drive}) for drive in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=drives, constructor=self.drive_constructor, + next_link=next_link, limit=limit) + else: + return drives diff --git a/O365/event.py b/O365/event.py deleted file mode 100644 index c475aa44603ef..0000000000000 --- a/O365/event.py +++ /dev/null @@ -1,381 +0,0 @@ -from O365.contact import Contact -from O365.group import Group -import logging -import json -import requests -import time - -log = logging.getLogger(__name__) - -class Event( object ): - ''' - Class for managing the creation and manipluation of events in a calendar. - - Methods: - create -- Creates the event in a calendar. - update -- Sends local changes up to the cloud. - delete -- Deletes event from the cloud. - toJson -- returns the json representation. - fullcalendarioJson -- gets a specific json representation used for fullcalendario. - getSubject -- gets the subject of the event. - getBody -- gets the body of the event. - getStart -- gets the starting time of the event. (struct_time) - getEnd -- gets the ending time of the event. (struct_time) - getAttendees -- gets the attendees of the event. - addAttendee -- adds an attendee to the event. update needs to be called for notification. - setSubject -- sets the subject line of the event. - setBody -- sets the body of the event. - setStart -- sets the starting time of the event. (struct_time) - setEnd -- sets the starting time of the event. (struct_time) - setAttendees -- sets the attendee list. - setStartTimeZone -- sets the timezone for the start of the event item. - setEndTimeZone -- sets the timezone for the end of the event item. - - Variables: - time_string -- Formated time string for translation to and from json. - create_url -- url for creating a new event. - update_url -- url for updating an existing event. - delete_url -- url for deleting an event. - ''' - #Formated time string for translation to and from json. - time_string = '%Y-%m-%dT%H:%M:%SZ' - #takes a calendar ID - create_url = 'https://outlook.office365.com/api/v1.0/me/calendars/{0}/events' - #takes current event ID - update_url = 'https://outlook.office365.com/api/v1.0/me/events/{0}' - #takes current event ID - delete_url = 'https://outlook.office365.com/api/v1.0/me/events/{0}' - - - def __init__(self,json=None,auth=None,cal=None,verify=True): - ''' - Creates a new event wrapper. - - Keyword Argument: - json (default = None) -- json representation of an existing event. mostly just used by - this library internally for events that are downloaded by the callendar class. - auth (default = None) -- a (email,password) tuple which will be used for authentication - to office365. - cal (default = None) -- an instance of the calendar for this event to associate with. - ''' - self.auth = auth - self.calendar = cal - self.attendees = [] - - if json: - self.json = json - self.isNew = False - else: - self.json = {} - - self.verify = verify - - - def create(self,calendar=None): - ''' - this method creates an event on the calender passed. - - IMPORTANT: It returns that event now created in the calendar, if you wish - to make any changes to this event after you make it, use the returned value - and not this particular event any further. - - calendar -- a calendar class onto which you want this event to be created. If this is left - empty then the event's default calendar, specified at instancing, will be used. If no - default is specified, then the event cannot be created. - - ''' - if not self.auth: - log.debug('failed authentication check when creating event.') - return False - - if calendar: - calId = calendar.calendarId - self.calendar = calendar - log.debug('sent to passed calendar.') - elif self.calendar: - calId = self.calendar.calendarId - log.debug('sent to default calendar.') - else: - log.debug('no valid calendar to upload to.') - return False - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - log.debug('creating json for request.') - data = json.dumps(self.json) - - response = None - try: - log.debug('sending post request now') - response = requests.post(self.create_url.format(calId),data,headers=headers,auth=self.auth,verify=self.verify) - log.debug('sent post request.') - if response.status_code > 399: - log.error("Invalid response code [{}], response text: \n{}".format(response.status_code, response.text)) - return False - except Exception as e: - if response: - log.debug('response to event creation: %s',str(response)) - else: - log.error('No response, something is very wrong with create: %s',str(e)) - return False - - log.debug('response to event creation: %s',str(response)) - return Event(response.json(),self.auth,calendar) - - def update(self): - '''Updates an event that already exists in a calendar.''' - if not self.auth: - return False - - if self.calendar: - calId = self.calendar.calendarId - else: - return False - - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = None - try: - response = requests.patch(self.update_url.format(self.json['Id']),data,headers=headers,auth=self.auth,verify=self.verify) - log.debug('sending patch request now') - except Exception as e: - if response: - log.debug('response to event creation: %s',str(response)) - else: - log.error('No response, something is very wrong with update: %s',str(e)) - return False - - log.debug('response to event creation: %s',str(response)) - - return Event(response.json(),self.auth) - - - def delete(self): - ''' - Delete's an event from the calendar it is in. - - But leaves you this handle. You could then change the calendar and transfer the event to - that new calendar. You know, if that's your thing. - ''' - if not self.auth: - return False - - headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} - - response = None - try: - log.debug('sending delete request') - response = requests.delete(self.delete_url.format(self.json['Id']),headers=headers,auth=self.auth,verify=self.verify) - - except Exception as e: - if response: - log.debug('response to deletion: %s',str(response)) - else: - log.error('No response, something is very wrong with delete: %s',str(e)) - return False - - return response - - def toJson(self): - ''' - Creates a JSON representation of the calendar event. - - oh. uh. I mean it simply returns the json representation that has always been in self.json. - ''' - return self.json - - def fullcalendarioJson(self): - ''' - returns a form of the event suitable for the vehicle booking system here. - oh the joys of having a library to yourself! - ''' - ret = {} - ret['title'] = self.json['Subject'] - ret['driver'] = self.json['Organizer']['EmailAddress']['Name'] - ret['driverEmail'] = self.json['Organizer']['EmailAddress']['Address'] - ret['start'] = self.json['Start'] - ret['end'] = self.json['End'] - ret['IsAllDay'] = self.json['IsAllDay'] - return ret - - def getSubject(self): - '''Gets event subject line.''' - return self.json['Subject'] - - def getBody(self): - '''Gets event body content.''' - return self.json['Body']['Content'] - - def getStart(self): - '''Gets event start struct_time''' - return time.strptime(self.json['Start'], self.time_string) - - def getEnd(self): - '''Gets event end struct_time''' - return time.strptime(self.json['End'], self.time_string) - - def getAttendees(self): - '''Gets list of event attendees.''' - return self.json['Attendees'] - - def setSubject(self,val): - '''sets event subject line.''' - self.json['Subject'] = val - - def setBody(self,val,contentType='Text'): - ''' - sets event body content: - Examples for ContentType could be 'Text' or 'HTML' - ''' - cont = False - - while not cont: - try: - self.json['Body']['Content'] = val - self.json['Body']['ContentType'] = contentType - cont = True - except: - self.json['Body'] = {} - - def setStart(self,val): - ''' - sets event start time. - - Argument: - val - this argument can be passed in three different ways. You can pass it in as a int - or float, in which case the assumption is that it's seconds since Unix Epoch. You can - pass it in as a struct_time. Or you can pass in a string. The string must be formated - in the json style, which is %Y-%m-%dT%H:%M:%SZ. If you stray from that in your string - you will break the library. - ''' - if isinstance(val,time.struct_time): - self.json['Start'] = time.strftime(self.time_string,val) - elif isinstance(val,int): - self.json['Start'] = time.strftime(self.time_string,time.gmtime(val)) - elif isinstance(val,float): - self.json['Start'] = time.strftime(self.time_string,time.gmtime(val)) - else: - #this last one assumes you know how to format the time string. if it brakes, check - #your time string! - self.json['Start'] = val - - def setEnd(self,val): - ''' - sets event end time. - - Argument: - val - this argument can be passed in three different ways. You can pass it in as a int - or float, in which case the assumption is that it's seconds since Unix Epoch. You can - pass it in as a struct_time. Or you can pass in a string. The string must be formated - in the json style, which is %Y-%m-%dT%H:%M:%SZ. If you stray from that in your string - you will break the library. - ''' - if isinstance(val,time.struct_time): - self.json['End'] = time.strftime(self.time_string,val) - elif isinstance(val,int): - self.json['End'] = time.strftime(self.time_string,time.gmtime(val)) - elif isinstance(val,float): - self.json['End'] = time.strftime(self.time_string,time.gmtime(val)) - else: - #this last one assumes you know how to format the time string. if it brakes, check - #your time string! - self.json['End'] = val - - def setAttendees(self,val): - ''' - set the attendee list. - - val: the one argument this method takes can be very flexible. you can send: - a dictionary: this must to be a dictionary formated as such: - {"EmailAddress":{"Address":"recipient@example.com"}} - with other options such ass "Name" with address. but at minimum it must have this. - a list: this must to be a list of libraries formatted the way specified above, - or it can be a list of libraries objects of type Contact. The method will sort - out the libraries from the contacts. - a string: this is if you just want to throw an email address. - a contact: type Contact from this library. - For each of these argument types the appropriate action will be taken to fit them to the - needs of the library. - ''' - self.json['Attendees'] = [] - if isinstance(val,list): - self.json['Attendees'] = val - elif isinstance(val,dict): - self.json['Attendees'] = [val] - elif isinstance(val,str): - if '@' in val: - self.addAttendee(val) - elif isinstance(val,Contact): - self.addAttendee(val) - elif isinstance(val,Group): - self.addAttendee(val) - else: - return False - return True - - def setStartTimeZone(self,val): - '''sets event start timezone''' - self.json['StartTimeZone'] = val - - def setEndTimeZone(self,val): - '''sets event end timezone''' - self.json['EndTimeZone'] = val - - def addAttendee(self,address,name=None): - ''' - Adds a recipient to the attendee list. - - Arguments: - address -- the email address of the person you are sending to. <<< Important that. - Address can also be of type Contact or type Group. - name -- the name of the person you are sending to. mostly just a decorator. If you - send an email address for the address arg, this will give you the ability - to set the name properly, other wise it uses the email address up to the - at sign for the name. But if you send a type Contact or type Group, this - argument is completely ignored. - ''' - if isinstance(address,Contact): - self.json['Attendees'].append(address.getFirstEmailAddress()) - elif isinstance(address,Group): - for con in address.contacts: - self.json['Attendees'].append(address.getFirstEmailAddress()) - else: - if name is None: - name = address[:address.index('@')] - self.json['Attendees'].append({'EmailAddress':{'Address':address,'Name':name}}) - - def setLocation(self,loc): - ''' - Sets the event's location. - - Arguments: - loc -- two options, you can send a dictionary in the format discribed here: - https://msdn.microsoft.com/en-us/office/office365/api/complex-types-for-mail-contacts-calendar#LocationBeta - this will allow you to set address, coordinates, displayname, location email - address, location uri, or any combination of the above. If you don't need that much - detail you can simply send a string and it will be set as the locations display - name. If you send something not a string or a dict, it will try to cast whatever - you send into a string and set that as the display name. - ''' - if 'Location' not in self.json: - self.json['Location'] = {"Address":None} - - if isinstance(loc,dict): - self.json['Location'] = loc - else: - self.json['Location']['DisplayName'] = str(loc) - - def getLocation(self): - ''' - Get the current location, if one is set. - ''' - if 'Location' in self.json: - return self.json['Location'] - return None - - - -#To the King! diff --git a/O365/fluent_inbox.py b/O365/fluent_inbox.py deleted file mode 100644 index 55ff0bb78f8e2..0000000000000 --- a/O365/fluent_inbox.py +++ /dev/null @@ -1,240 +0,0 @@ -import logging - -from O365.connection import Connection -from O365.fluent_message import Message - -log = logging.getLogger(__name__) - - -class FluentInbox(object): - url_dict = { - 'inbox': { - '1.0': 'https://outlook.office365.com/api/v1.0/me/messages', - '2.0': 'https://graph.microsoft.com/v1.0/me/messages', - }, - 'folders': { - '1.0': 'https://outlook.office365.com/api/v1.0/me/Folders', - '2.0': 'https://graph.microsoft.com/v1.0/me/MailFolders', - }, - 'folder': { - '1.0': 'https://outlook.office365.com/api/v1.0/me/Folders/{folder_id}/messages', - '2.0': 'https://graph.microsoft.com/v1.0/me/MailFolders/{folder_id}/messages', - }, - 'child_folders': { - '1.0': 'https://outlook.office365.com/api/v1.0/me/Folders/{folder_id}/childfolders', - '2.0': 'https://graph.microsoft.com/v1.0/me/MailFolders/{folder_id}/childfolders', - }, - 'user_folders': { - '1.0': 'https://outlook.office365.com/api/v1.0/users/{user_id}/Folders', - '2.0': 'https://graph.microsoft.com/v1.0/users/{user_id}/MailFolders', - }, - 'user_folder': { - '1.0': 'https://outlook.office365.com/api/v1.0/users/{user_id}/Folders/{folder_id}/messages', - '2.0': 'https://graph.microsoft.com/v1.0/users/{user_id}/MailFolders/{folder_id}/messages', - }, - 'user_child_folders': { - '1.0': 'https://outlook.office365.com/api/v1.0/users/{user_id}/Folders/{folder_id}/childfolders', - '2.0': 'https://graph.microsoft.com/v1.0/users/{user_id}/MailFolders/{folder_id}/childfolders', - } - } - - def __init__(self, verify=True): - """ Creates a new inbox wrapper. - - :param verify: whether or not to verify SSL certificate - """ - self.url = FluentInbox._get_url('inbox') - self.folder = None - self.fetched_count = 0 - self._filter = '' - self._search = '' - self.verify = verify - self.messages = [] - - def from_folder(self, folder_name, parent_id=None, user_id=None): - """ Configure to use this folder for fetching the mails - - :param folder_name: name of the outlook folder - :param user_id: user id the folder belongs to (shared mailboxes) - """ - self._reset() - - folder_id = self.get_folder(value=folder_name, - by='DisplayName', - parent_id=parent_id, - user_id=user_id)['Id'] - - if user_id: - self.url = FluentInbox._get_url('user_folder').format( - user_id=user_id, folder_id=folder_id) - else: - self.url = FluentInbox._get_url('folder').format( - folder_id=folder_id) - - return self - - def get_folder(self, value, by='Id', parent_id=None, user_id=None): - """ - Return a folder by a given attribute. If multiple folders exist by - this attribute, only the first will be returned - - Example: - get_folder(by='DisplayName', value='Inbox') - - or - - get_folder(by='Id', value='AAKrWFG...') - - Would both return the requested folder attributes - - :param value: Value that we are searching for - :param by: Search on this key (default: Id) - :param user_id: user id the folder belongs to (shared mailboxes) - :returns: Single folder data - """ - if parent_id and user_id: - folders_url = FluentInbox._get_url('user_child_folders').format( - folder_id=parent_id, user_id=user_id) - elif parent_id: - folders_url = FluentInbox._get_url('child_folders').format( - folder_id=parent_id) - elif user_id: - folders_url = FluentInbox._get_url('user_folders').format( - user_id=user_id) - else: - folders_url = FluentInbox._get_url('folders') - - response = Connection.get_response(folders_url, - verify=self.verify, - params={'$top': 100}) - - folder_id = None - all_folders = [] - - for folder in response: - if folder[by] == value: - self.folder = folder - return(folder) - - all_folders.append(folder['displayName']) - - if not folder_id: - raise RuntimeError( - 'Folder "{}" is not found by "{}", available folders ' - 'are {}'.format(value, by, all_folders)) - - def list_folders(self, parent_id=None, user_id=None): - """ - :param parent_id: Id of parent folder to list. Default to top folder - :return: List of all folder data - """ - if parent_id and user_id: - folders_url = FluentInbox._get_url('user_child_folders').format( - folder_id=parent_id, user_id=user_id) - elif parent_id: - folders_url = FluentInbox._get_url('child_folders').format( - folder_id=parent_id) - elif user_id: - folders_url = FluentInbox._get_url('user_folders').format( - user_id=user_id) - else: - folders_url = FluentInbox._get_url('folders') - - response = Connection.get_response(folders_url, - verify=self.verify, - params={'$top': 100}) - - folders = [] - for folder in response: - folders.append(folder) - - return folders - - def filter(self, filter_string): - """ Set the value of a filter. More information on what filters are available can be found here: - https://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#RESTAPIResourcesMessage - More improvements coming soon - - :param filter_string: The string that represents the filters you want to enact. - should be something like: (HasAttachments eq true) and (IsRead eq false) or just: IsRead eq false - test your filter string here: https://outlook.office365.com/api/v1.0/me/messages?$filter= - if that accepts it then you know it works. - """ - self._filter = filter_string - return self - - def search(self, search_string): - """ Set the value of a search. More information on what searches are available can be found here: - https://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#RESTAPIResourcesMessage - More improvements coming soon - - :param search_string: The search string you want to use - - Should be something like: "Category:Action AND Subject:Test" or just: "Subject:Test". - - Test your search string here: "https://outlook.office365.com/api/v1.0/me/messages?$search=" - or directly in your mailbox, if that accepts it then you know it works. - """ - self._search = search_string - return self - - def fetch_first(self, count=10): - """ Fetch the first n messages, where n is the specified count - - :param count: no.of messages to fetch - """ - self.fetched_count = 0 - return self.fetch_next(count=count) - - def skip(self, count): - """ Skips the first n messages, where n is the specified count - - :param count: no.of messages to skip - """ - self.fetched_count = count - return self - - def fetch(self, count=10): - """ Fetch n messages from the result, where n is the specified count - - :param count: no.of messages to fetch - """ - return self.fetch_next(count=count) - - def fetch_next(self, count=1): - """ Fetch the next n messages after the previous fetch, where n is the specified count - - :param count: no.of messages to fetch - """ - skip_count = self.fetched_count - if self._search: - params = {'$filter': self._filter, '$top': count, - '$search': '"{}"'.format(self._search)} - else: - params = {'$filter': self._filter, '$top': count, - '$skip': skip_count} - - response = Connection.get_response(self.url, verify=self.verify, - params=params) - self.fetched_count += count - - connection = Connection() - messages = [] - for message in response: - messages.append(Message(message, connection.auth, oauth=connection.oauth)) - - return messages - - @staticmethod - def _get_url(key): - """ Fetches the url for specified key as per the connection version configured - - :param key: the key for which url is required - :return: URL to use for requests - """ - return FluentInbox.url_dict[key][Connection().api_version] - - def _reset(self): - """ Resets the current reference """ - self.fetched_count = 0 - self.messages = [] diff --git a/O365/fluent_message.py b/O365/fluent_message.py deleted file mode 100644 index 55f22799516ac..0000000000000 --- a/O365/fluent_message.py +++ /dev/null @@ -1,315 +0,0 @@ -from O365.attachment import Attachment -from O365.contact import Contact -from O365.group import Group -import logging -import json -import requests - -log = logging.getLogger(__name__) - - -class Message(object): - ''' - Management of the process of sending, receiving, reading, and editing emails. - - Note: the get and set methods are technically superflous. You can get more through control over - a message you are trying to craft throught he use of editing the message.json, but these - methods provide an easy way if you don't need all the power and would like the ease. - - Methods: - constructor -- creates a new message class, using json for existing, nothing for new. - fetchAttachments -- kicks off the process that downloads attachments. - sendMessage -- take local variables and form them to send the message. - markAsRead -- marks the analougs message in the cloud as read. - getSender -- gets a dictionary with the sender's information. - getSenderEmail -- gets the email address of the sender. - getSenderName -- gets the name of the sender, if possible. - getSubject -- gets the email's subject line. - getBody -- gets contents of the body of the email. - addRecipient -- adds a person to the recipient list. - setRecipients -- sets the list of recipients. - setSubject -- sets the subject line. - setBody -- sets the body. - setCategory -- sets the email's category - - Variables: - att_url -- url for requestiong attachments. takes message GUID - send_url -- url for sending an email - update_url -- url for updating an email already existing in the cloud. - - ''' - - att_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}/attachments' - send_url = 'https://outlook.office365.com/api/v1.0/me/sendmail' - send_as_url = 'https://outlook.office365.com/api/v1.0/users/{user_id}/sendmail' - draft_url = 'https://outlook.office365.com/api/v1.0/me/folders/{folder_id}/messages' - update_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}' - - def __init__(self, json=None, auth=None, verify=True, oauth=None): - ''' - Makes a new message wrapper for sending and receiving messages. - - Keyword Arguments: - json (default = None) -- Takes json if you have a pre-existing message to create from. - this is mostly used inside the library for when new messages are downloaded. - auth (default = None) -- Takes an (email,password) tuple that will be used for - authentication with office365. - ''' - if json: - self.json = json - self.hasAttachments = json['HasAttachments'] - - else: - self.json = {'Message': {'Body': {}}, - 'ToRecipients': [], 'CcRecipients': [], 'BccRecipients': []} - self.hasAttachments = False - - self.auth = auth - self.attachments = [] - self.receiver = None - self.verify = verify - self.oauth = oauth - - # Update to 2.0 versions - if self.oauth: - self.att_url = self.att_url.replace('https://outlook.office365.com/api', 'https://graph.microsoft.com') - self.send_url = self.send_url.replace('https://outlook.office365.com/api', 'https://graph.microsoft.com') - self.send_as_url = self.send_as_url.replace('https://outlook.office365.com/api', 'https://graph.microsoft.com') - self.draft_url = self.draft_url.replace('https://outlook.office365.com/api', 'https://graph.microsoft.com').replace( - 'folders', 'MailFolders', 1) - self.update_url = self.update_url.replace('https://outlook.office365.com/api', 'https://graph.microsoft.com') - - def fetchAttachments(self,**kwargs): - '''kicks off the process that downloads attachments locally.''' - if not self.hasAttachments: - log.debug('message has no attachments, skipping out early.') - return False - - response = (self.oauth, requests)[self.oauth is None].get(self.att_url.format( - self.json['Id']), auth=self.auth, verify=self.verify, **kwargs) - log.info('response from O365 for retriving message attachments: %s', str(response)) - json = response.json() - - for att in json['value']: - try: - self.attachments.append(Attachment(att)) - log.debug('successfully downloaded attachment for: %s.', self.auth[0]) - except Exception as e: - log.info('failed to download attachment for: %s', self.auth[0]) - - return len(self.attachments) - - def sendMessage(self, user_id=None, **kwargs): - ''' - Takes local variabls and forms them into a message to be sent. - - :param user_id: User id (email) if sending as other user - ''' - - headers = {'Content-Type': 'application/json', 'Accept': 'text/plain'} - - try: - data = {'Message': {'Body': {}}} - data['Message']['Subject'] = self.json['Subject'] - data['Message']['Body']['Content'] = self.json['Body']['Content'] - data['Message']['Body']['ContentType'] = self.json['Body']['ContentType'] - data['Message']['ToRecipients'] = self.json['ToRecipients'] - data['Message']['CcRecipients'] = self.json['CcRecipients'] - data['Message']['BccRecipients'] = self.json['BccRecipients'] - data['Message']['Attachments'] = [att.json for att in self.attachments] - data = json.dumps(data) - except Exception as e: - log.error( - 'Error while trying to compile the json string to send: {0}'.format(str(e))) - return False - - if user_id: - url = self.send_as_url.format(user_id=user_id) - else: - url = self.send_url - response = (self.oauth, requests)[self.oauth is None].post( - url, data, headers=headers, auth=self.auth, verify=self.verify, **kwargs) - log.debug('response from server for sending message:' + str(response)) - log.debug("respnse body: {}".format(response.text)) - if response.status_code != 202: - return False - - return True - - def markAsRead(self): - '''marks analogous message as read in the cloud.''' - read = '{"IsRead":true}' - headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} - try: - response = (self.oauth, requests)[self.oauth is None].patch(self.update_url.format( - self.json['Id']), read, headers=headers, auth=self.auth, verify=self.verify) - except: - return False - return response.ok - - def moveToFolder(self, folder_id): - """ - Move the message to a given folder - - :param folder_id: Folder ID to move this message to - :returns: True on success - """ - move_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}/move' - headers = {'Content-Type': 'application/json', - 'Accept': 'application/json'} - post_data = {"DestinationId": folder_id} - try: - response = (self.oauth, requests)[self.oauth is None].post(move_url.format(self.json['Id']), - json=post_data, headers=headers, - auth=self.auth, verify=self.verify) - except: - return False - - return response.ok - - def getSender(self): - '''get all available information for the sender of the email.''' - return self.json['Sender'] - - def getSenderEmail(self): - '''get the email address of the sender.''' - return self.json['Sender']['EmailAddress']['Address'] - - def getSenderName(self): - '''try to get the name of the sender.''' - try: - return self.json['Sender']['EmailAddress']['Name'] - except: - return '' - - def getSubject(self): - '''get email subject line.''' - return self.json['Subject'] - - def getBody(self): - '''get email body.''' - try: - return self.json['Body']['Content'] - except KeyError as e: - log.debug("Fluent inbox getBody: No body content.") - return "" - - def setRecipients(self, val, r_type="To"): - ''' - set the recipient list. - - val: the one argument this method takes can be very flexible. you can send: - a dictionary: this must to be a dictionary formated as such: - {"EmailAddress":{"Address":"recipient@example.com"}} - with other options such ass "Name" with address. but at minimum - it must have this. - a list: this must to be a list of libraries formatted the way - specified above, or it can be a list of dictionary objects of - type Contact or it can be an email address as string. The - method will sort out the libraries from the contacts. - a string: this is if you just want to throw an email address. - a contact: type Contact from this dictionary. - a group: type Group, which is a list of contacts. - For each of these argument types the appropriate action will be taken - to fit them to the needs of the library. - ''' - log.debug("Entered SET_RECIPIENTS function with type: {}".format(r_type)) - self.json[r_type + 'Recipients'] = [] - - if isinstance(val, list): - for con in val: - if isinstance(con, Contact): - self.addRecipient(con, r_type=r_type) - elif isinstance(con, str): - if '@' in con: - self.addRecipient(con, r_type=r_type) - elif isinstance(con, dict): - self.json[r_type + 'Recipients'].append(con) - elif isinstance(val, dict): - self.json[r_type + 'Recipients'] = [val] - elif isinstance(val, str): - if '@' in val: - self.addRecipient(val, r_type=r_type) - elif isinstance(val, Contact): - self.addRecipient(val, r_type=r_type) - elif isinstance(val, Group): - for person in val: - self.addRecipient(person, r_type=r_type) - else: - return False - return True - - def addRecipient(self, address, name=None, r_type="To"): - ''' - Adds a recipient to the recipients list. - - Arguments: - address -- the email address of the person you are sending to. <<< Important that. - Address can also be of type Contact or type Group. - name -- the name of the person you are sending to. mostly just a decorator. If you - send an email address for the address arg, this will give you the ability - to set the name properly, other wise it uses the email address up to the - at sign for the name. But if you send a type Contact or type Group, this - argument is completely ignored. - ''' - if isinstance(address, Contact): - self.json[r_type + 'Recipients'].append(address.getFirstEmailAddress()) - elif isinstance(address, Group): - for con in address.contacts: - self.json[r_type + 'Recipients'].append(address.getFirstEmailAddress()) - else: - if name is None: - name = address[:address.index('@')] - self.json[r_type + 'Recipients'].append( - {'EmailAddress': {'Address': address, 'Name': name}}) - - def setSubject(self, val): - '''Sets the subect line of the email.''' - self.json['Subject'] = val - - def setBody(self, val): - '''Sets the body content of the email.''' - cont = False - - while not cont: - try: - self.json['Body']['Content'] = val - self.json['Body']['ContentType'] = 'Text' - cont = True - except: - self.json['Body'] = {} - - def setBodyHTML(self, val=None): - ''' - Sets the body content type to HTML for your pretty emails. - - arguments: - val -- Default: None. The content of the body you want set. If you don't pass a - value it is just ignored. - ''' - cont = False - - while not cont: - try: - self.json['Body']['ContentType'] = 'HTML' - if val: - self.json['Body']['Content'] = val - cont = True - except: - self.json['Body'] = {} - - def setCategory(self, category_name, **kwargs): - "Sets the email's category" - self.update_category(self, category_name, **kwargs) - - def update_category(self, category_name, **kwargs): - category = '{{"Categories":["{}"]}}'.format(category_name) - headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} - try: - response = (self.oauth, requests)[self.oauth is None].patch(self.update_url.format( - self.json['Id']), category, headers=headers, auth=self.auth, verify=self.verify, **kwargs) - except: - return False - return response.ok - -# To the King! diff --git a/O365/group.py b/O365/group.py deleted file mode 100644 index 9a4a5974826f6..0000000000000 --- a/O365/group.py +++ /dev/null @@ -1,75 +0,0 @@ -from O365.contact import Contact -import logging -import json -import requests - -log = logging.getLogger(__name__) - -class Group( object ): - ''' - A wrapper class that handles all the contacts associated with a single Office365 account. - - Methods: - constructor -- takes your email and password for authentication. - getContacts -- begins the actual process of downloading contacts. - - Variables: - con_url -- the url that is requested for the retrival of the contacts. - con_folder_url -- the url that is used for requesting contacts from a specific folder. - folder_url -- the url that is used for finding folder Id's from folder names. - ''' - con_url = 'https://outlook.office365.com/api/v1.0/me/contacts' - con_folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders/{0}/contacts' - folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders?$filter=DisplayName eq \'{0}\'' - - def __init__(self, auth, folderName=None,verify=True): - ''' - Creates a group class for managing all contacts associated with email+password. - - Optional: folderName -- send the name of a contacts folder and the search will limit - it'self to only those which are in that folder. - ''' - log.debug('setting up for the schedule of the email %s',auth[0]) - self.auth = auth - self.contacts = [] - self.folderName = folderName - - self.verify = verify - - - def getContacts(self): - '''Begin the process of downloading contact metadata.''' - if self.folderName is None: - log.debug('fetching contacts.') - response = requests.get(self.con_url,auth=self.auth,verify=self.verify) - log.info('Response from O365: %s', str(response)) - - else: - log.debug('fetching contact folder.') - response = requests.get(self.folder_url.format(self.folderName),auth=self.auth,verify=self.verify) - fid = response.json()['value'][0]['Id'] - log.debug('got a response of {0} and an Id of {1}'.format(response.status_code,fid)) - - log.debug('fetching contacts for {0}.'.format(self.folderName)) - response = requests.get(self.con_folder_url.format(fid),auth=self.auth,verify=self.verify) - log.info('Response from O365: {0}'.format(str(response))) - - for contact in response.json()['value']: - duplicate = False - log.debug('Got a contact Named: {0}'.format(contact['DisplayName'])) - for existing in self.contacts: - if existing.json['Id'] == contact['Id']: - log.info('duplicate contact') - duplicate = True - break - - if not duplicate: - self.contacts.append(Contact(contact,self.auth)) - - log.debug('Appended Contact.') - - - log.debug('all calendars retrieved and put in to the list.') - return True - -#To the King! diff --git a/O365/inbox.py b/O365/inbox.py deleted file mode 100644 index b50242bf82ec4..0000000000000 --- a/O365/inbox.py +++ /dev/null @@ -1,128 +0,0 @@ -from O365.message import Message -import logging -import json -import requests - -log = logging.getLogger(__name__) - -class Inbox( object ): - ''' - Wrapper class for an inbox which mostly holds a list of messages. - - Methods: - getMessages -- downloads messages to local memory. - - Variables: - inbox_url -- url used for fetching emails. - ''' - #url for fetching emails. Takes a flag for whether they are read or not. - inbox_url = 'https://outlook.office365.com/api/v1.0/me/messages' - - def __init__(self, auth, getNow=True, verify=True): - ''' - Creates a new inbox wrapper. Send email and password for authentication. - - set getNow to false if you don't want to immedeatly download new messages. - ''' - - log.debug('creating inbox for the email %s',auth[0]) - self.auth = auth - self.messages = [] - self.errors = '' - - self.filters = '' - self.order_by = '' - self.verify = verify - - if getNow: - self.filters = 'IsRead eq false' - self.getMessages() - - def getMessages(self, number = 10): - ''' - Downloads messages to local memory. - - You create an inbox to be the container class for messages, this method - then pulls those messages down to the local disk. This is called in the - init method, so it's kind of pointless for you. Unless you think new - messages have come in. - - You can filter only certain emails by setting filters. See the set and - get filters methods for more information. - - Returns true if there are messages. Returns false if there were no - messages available that matched the filters specified. - ''' - - log.debug('fetching messages.') - response = requests.get(self.inbox_url,auth=self.auth,params={'$orderby':self.order_by, '$filter':self.filters, '$top':number},verify=self.verify) - if response.status_code in [400, 500]: - self.errors = response.text - return False - elif response.status_code in [401]: - self.errors = response.reason - return False - - log.info('Response from O365: %s', str(response)) - - #check that there are messages - try: - response.json()['value'] - except KeyError as e: - log.debug('no messages') - return False - - for message in response.json()['value']: - try: - duplicate = False - for i,m in enumerate(self.messages): - if message['Id'] == m.json['Id']: - self.messages[i] = Message(message,self.auth) - duplicate = True - break - - if not duplicate: - self.messages.append(Message(message,self.auth)) - - log.debug('appended message: %s',message['Subject']) - except Exception as e: - log.info('failed to append message: %',str(e)) - - log.debug('all messages retrieved and put in to the list.') - return True - - def getErrors(self): - return self.errors - - def getOrderBy(self): - return self.order_by - - def setOrderBy(self, f_string): - ''' - For example 'DateTimeReceived desc' - ''' - self.order_by = f_string - return True - - def getFilter(self): - '''get the value set for a specific filter, if exists, else None''' - return self.filters - - def setFilter(self,f_string): - ''' - Set the value of a filter. More information on what filters are available - can be found here: - https://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#RESTAPIResourcesMessage - I may in the future have the ability to add these in yourself. but right now that is to complicated. - - Arguments: - f_string -- The string that represents the filters you want to enact. - should be something like: (HasAttachments eq true) and (IsRead eq false) - or just: IsRead eq false - test your filter stirng here: https://outlook.office365.com/api/v1.0/me/messages?$filter= - if that accepts it then you know it works. - ''' - self.filters = f_string - return True - -#To the King! diff --git a/O365/mailbox.py b/O365/mailbox.py new file mode 100644 index 0000000000000..388ef43302117 --- /dev/null +++ b/O365/mailbox.py @@ -0,0 +1,407 @@ +import logging +import datetime as dt + +from O365.message import Message +from O365.utils import Pagination, NEXT_LINK_KEYWORD, OutlookWellKnowFolderNames, ApiComponent + +log = logging.getLogger(__name__) + + +class Folder(ApiComponent): + """ A Mail Folder representation """ + + _endpoints = { + 'root_folders': '/mailFolders', + 'child_folders': '/mailFolders/{id}/childFolders', + 'get_folder': '/mailFolders/{id}', + 'root_messages': '/messages', + 'folder_messages': '/mailFolders/{id}/messages', + 'copy_folder': '/mailFolders/{id}/copy', + 'move_folder': '/mailFolders/{id}/move', + 'delete_message': '/messages/{id}', + } + message_constructor = Message + + def __init__(self, *, parent=None, con=None, **kwargs): + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + self.parent = parent if isinstance(parent, Folder) else None + + self.root = kwargs.pop('root', False) # This folder has no parents if root = True. + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name', '')) # Fallback to manual folder + if self.root is False: + self.folder_id = cloud_data.get(self._cc('id'), kwargs.get('folder_id', None)) # Fallback to manual folder + self.parent_id = cloud_data.get(self._cc('parentFolderId'), None) + self.child_folders_count = cloud_data.get(self._cc('childFolderCount'), 0) + self.unread_items_count = cloud_data.get(self._cc('unreadItemCount'), 0) + self.total_items_count = cloud_data.get(self._cc('totalItemCount'), 0) + self.updated_at = dt.datetime.now() + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return '{} from resource: {}'.format(self.name, self.main_resource) + + def get_folders(self, limit=None, *, query=None, order_by=None, batch=None): + """ + Returns a list of child folders + + :param limit: limits the result set. Over 999 uses batch. + :param query: applies a filter to the request such as "displayName eq 'HelloFolder'" + :param order_by: orders the result set based on this condition + :param batch: Returns a custom iterator that retrieves items in batches allowing to retrieve more items than the limit. + """ + + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + self_class = getattr(self, 'folder_constructor', type(self)) + folders = [self_class(parent=self, **{self._cloud_data_key: folder}) for folder in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=folders, constructor=self_class, + next_link=next_link, limit=limit) + else: + return folders + + def get_message(self, query=None, *, download_attachments=False): + """ A shorcut to get_messages with limit=1 """ + messages = self.get_messages(limit=1, query=query, download_attachments=download_attachments) + + return messages[0] if messages else None + + def get_messages(self, limit=25, *, query=None, order_by=None, batch=None, download_attachments=False): + """ + Downloads messages from this folder + + :param limit: limits the result set. Over 999 uses batch. + :param query: applies a filter to the request such as 'displayName:HelloFolder' + :param order_by: orders the result set based on this condition + :param batch: Returns a custom iterator that retrieves items in batches allowing + to retrieve more items than the limit. Download_attachments is ignored. + :param download_attachments: downloads message attachments + """ + + if self.root: + url = self.build_url(self._endpoints.get('root_messages')) + else: + url = self.build_url(self._endpoints.get('folder_messages').format(id=self.folder_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + if batch: + download_attachments = False + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + messages = [self.message_constructor(parent=self, download_attachments=download_attachments, + **{self._cloud_data_key: message}) + for message in data.get('value', [])] + + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=messages, constructor=self.message_constructor, + next_link=next_link, limit=limit) + else: + return messages + + def create_child_folder(self, folder_name): + """ + Creates a new child folder + :return the new Folder Object or None + """ + if not folder_name: + return None + + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) + + response = self.con.post(url, data={self._cc('displayName'): folder_name}) + if not response: + return None + + folder = response.json() + + self_class = getattr(self, 'folder_constructor', type(self)) + # Everything received from the cloud must be passed with self._cloud_data_key + return self_class(parent=self, **{self._cloud_data_key: folder}) + + def get_folder(self, *, folder_id=None, folder_name=None): + """ + Returns a folder by it's id or name + :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not) + :param folder_name: the folder name to be retrieved. Must be a child of this folder. + """ + if folder_id and folder_name: + raise RuntimeError('Provide only one of the options') + + if not folder_id and not folder_name: + raise RuntimeError('Provide one of the options') + + if folder_id: + # get folder by it's id, independent of the parent of this folder_id + url = self.build_url(self._endpoints.get('get_folder').format(id=folder_id)) + params = None + else: + # get folder by name. Only looks up in child folders. + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) + params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), folder_name), '$top': 1} + + response = self.con.get(url, params=params) + if not response: + return None + + if folder_id: + folder = response.json() + else: + folder = response.json().get('value') + folder = folder[0] if folder else None + if folder is None: + return None + + self_class = getattr(self, 'folder_constructor', type(self)) + # Everything received from the cloud must be passed with self._cloud_data_key + # we don't pass parent, as this folder may not be a child of self. + return self_class(con=self.con, protocol=self.protocol, main_resource=self.main_resource, **{self._cloud_data_key: folder}) + + def refresh_folder(self, update_parent_if_changed=False): + """ + Re-donwload folder data + Inbox Folder will be unable to download its own data (no folder_id) + :param update_parent_if_changed: updates self.parent with the new parent Folder if changed + """ + folder_id = getattr(self, 'folder_id', None) + if self.root or folder_id is None: + return False + + folder = self.get_folder(folder_id=folder_id) + if folder is None: + return False + + self.name = folder.name + if folder.parent_id and self.parent_id: + if folder.parent_id != self.parent_id: + self.parent_id = folder.parent_id + self.parent = self.get_parent_folder() if update_parent_if_changed else None + self.child_folders_count = folder.child_folders_count + self.unread_items_count = folder.unread_items_count + self.total_items_count = folder.total_items_count + self.updated_at = folder.updated_at + + return True + + def get_parent_folder(self): + """ Returns the parent folder from attribute self.parent or getting it from the cloud""" + if self.root: + return None + if self.parent: + return self.parent + + if self.parent_id: + self.parent = self.get_folder(folder_id=self.parent_id) + return self.parent + + def update_folder_name(self, name, update_folder_data=True): + """ Change this folder name """ + if self.root: + return False + if not name: + return False + + url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.patch(url, data={self._cc('displayName'): name}) + if not response: + return False + + self.name = name + if not update_folder_data: + return True + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + self.child_folders_count = folder.get(self._cc('childFolderCount'), 0) + self.unread_items_count = folder.get(self._cc('unreadItemCount'), 0) + self.total_items_count = folder.get(self._cc('totalItemCount'), 0) + self.updated_at = dt.datetime.now() + + return True + + def delete(self): + """ Deletes this folder """ + + if self.root or not self.folder_id: + return False + + url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + return True + + def copy_folder(self, to_folder): + """ + Copy this folder and it's contents to into another folder + :param to_folder: the destination Folder instance or a string folder_id + :return The copied folder object + """ + to_folder_id = to_folder.folder_id if isinstance(to_folder, Folder) else to_folder + + if self.root or not self.folder_id or not to_folder_id: + return None + + url = self.build_url(self._endpoints.get('copy_folder').format(id=self.folder_id)) + + response = self.con.post(url, data={self._cc('destinationId'): to_folder_id}) + if not response: + return None + + folder = response.json() + + self_class = getattr(self, 'folder_constructor', type(self)) + # Everything received from the cloud must be passed with self._cloud_data_key + return self_class(con=self.con, main_resource=self.main_resource, **{self._cloud_data_key: folder}) + + def move_folder(self, to_folder, *, update_parent_if_changed=False): + """ + Move this folder to another folder + :param to_folder: the destination Folder instance or a string folder_id + :param update_parent_if_changed: updates self.parent with the new parent Folder if changed + """ + to_folder_id = to_folder.folder_id if isinstance(to_folder, Folder) else to_folder + + if self.root or not self.folder_id or not to_folder_id: + return False + + url = self.build_url(self._endpoints.get('move_folder').format(id=self.folder_id)) + + response = self.con.post(url, data={self._cc('destinationId'): to_folder_id}) + if not response: + return False + + folder = response.json() + + parent_id = folder.get(self._cc('parentFolderId'), None) + + if parent_id and self.parent_id: + if parent_id != self.parent_id: + self.parent_id = parent_id + self.parent = self.get_parent_folder() if update_parent_if_changed else None + + return True + + def new_message(self): + """ Creates a new draft message in this folder """ + + draft_message = self.message_constructor(parent=self, is_draft=True) + + if self.root: + draft_message.folder_id = OutlookWellKnowFolderNames.DRAFTS.value + else: + draft_message.folder_id = self.folder_id + + return draft_message + + def delete_message(self, message): + """ Deletes a stored message by it's id """ + + message_id = message.object_id if isinstance(message, Message) else message + + if message_id is None: + raise RuntimeError('Provide a valid Message or a message id') + + url = self.build_url(self._endpoints.get('delete_message').format(id=message_id)) + + response = self.con.delete(url) + + return bool(response) + + +class MailBox(Folder): + + folder_constructor = Folder + + def __init__(self, *, parent=None, con=None, **kwargs): + super().__init__(parent=parent, con=con, root=True, **kwargs) + + def inbox_folder(self): + """ Returns this mailbox Inbox """ + return self.folder_constructor(parent=self, name='Inbox', folder_id=OutlookWellKnowFolderNames.INBOX.value) + + def junk_folder(self): + """ Returns this mailbox Junk Folder """ + return self.folder_constructor(parent=self, name='Junk', folder_id=OutlookWellKnowFolderNames.JUNK.value) + + def deleted_folder(self): + """ Returns this mailbox DeletedItems Folder """ + return self.folder_constructor(parent=self, name='DeletedItems', folder_id=OutlookWellKnowFolderNames.DELETED.value) + + def drafts_folder(self): + """ Returns this mailbox Drafs Folder """ + return self.folder_constructor(parent=self, name='Drafs', folder_id=OutlookWellKnowFolderNames.DRAFTS.value) + + def sent_folder(self): + """ Returns this mailbox SentItems Folder """ + return self.folder_constructor(parent=self, name='SentItems', folder_id=OutlookWellKnowFolderNames.SENT.value) + + def outbox_folder(self): + """ Returns this mailbox Outbox Folder """ + return self.folder_constructor(parent=self, name='Outbox', folder_id=OutlookWellKnowFolderNames.OUTBOX.value) diff --git a/O365/message.py b/O365/message.py index 252c3f57f086c..42fdb7918af13 100644 --- a/O365/message.py +++ b/O365/message.py @@ -1,275 +1,671 @@ -from O365.attachment import Attachment -from O365.contact import Contact -from O365.group import Group import logging -import json -import requests +import datetime as dt +from dateutil.parser import parse +import pytz +from bs4 import BeautifulSoup as bs + +from O365.utils import OutlookWellKnowFolderNames, ApiComponent, BaseAttachments, BaseAttachment, AttachableMixin, ImportanceLevel, TrackerSet log = logging.getLogger(__name__) -class Message(object): - ''' - Management of the process of sending, receiving, reading, and editing emails. - - Note: the get and set methods are technically superflous. You can get more through control over - a message you are trying to craft through the use of editing the message.json, but these - methods provide an easy way if you don't need all the power and would like the ease. - - Methods: - constructor -- creates a new message class, using json for existing, nothing for new. - fetchAttachments -- kicks off the process that downloads attachments. - sendMessage -- take local variables and form them to send the message. - markAsRead -- marks the analougs message in the cloud as read. - setCategories -- sets the list of categories in the cloud - getCategories -- gets the email's categories - getSender -- gets a dictionary with the sender's information. - getSenderEmail -- gets the email address of the sender. - getSenderName -- gets the name of the sender, if possible. - getSubject -- gets the email's subject line. - getBody -- gets contents of the body of the email. - addRecipient -- adds a person to the recipient list. - setRecipients -- sets the list of recipients. - setSubject -- sets the subject line. - setBody -- sets the body. - - Variables: - att_url -- url for requestiong attachments. takes message GUID - send_url -- url for sending an email - update_url -- url for updating an email already existing in the cloud. - - ''' - - att_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}/attachments' - send_url = 'https://outlook.office365.com/api/v1.0/me/sendmail' - draft_url = 'https://outlook.office365.com/api/v1.0/me/folders/{folder_id}/messages' - update_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}' - - def __init__(self, json=None, auth=None, verify=True): - ''' - Makes a new message wrapper for sending and receiving messages. - - Keyword Arguments: - json (default = None) -- Takes json if you have a pre-existing message to create from. - this is mostly used inside the library for when new messages are downloaded. - auth (default = None) -- Takes an (email,password) tuple that will be used for - authentication with office365. - ''' - if json: - self.json = json - self.hasAttachments = json['HasAttachments'] - - else: - self.json = {'Body': {}, - 'ToRecipients': [], 'CcRecipients': [], 'BccRecipients': []} - self.hasAttachments = False - - self.auth = auth - self.attachments = [] - self.receiver = None - - self.verify = verify - - - def fetchAttachments(self): - '''kicks off the process that downloads attachments locally.''' - if not self.hasAttachments: - log.debug('message has no attachments, skipping out early.') - return False - - response = requests.get(self.att_url.format( - self.json['Id']), auth=self.auth,verify=self.verify) - log.info('response from O365 for retriving message attachments: %s', str(response)) - json = response.json() - - for att in json['value']: - try: - self.attachments.append(Attachment(att)) - log.debug('successfully downloaded attachment for: %s.', self.auth[0]) - except Exception as e: - log.info('failed to download attachment for: %s', self.auth[0]) - - return len(self.attachments) - - def sendMessage(self): - '''takes local variabls and forms them into a message to be sent.''' - - headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} - - try: - data = {'Message': {'Body': {}}} - data['Message']['Subject'] = self.json['Subject'] - data['Message']['Body']['Content'] = self.json['Body']['Content'] - data['Message']['Body']['ContentType'] = self.json['Body']['ContentType'] - data['Message']['ToRecipients'] = self.json['ToRecipients'] - data['Message']['CcRecipients'] = self.json['CcRecipients'] - data['Message']['BccRecipients'] = self.json['BccRecipients'] - data['Message']['Attachments'] = [att.json for att in self.attachments] - data = json.dumps(data) - except Exception as e: - log.error( - 'Error while trying to compile the json string to send: {0}'.format(str(e))) - return False - - response = requests.post( - self.send_url, data, headers=headers, auth=self.auth,verify=self.verify) - log.debug('response from server for sending message:' + str(response)) - log.debug("response body: {}".format(response.text)) - if response.status_code != 202: - return False - - return True - - def markAsRead(self): - '''marks analogous message as read in the cloud.''' - read = '{"IsRead":true}' - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - try: - response = requests.patch(self.update_url.format( - self.json['Id']), read, headers=headers, auth=self.auth,verify=self.verify) - except: - return False - return True - - def setCategories(self, categories=""): - '''sets message categories in the cloud.''' - categories = json.dumps(dict(Categories=categories)) - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - try: - response = requests.patch(self.update_url.format( - self.json['Id']), categories, headers=headers, auth=self.auth,verify=self.verify) - except: - return False - return True - - def getCategories(self): - '''gets the message's categories''' - return self.json['Categories'] - - def getSender(self): - '''get all available information for the sender of the email.''' - return self.json['Sender'] - - def getSenderEmail(self): - '''get the email address of the sender.''' - return self.json['Sender']['EmailAddress']['Address'] - - def getSenderName(self): - '''try to get the name of the sender.''' - try: - return self.json['Sender']['EmailAddress']['Name'] - except: - return '' - - def getSubject(self): - '''get email subject line.''' - return self.json['Subject'] - - def getBody(self): - '''get email body.''' - return self.json['Body']['Content'] - - def setRecipients(self, val, r_type="To"): - ''' - set the recipient list. - - val: the one argument this method takes can be very flexible. you can send: - a dictionary: this must to be a dictionary formated as such: - {"EmailAddress":{"Address":"recipient@example.com"}} - with other options such ass "Name" with address. but at minimum - it must have this. - a list: this must to be a list of libraries formatted the way - specified above, or it can be a list of dictionary objects of - type Contact or it can be an email address as string. The - method will sort out the libraries from the contacts. - a string: this is if you just want to throw an email address. - a contact: type Contact from this dictionary. - a group: type Group, which is a list of contacts. - For each of these argument types the appropriate action will be taken - to fit them to the needs of the library. - ''' - log.debug("Entered SET_RECIPIENTS function with type: {}".format(r_type)) - self.json[r_type + 'Recipients'] = [] - - if isinstance(val, list): - for con in val: - if isinstance(con, Contact): - self.addRecipient(con, r_type=r_type) - elif isinstance(con, str): - if '@' in con: - self.addRecipient(con, r_type=r_type) - elif isinstance(con, dict): - self.json[r_type + 'Recipients'].append(con) - elif isinstance(val, dict): - self.json[r_type + 'Recipients'] = [val] - elif isinstance(val, str): - if '@' in val: - self.addRecipient(val, r_type=r_type) - elif isinstance(val, Contact): - self.addRecipient(val, r_type=r_type) - elif isinstance(val, Group): - for person in val: - self.addRecipient(person, r_type=r_type) - else: - return False - return True - - def addRecipient(self, address, name=None, r_type="To"): - ''' - Adds a recipient to the recipients list. - - Arguments: - address -- the email address of the person you are sending to. <<< Important that. - Address can also be of type Contact or type Group. - name -- the name of the person you are sending to. mostly just a decorator. If you - send an email address for the address arg, this will give you the ability - to set the name properly, other wise it uses the email address up to the - at sign for the name. But if you send a type Contact or type Group, this - argument is completely ignored. - ''' - if isinstance(address, Contact): - self.json[r_type + 'Recipients'].append(address.getFirstEmailAddress()) - elif isinstance(address, Group): - for con in address.contacts: - self.json[r_type + 'Recipients'].append(address.getFirstEmailAddress()) - else: - if name is None: - name = address[:address.index('@')] - self.json[r_type + 'Recipients'].append( - {'EmailAddress': {'Address': address, 'Name': name}}) - - def setSubject(self, val): - '''Sets the subect line of the email.''' - self.json['Subject'] = val - - def setBody(self, val): - '''Sets the body content of the email.''' - cont = False - - while not cont: - try: - self.json['Body']['Content'] = val - self.json['Body']['ContentType'] = 'Text' - cont = True - except: - self.json['Body'] = {} - - def setBodyHTML(self, val=None): - ''' - Sets the body content type to HTML for your pretty emails. - - arguments: - val -- Default: None. The content of the body you want set. If you don't pass a - value it is just ignored. - ''' - cont = False - - while not cont: - try: - self.json['Body']['ContentType'] = 'HTML' - if val: - self.json['Body']['Content'] = val - cont = True - except: - self.json['Body'] = {} - -# To the King! +class Recipient: + """ A single Recipient """ + + def __init__(self, address=None, name=None, parent=None, field=None): + self._address = address or '' + self._name = name or '' + self._parent = parent + self._field = field + + def __bool__(self): + return bool(self.address) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + if self.name: + return '{} ({})'.format(self.name, self.address) + else: + return self.address + + def _track_changes(self): + """ Update the track_changes on the parent to reflect a needed update on this field """ + if self._field and getattr(self._parent, '_track_changes', None) is not None: + self._parent._track_changes.add(self._field) + + @property + def address(self): + return self._address + + @address.setter + def address(self, value): + self._address = value + self._track_changes() + + @property + def name(self): + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes() + + +class Recipients: + """ A Sequence of Recipients """ + + def __init__(self, recipients=None, parent=None, field=None): + """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """ + self._parent = parent + self._field = field + self._recipients = [] + self.untrack = True + if recipients: + self.add(recipients) + self.untrack = False + + def __iter__(self): + return iter(self._recipients) + + def __getitem__(self, key): + return self._recipients[key] + + def __contains__(self, item): + return item in {recipient.address for recipient in self._recipients} + + def __bool__(self): + return bool(len(self._recipients)) + + def __len__(self): + return len(self._recipients) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Recipients count: {}'.format(len(self._recipients)) + + def _track_changes(self): + """ Update the track_changes on the parent to reflect a needed update on this field """ + if self._field and getattr(self._parent, '_track_changes', None) is not None and self.untrack is False: + self._parent._track_changes.add(self._field) + + def clear(self): + self._recipients = [] + self._track_changes() + + def add(self, recipients): + """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """ + + if recipients: + if isinstance(recipients, str): + self._recipients.append(Recipient(address=recipients, parent=self._parent, field=self._field)) + elif isinstance(recipients, Recipient): + self._recipients.append(recipients) + elif isinstance(recipients, tuple): + name, address = recipients + if address: + self._recipients.append(Recipient(address=address, name=name, parent=self._parent, field=self._field)) + elif isinstance(recipients, list): + for recipient in recipients: + self.add(recipient) + else: + raise ValueError('Recipients must be an address string, a' + ' Recipient instance, a (name, address) tuple or a list') + self._track_changes() + + def remove(self, address): + """ Remove an address or multiple addreses """ + recipients = [] + if isinstance(address, str): + address = {address} # set + elif isinstance(address, (list, tuple)): + address = set(address) + + for recipient in self._recipients: + if recipient.address not in address: + recipients.append(recipient) + if len(recipients) != len(self._recipients): + self._track_changes() + self._recipients = recipients + + def get_first_recipient_with_address(self): + """ Returns the first recipient found with a non blank address""" + recipients_with_address = [recipient for recipient in self._recipients if recipient.address] + if recipients_with_address: + return recipients_with_address[0] + else: + return None + + +class MessageAttachment(BaseAttachment): + + _endpoints = { + 'attach': '/messages/{id}/attachments', + 'attachment': '/messages/{id}/attachments/{ida}' + } + + +class MessageAttachments(BaseAttachments): + + _endpoints = { + 'attachments': '/messages/{id}/attachments', + 'attachment': '/messages/{id}/attachments/{ida}' + } + _attachment_constructor = MessageAttachment + + +class HandleRecipientsMixin: + + def _recipients_from_cloud(self, recipients, field=None): + """ Transform a recipient from cloud data to object data """ + recipients_data = [] + for recipient in recipients: + recipients_data.append(self._recipient_from_cloud(recipient, field=field)) + return Recipients(recipients_data, parent=self, field=field) + + def _recipient_from_cloud(self, recipient, field=None): + """ Transform a recipient from cloud data to object data """ + + if recipient: + recipient = recipient.get(self._cc('emailAddress'), recipient if isinstance(recipient, dict) else {}) + address = recipient.get(self._cc('address'), '') + name = recipient.get(self._cc('name'), '') + return Recipient(address=address, name=name, parent=self, field=field) + else: + return Recipient() + + def _recipient_to_cloud(self, recipient): + """ Transforms a Recipient object to a cloud dict """ + data = None + if recipient: + data = {self._cc('emailAddress'): {self._cc('address'): recipient.address}} + if recipient.name: + data[self._cc('emailAddress')][self._cc('name')] = recipient.name + return data + + +class Message(ApiComponent, AttachableMixin, HandleRecipientsMixin): + """ Management of the process of sending, receiving, reading, and editing emails. """ + + _endpoints = { + 'create_draft': '/messages', + 'create_draft_folder': '/mailFolders/{id}/messages', + 'send_mail': '/sendMail', + 'send_draft': '/messages/{id}/send', + 'get_message': '/messages/{id}', + 'move_message': '/messages/{id}/move', + 'copy_message': '/messages/{id}/copy', + 'create_reply': '/messages/{id}/createReply', + 'create_reply_all': '/messages/{id}/createReplyAll', + 'forward_message': '/messages/{id}/createForward' + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """ + Makes a new message wrapper for sending and receiving messages. + + :param parent: the parent object + :param con: the id of this message if it exists + """ + assert parent or con, 'Need a parent or a connection' + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource, + attachment_name_property='subject', attachment_type='message_type') + + download_attachments = kwargs.get('download_attachments') + + cloud_data = kwargs.get(self._cloud_data_key, {}) + cc = self._cc # alias to shorten the code + + self._track_changes = TrackerSet(casing=cc) # internal to know which properties need to be updated on the server + self.object_id = cloud_data.get(cc('id'), None) + + self.__created = cloud_data.get(cc('createdDateTime'), None) + self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) + self.__received = cloud_data.get(cc('receivedDateTime'), None) + self.__sent = cloud_data.get(cc('sentDateTime'), None) + + local_tz = self.protocol.timezone + self.__created = parse(self.__created).astimezone(local_tz) if self.__created else None + self.__modified = parse(self.__modified).astimezone(local_tz) if self.__modified else None + self.__received = parse(self.__received).astimezone(local_tz) if self.__received else None + self.__sent = parse(self.__sent).astimezone(local_tz) if self.__sent else None + + self.__attachments = MessageAttachments(parent=self, attachments=[]) + self.has_attachments = cloud_data.get(cc('hasAttachments'), 0) + if self.has_attachments and download_attachments: + self.attachments.download_attachments() + self.__subject = cloud_data.get(cc('subject'), '') + body = cloud_data.get(cc('body'), {}) + self.__body = body.get(cc('content'), '') + self.body_type = body.get(cc('contentType'), 'HTML') # default to HTML for new messages + self.__sender = self._recipient_from_cloud(cloud_data.get(cc('from'), None), field='from') + self.__to = self._recipients_from_cloud(cloud_data.get(cc('toRecipients'), []), field='toRecipients') + self.__cc = self._recipients_from_cloud(cloud_data.get(cc('ccRecipients'), []), field='ccRecipients') + self.__bcc = self._recipients_from_cloud(cloud_data.get(cc('bccRecipients'), []), field='bccRecipients') + self.__reply_to = self._recipients_from_cloud(cloud_data.get(cc('replyTo'), []), field='replyTo') + self.__categories = cloud_data.get(cc('categories'), []) + self.__importance = ImportanceLevel((cloud_data.get(cc('importance'), 'normal') or 'normal').lower()) # lower because of office365 v1.0 + self.__is_read = cloud_data.get(cc('isRead'), None) + self.__is_draft = cloud_data.get(cc('isDraft'), kwargs.get('is_draft', True)) # a message is a draft by default + self.conversation_id = cloud_data.get(cc('conversationId'), None) + self.folder_id = cloud_data.get(cc('parentFolderId'), None) + + def _clear_tracker(self): + # reset the tracked changes. Usually after a server update + self._track_changes = TrackerSet(casing=self._cc) + + @property + def is_read(self): + return self.__is_read + + @is_read.setter + def is_read(self, value): + self.__is_read = value + self._track_changes.add('isRead') + + @property + def is_draft(self): + return self.__is_draft + + @property + def subject(self): + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add('subject') + + @property + def body(self): + return self.__body + + @body.setter + def body(self, value): + if self.__body: + if not value: + self.__body = '' + else: + soup = bs(self.__body, 'html.parser') + soup.body.insert(0, value) + self.__body = str(soup) + else: + self.__body = value + self._track_changes.add('body') + + @property + def created(self): + return self.__created + + @property + def modified(self): + return self.__modified + + @property + def received(self): + return self.__received + + @property + def sent(self): + return self.__sent + + @property + def attachments(self): + """ Just to avoid api misuse by assigning to 'attachments' """ + return self.__attachments + + @property + def sender(self): + """ sender is a property to force to be allways a Recipient class """ + return self.__sender + + @sender.setter + def sender(self, value): + """ sender is a property to force to be allways a Recipient class """ + if isinstance(value, Recipient): + if value._parent is None: + value._parent = self + value._field = 'from' + self.__sender = value + elif isinstance(value, str): + self.__sender.address = value + self.__sender.name = '' + else: + raise ValueError('sender must be an address string or a Recipient object') + self._track_changes.add('from') + + @property + def to(self): + """ Just to avoid api misuse by assigning to 'to' """ + return self.__to + + @property + def cc(self): + """ Just to avoid api misuse by assigning to 'cc' """ + return self.__cc + + @property + def bcc(self): + """ Just to avoid api misuse by assigning to 'bcc' """ + return self.__bcc + + @property + def reply_to(self): + """ Just to avoid api misuse by assigning to 'reply_to' """ + return self.__reply_to + + @property + def categories(self): + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = value + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, tuple): + self.__categories = list(value) + else: + raise ValueError('categories must be a list') + self._track_changes.add('categories') + + @property + def importance(self): + return self.__importance + + @importance.setter + def importance(self, value): + self.__importance = value if isinstance(value, ImportanceLevel) else ImportanceLevel(value.lower()) + self._track_changes.add('importance') + + def to_api_data(self, restrict_keys=None): + """ Returns a dict representation of this message prepared to be send to the cloud + :param restrict_keys: a set of keys to restrict the returned data to. + """ + + cc = self._cc # alias to shorten the code + + message = { + cc('subject'): self.subject, + cc('body'): { + cc('contentType'): self.body_type, + cc('content'): self.body}, + cc('importance'): self.importance.value + } + + if self.to: + message[cc('toRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.to] + if self.cc: + message[cc('ccRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.cc] + if self.bcc: + message[cc('bccRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.bcc] + if self.reply_to: + message[cc('replyTo')] = [self._recipient_to_cloud(recipient) for recipient in self.reply_to] + if self.attachments: + message[cc('attachments')] = self.attachments.to_api_data() + if self.sender and self.sender.address: + message[cc('from')] = self._recipient_to_cloud(self.sender) + + if self.object_id and not self.__is_draft: + # return the whole signature of this message + + message[cc('id')] = self.object_id + message[cc('createdDateTime')] = self.created.astimezone(pytz.utc).isoformat() + message[cc('receivedDateTime')] = self.received.astimezone(pytz.utc).isoformat() + message[cc('sentDateTime')] = self.sent.astimezone(pytz.utc).isoformat() + message[cc('hasAttachments')] = len(self.attachments) > 0 + message[cc('categories')] = self.categories + message[cc('isRead')] = self.is_read + message[cc('isDraft')] = self.__is_draft + message[cc('conversationId')] = self.conversation_id + message[cc('parentFolderId')] = self.folder_id # this property does not form part of the message itself + + if restrict_keys: + for key in list(message.keys()): + if key not in restrict_keys: + del message[key] + + return message + + def send(self, save_to_sent_folder=True): + """ Sends this message. """ + + if self.object_id and not self.__is_draft: + return RuntimeError('Not possible to send a message that is not new or a draft. Use Reply or Forward instead.') + + if self.__is_draft and self.object_id: + url = self.build_url(self._endpoints.get('send_draft').format(id=self.object_id)) + data = None + else: + url = self.build_url(self._endpoints.get('send_mail')) + data = {self._cc('message'): self.to_api_data()} + if save_to_sent_folder is False: + data[self._cc('saveToSentItems')] = False + + response = self.con.post(url, data=data) + if not response: # response evaluates to false if 4XX or 5XX status codes are returned + return False + + self.object_id = 'sent_message' if not self.object_id else self.object_id + self.__is_draft = False + + return True + + def reply(self, to_all=True): + """ + Creates a new message that is a reply to this message. + :param to_all: replies to all the recipients instead to just the sender + """ + if not self.object_id or self.__is_draft: + raise RuntimeError("Can't reply to this message") + + if to_all: + url = self.build_url(self._endpoints.get('create_reply_all').format(id=self.object_id)) + else: + url = self.build_url(self._endpoints.get('create_reply').format(id=self.object_id)) + + response = self.con.post(url) + if not response: + return None + + message = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message}) + + def forward(self): + """ + Creates a new message that is a forward of this message. + """ + if not self.object_id or self.__is_draft: + raise RuntimeError("Can't forward this message") + + url = self.build_url(self._endpoints.get('forward_message').format(id=self.object_id)) + + response = self.con.post(url) + if not response: + return None + + message = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message}) + + def delete(self): + """ Deletes a stored message """ + if self.object_id is None: + raise RuntimeError('Attempting to delete an unsaved Message') + + url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response) + + def mark_as_read(self): + """ Marks this message as read in the cloud.""" + if self.object_id is None or self.__is_draft: + raise RuntimeError('Attempting to mark as read an unsaved Message') + + data = {self._cc('isRead'): True} + + url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.__is_read = True + + return True + + def move(self, folder): + """ + Move the message to a given folder + + :param folder: Folder object or Folder id or Well-known name to move this message to + :returns: True on success + """ + if self.object_id is None: + raise RuntimeError('Attempting to move an unsaved Message') + + url = self.build_url(self._endpoints.get('move_message').format(id=self.object_id)) + + if isinstance(folder, str): + folder_id = folder + else: + folder_id = getattr(folder, 'folder_id', None) + + if not folder_id: + raise RuntimeError('Must Provide a valid folder_id') + + data = {self._cc('destinationId'): folder_id} + + response = self.con.post(url, data=data) + if not response: + return False + + self.folder_id = folder_id + + return True + + def copy(self, folder): + """ + Copy the message to a given folder + + :param folder: Folder object or Folder id or Well-known name to move this message to + :returns: the copied message + """ + if self.object_id is None: + raise RuntimeError('Attempting to move an unsaved Message') + + url = self.build_url(self._endpoints.get('copy_message').format(id=self.object_id)) + + if isinstance(folder, str): + folder_id = folder + else: + folder_id = getattr(folder, 'folder_id', None) + + if not folder_id: + raise RuntimeError('Must Provide a valid folder_id') + + data = {self._cc('destinationId'): folder_id} + + response = self.con.post(url, data=data) + if not response: + return None + + message = response.json() + + # Everything received from the cloud must be passed with self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message}) + + def save_draft(self, target_folder=OutlookWellKnowFolderNames.DRAFTS): + """ Save this message as a draft on the cloud """ + + if self.object_id: + # update message. Attachments are NOT included nor saved. + if not self.__is_draft: + raise RuntimeError('Only draft messages can be updated') + if not self._track_changes: + return True # there's nothing to update + url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + + data.pop(self._cc('attachments'), None) # attachments are handled by the next method call + self.attachments._update_attachments_to_cloud() + else: + # new message. Attachments are included and saved. + if not self.__is_draft: + raise RuntimeError('Only draft messages can be saved as drafts') + + target_folder = target_folder or OutlookWellKnowFolderNames.DRAFTS + if isinstance(target_folder, OutlookWellKnowFolderNames): + target_folder = target_folder.value + elif not isinstance(target_folder, str): + # a Folder instance + target_folder = getattr(target_folder, 'folder_id', OutlookWellKnowFolderNames.DRAFTS.value) + + url = self.build_url(self._endpoints.get('create_draft_folder').format(id=target_folder)) + method = self.con.post + data = self.to_api_data() + + self._clear_tracker() # reset the tracked changes as they are all saved. + if not data: + return True + + response = method(url, data=data) + if not response: + return False + + if not self.object_id: + # new message + message = response.json() + + self.object_id = message.get(self._cc('id'), None) + self.folder_id = message.get(self._cc('parentFolderId'), None) + + self.__created = message.get(self._cc('createdDateTime'), message.get(self._cc('dateTimeCreated'), None)) # fallback to office365 v1.0 + self.__modified = message.get(self._cc('lastModifiedDateTime'), message.get(self._cc('dateTimeModified'), None)) # fallback to office365 v1.0 + + self.__created = parse(self.__created).astimezone(self.protocol.timezone) if self.__created else None + self.__modified = parse(self.__modified).astimezone(self.protocol.timezone) if self.__modified else None + + else: + self.__modified = self.protocol.timezone.localize(dt.datetime.now()) + + return True + + def get_body_text(self): + """ Parse the body html and returns the body text using bs4 """ + if self.body_type != 'HTML': + return self.body + + try: + soup = bs(self.body, 'html.parser') + except Exception as e: + return self.body + else: + return soup.body.text + + def get_body_soup(self): + """ Returns the beautifulsoup4 of the html body""" + if self.body_type != 'HTML': + return None + else: + return bs(self.body, 'html.parser') + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Subject: {}'.format(self.subject) diff --git a/O365/schedule.py b/O365/schedule.py deleted file mode 100644 index 18e7caaf4b3eb..0000000000000 --- a/O365/schedule.py +++ /dev/null @@ -1,61 +0,0 @@ -from O365.cal import Calendar -import logging -import json -import requests - -log = logging.getLogger(__name__) - -class Schedule( object ): - ''' - A wrapper class that handles all the Calendars associated with a sngle Office365 account. - - Methods: - constructor -- takes your email and password for authentication. - getCalendars -- begins the actual process of downloading calendars. - - Variables: - cal_url -- the url that is requested for the retrival of the calendar GUIDs. - ''' - cal_url = 'https://outlook.office365.com/api/v1.0/me/calendars' - - def __init__(self, auth, verify=True): - '''Creates a Schedule class for managing all calendars associated with email+password.''' - log.debug('setting up for the schedule of the email %s',auth[0]) - self.auth = auth - self.calendars = [] - - self.verify = verify - - - def getCalendars(self): - '''Begin the process of downloading calendar metadata.''' - log.debug('fetching calendars.') - response = requests.get(self.cal_url,auth=self.auth,verify=self.verify) - log.info('Response from O365: %s', str(response)) - - for calendar in response.json()['value']: - try: - duplicate = False - log.debug('Got a calendar with Name: {0} and Id: {1}'.format(calendar['Name'],calendar['Id'])) - for i,c in enumerate(self.calendars): - if c.json['Id'] == calendar['Id']: - c.json = calendar - c.name = calendar['Name'] - c.calendarId = calendar['Id'] - duplicate = True - log.debug('Calendar: {0} is a duplicate',calendar['Name']) - break - - if not duplicate: - self.calendars.append(Calendar(calendar,self.auth)) - log.debug('appended calendar: %s',calendar['Name']) - - log.debug('Finished with calendar {0} moving on.'.format(calendar['Name'])) - - except Exception as e: - log.info('failed to append calendar: {0}'.format(str(e))) - - log.debug('all calendars retrieved and put in to the list.') - return True - -#To the King! diff --git a/O365/utils/__init__.py b/O365/utils/__init__.py new file mode 100644 index 0000000000000..2fd4cab04664a --- /dev/null +++ b/O365/utils/__init__.py @@ -0,0 +1,5 @@ + +from .utils import ApiComponent, OutlookWellKnowFolderNames, OneDriveWellKnowFolderNames, \ + Pagination, Query, NEXT_LINK_KEYWORD, ME_RESOURCE, ImportanceLevel, TrackerSet +from .attachment import BaseAttachments, BaseAttachment, AttachableMixin +from .windows_tz import IANA_TO_WIN, WIN_TO_IANA diff --git a/O365/utils/attachment.py b/O365/utils/attachment.py new file mode 100644 index 0000000000000..54de4bbc544bb --- /dev/null +++ b/O365/utils/attachment.py @@ -0,0 +1,373 @@ +import logging +import base64 +from pathlib import Path + +from O365.utils.utils import ApiComponent + + +log = logging.getLogger(__name__) + + +class AttachableMixin: + """ + Defines the functionality for an object to be attachable. + Any object that inherits from this class will be attachable (if the underlying api allows that) + """ + + def __init__(self, attachment_name_property=None, attachment_type=None): + self.__attachment_name = None + self.__attachment_name_property = attachment_name_property + self.__attachment_type = self._gk(attachment_type) + + @property + def attachment_name(self): + if self.__attachment_name is not None: + return self.__attachment_name + if self.__attachment_name_property: + return getattr(self, self.__attachment_name_property, '') + else: + # property order resolution: + # 1) try property 'subject' + # 2) try property 'name' + try: + attachment_name = getattr(self, 'subject') + except AttributeError: + attachment_name = getattr(self, 'name', '') + return attachment_name + + @attachment_name.setter + def attachment_name(self, value): + self.__attachment_name = value + + @property + def attachment_type(self): + return self.__attachment_type + + def to_api_data(self): + raise NotImplementedError() + + +class BaseAttachment(ApiComponent): + """ BaseAttachment class is the base object for dealing with attachments """ + + _endpoints = {'attach': '/messages/{id}/attachments'} + + def __init__(self, attachment=None, *, parent=None, **kwargs): + """ + Creates a new attachment class, optionally from existing cloud data. + + :param attachment: attachment data (dict = cloud data, other = user data) + :param parent: the parent Attachments + :param kwargs: extra options: + - 'protocol' when using attachment standalone + - 'main_resource' when using attachment standalone and is not the default_resource of protocol + """ + kwargs.setdefault('protocol', getattr(parent, 'protocol', None)) + kwargs.setdefault('main_resource', getattr(parent, 'main_resource', None)) + + super().__init__(**kwargs) + self.name = None + self.attachment_type = 'file' + self.attachment_id = None + self.attachment = None + self.content = None + self.on_disk = False + self.on_cloud = kwargs.get('on_cloud', False) + + if attachment: + if isinstance(attachment, dict): + if self._cloud_data_key in attachment: + # data from the cloud + attachment = attachment.get(self._cloud_data_key) + self.attachment_id = attachment.get(self._cc('id'), None) + self.name = attachment.get(self._cc('name'), None) + self.content = attachment.get(self._cc('contentBytes'), None) + self.attachment_type = 'item' if 'item' in attachment.get('@odata.type', '').lower() else 'file' + self.on_disk = False + else: + file_path = attachment.get('path', attachment.get('name')) + if file_path is None: + raise ValueError('Must provide a valid "path" or "name" for the attachment') + self.content = attachment.get('content') + self.on_disk = attachment.get('on_disk') + self.attachment_id = attachment.get('attachment_id') + self.attachment = Path(file_path) if self.on_disk else None + self.name = self.attachment.name if self.on_disk else attachment.get('name') + elif isinstance(attachment, str): + self.attachment = Path(attachment) + self.name = self.attachment.name + elif isinstance(attachment, Path): + self.attachment = attachment + self.name = self.attachment.name + elif isinstance(attachment, (tuple, list)): + file_path, custom_name = attachment + self.attachment = Path(file_path) + self.name = custom_name + elif isinstance(attachment, AttachableMixin): + # Object that can be attached (Message for example) + self.attachment_type = 'item' + self.attachment = attachment + self.name = attachment.attachment_name + self.content = attachment.to_api_data() + self.content['@odata.type'] = attachment.attachment_type + + if self.content is None and self.attachment and self.attachment.exists(): + with self.attachment.open('rb') as file: + self.content = base64.b64encode(file.read()).decode('utf-8') + self.on_disk = True + + def to_api_data(self): + data = {'@odata.type': self._gk('{}_attachment_type'.format(self.attachment_type)), self._cc('name'): self.name} + + if self.attachment_type == 'file': + data[self._cc('contentBytes')] = self.content + else: + data[self._cc('item')] = self.content + + return data + + def save(self, location=None, custom_name=None): + """ Save the attachment locally to disk. + :param location: path string to where the file is to be saved. + :param custom_name: a custom name to be saved as + """ + if not self.content: + return False + + location = Path(location or '') + if not location.exists(): + log.debug('the location provided does not exist') + return False + + name = custom_name or self.name + name = name.replace('/', '-').replace('\\', '') + try: + path = location / name + with path.open('wb') as file: + file.write(base64.b64decode(self.content)) + self.attachment = path + self.on_disk = True + log.debug('file saved locally.') + except Exception as e: + log.error('file failed to be saved: %s', str(e)) + return False + return True + + def attach(self, api_object, on_cloud=False): + """ + Attach this attachment to an existing api_object + This BaseAttachment object must be an orphan BaseAttachment created for the + sole purpose of attach it to something and therefore run this method. + """ + + if self.on_cloud: + # item is already saved on the cloud. + return True + + # api_object must exist and if implements attachments then we can attach to it. + if api_object and getattr(api_object, 'attachments', None): + if on_cloud: + if not api_object.object_id: + raise RuntimeError('A valid object id is needed in order to attach a file') + # api_object builds its own url using its resource and main configuration + url = api_object.build_url(self._endpoints.get('attach').format(id=api_object.object_id)) + + response = api_object.con.post(url, data=self.to_api_data()) + + return bool(response) + else: + if self.attachment_type == 'file': + api_object.attachments.add([{ + 'attachment_id': self.attachment_id, # TODO: copy attachment id? or set to None? + 'path': str(self.attachment) if self.attachment else None, + 'name': self.name, + 'content': self.content, + 'on_disk': self.on_disk + }]) + else: + raise RuntimeError('Only file attachments can be attached') + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Attachment: {}'.format(self.name) + + +class BaseAttachments(ApiComponent): + """ A Collection of BaseAttachments """ + + _endpoints = { + 'attachments': '/messages/{id}/attachments', + 'attachment': '/messages/{id}/attachments/{ida}' + } + _attachment_constructor = BaseAttachment + + def __init__(self, parent, attachments=None): + """ Attachments must be a list of path strings or dictionary elements """ + super().__init__(protocol=parent.protocol, main_resource=parent.main_resource) + self._parent = parent + self.__attachments = [] + self.__removed_attachments = [] # holds on_cloud attachments removed from the parent object + self.untrack = True + if attachments: + self.add(attachments) + self.untrack = False + + def __iter__(self): + return iter(self.__attachments) + + def __getitem__(self, key): + return self.__attachments[key] + + def __contains__(self, item): + return item in {attachment.name for attachment in self.__attachments} + + def __len__(self): + return len(self.__attachments) + + def __str__(self): + attachments = len(self.__attachments) + parent_has_attachments = getattr(self._parent, 'has_attachments', False) + if parent_has_attachments and attachments == 0: + return 'Number of Attachments: unknown' + else: + return 'Number of Attachments: {}'.format(attachments) + + def __repr__(self): + return self.__str__() + + def __bool__(self): + return bool(len(self.__attachments)) + + def to_api_data(self): + return [attachment.to_api_data() for attachment in self.__attachments if attachment.on_cloud is False] + + def clear(self): + for attachment in self.__attachments: + if attachment.on_cloud: + self.__removed_attachments.append(attachment) + self.__attachments = [] + self._update_parent_attachments() + self._track_changes() + + def _track_changes(self): + """ Update the track_changes on the parent to reflect a needed update on this field """ + if getattr(self._parent, '_track_changes', None) is not None and self.untrack is False: + self._parent._track_changes.add('attachments') + + def _update_parent_attachments(self): + """ Tries to update the parent property 'has_attachments' """ + try: + self._parent.has_attachments = bool(len(self.__attachments)) + except AttributeError: + pass + + def add(self, attachments): + """ Attachments must be a Path or string or a list of Paths, path strings or dictionary elements """ + + if attachments: + if isinstance(attachments, (str, Path)): + attachments = [attachments] + if isinstance(attachments, (list, tuple, set)): + # User provided attachments + attachments_temp = [self._attachment_constructor(attachment, parent=self) + for attachment in attachments] + elif isinstance(attachments, dict) and self._cloud_data_key in attachments: + # Cloud downloaded attachments. We pass on_cloud=True to track if this attachment is saved on the server + attachments_temp = [self._attachment_constructor({self._cloud_data_key: attachment}, parent=self, on_cloud=True) + for attachment in attachments.get(self._cloud_data_key, [])] + else: + raise ValueError('Attachments must be a str or Path or a list, tuple or set of the former') + + self.__attachments.extend(attachments_temp) + self._update_parent_attachments() + self._track_changes() + + def remove(self, attachments): + """ Remove attachments from this collection of attachments """ + if isinstance(attachments, (list, tuple)): + attachments = {attachment.name if isinstance(attachment, BaseAttachment) else attachment for attachment in attachments} + elif isinstance(attachments, str): + attachments = {attachments} + elif isinstance(attachments, BaseAttachment): + attachments = {attachments.name} + else: + raise ValueError('Incorrect parameter type for attachments') + + new_attachments = [] + for attachment in self.__attachments: + if attachment.name not in attachments: + new_attachments.append(attachment) + else: + if attachment.on_cloud: + self.__removed_attachments.append(attachment) # add to removed_attachments so later we can delete them + self.__attachments = new_attachments + self._update_parent_attachments() + self._track_changes() + + def download_attachments(self): + """ Downloads this message attachments into memory. Need a call to 'attachment.save' to save them on disk. """ + + if not self._parent.has_attachments: + log.debug('Parent {} has no attachments, skipping out early.'.format(self._parent.__class__.__name__)) + return False + + if not self._parent.object_id: + raise RuntimeError('Attempted to download attachments of an unsaved {}'.format(self._parent.__class__.__name__)) + + url = self.build_url(self._endpoints.get('attachments').format(id=self._parent.object_id)) + + response = self._parent.con.get(url) + if not response: + return False + + attachments = response.json().get('value', []) + + # Everything received from the cloud must be passed with self._cloud_data_key + self.untrack = True + self.add({self._cloud_data_key: attachments}) + self.untrack = False + + # TODO: when it's a item attachment the attachment itself is not downloaded. We must download it... + # TODO: idea: retrieve the attachments ids' only with select and then download one by one. + return True + + def _update_attachments_to_cloud(self): + """ + Push new, unsaved attachments to the cloud and remove removed attachments + This method should not be called for non draft messages. + """ + + url = self.build_url(self._endpoints.get('attachments').format(id=self._parent.object_id)) + + # ! potencially several api requests can be made by this method. + + for attachment in self.__attachments: + if attachment.on_cloud is False: + # upload attachment: + response = self._parent.con.post(url, data=attachment.to_api_data()) + if not response: + return False + + data = response.json() + + # update attachment data + attachment.attachment_id = data.get('id') + attachment.content = data.get(self._cc('contentBytes'), None) + attachment.on_cloud = True + + for attachment in self.__removed_attachments: + if attachment.on_cloud and attachment.attachment_id is not None: + # delete attachment + url = self.build_url(self._endpoints.get('attachment').format(id=self._parent.object_id, ida=attachment.attachment_id)) + + response = self._parent.con.delete(url) + if not response: + return False + + self.__removed_attachments = [] # reset the removed attachments + + log.debug('Successfully updated attachments on {}'.format(self._parent.object_id)) + + return True diff --git a/O365/utils/utils.py b/O365/utils/utils.py new file mode 100644 index 0000000000000..1a1be48b06019 --- /dev/null +++ b/O365/utils/utils.py @@ -0,0 +1,432 @@ +import logging +from enum import Enum +import datetime as dt +import pytz +from collections import OrderedDict + +ME_RESOURCE = 'me' +USERS_RESOURCE = 'users' + +NEXT_LINK_KEYWORD = '@odata.nextLink' + +log = logging.getLogger(__name__) + +MAX_RECIPIENTS_PER_MESSAGE = 500 # Actual limit on Office 365 + + +class ImportanceLevel(Enum): + Normal = 'normal' + Low = 'low' + High = 'high' + + +class OutlookWellKnowFolderNames(Enum): + INBOX = 'Inbox' + JUNK = 'JunkEmail' + DELETED = 'DeletedItems' + DRAFTS = 'Drafts' + SENT = 'SentItems' + OUTBOX = 'Outbox' + + +class OneDriveWellKnowFolderNames(Enum): + DOCUMENTS = 'documents' + PHOTOS = 'photos' + CAMERA_ROLL = 'cameraroll' + APP_ROOT = 'approot' + MUSIC = 'music' + ATTACHMENTS = 'attachments' + + +class ChainOperator(Enum): + AND = 'and' + OR = 'or' + + +class TrackerSet(set): + """ A Custom Set that changes the casing of it's keys """ + + def __init__(self, *args, casing=None, **kwargs): + self.cc = casing + super().__init__(*args, **kwargs) + + def add(self, value): + value = self.cc(value) + super().add(value) + + +class ApiComponent: + """ Base class for all object interactions with the Cloud Service API + + Exposes common access methods to the api protocol within all Api objects + """ + + _cloud_data_key = '__cloud_data__' # wrapps cloud data with this dict key + _endpoints = {} # dict of all API service endpoints needed + + def __init__(self, *, protocol=None, main_resource=None, **kwargs): + """ Object initialization + :param protocol: A protocol class or instance to be used with this connection + :param main_resource: main_resource to be used in these API comunications + :param kwargs: Extra arguments + """ + self.protocol = protocol() if isinstance(protocol, type) else protocol + if self.protocol is None: + raise ValueError('Protocol not provided to Api Component') + self.main_resource = self._parse_resource(main_resource or protocol.default_resource) + self._base_url = '{}{}'.format(self.protocol.service_url, self.main_resource) + super().__init__() + + @staticmethod + def _parse_resource(resource): + resource = resource.strip() if resource else resource + """ Parses and completes resource information """ + if resource == ME_RESOURCE: + return resource + elif resource == USERS_RESOURCE: + return resource + elif '/' not in resource and USERS_RESOURCE not in resource: + # when for example accesing a shared mailbox the resouse is set to the email address. + # we have to prefix the email with the resource 'users/' so --> 'users/email_address' + return '{}/{}'.format(USERS_RESOURCE, resource) + else: + return resource + + def build_url(self, endpoint): + """ Returns a url for a given endpoint using the protocol service url """ + return '{}{}'.format(self._base_url, endpoint) + + def _gk(self, keyword): + """ Alias for protocol.get_service_keyword """ + return self.protocol.get_service_keyword(keyword) + + def _cc(self, dict_key): + """ Alias for protocol.convert_case """ + return self.protocol.convert_case(dict_key) + + def new_query(self, attribute=None): + return Query(attribute=attribute, protocol=self.protocol) + + q = new_query # alias for new query + + +class Pagination(ApiComponent): + """ Utility class that allows batching requests to the server """ + + def __init__(self, *, parent=None, data=None, constructor=None, next_link=None, limit=None): + """ + Returns an iterator that returns data until it's exhausted. Then will request more data + (same amount as the original request) to the server until this data is exhausted as well. + Stops when no more data exists or limit is reached. + + :param parent: the parent class. Must implement attributes: + con, api_version, main_resource + :param data: the start data to be return + :param constructor: the data constructor for the next batch. It can be a function. + :param next_link: the link to request more data to + :param limit: when to stop retrieving more data + """ + if parent is None: + raise ValueError('Parent must be another Api Component') + + super().__init__(protocol=parent.protocol, main_resource=parent.main_resource) + + self.parent = parent + self.con = parent.con + self.constructor = constructor + self.next_link = next_link + self.limit = limit + self.data = data if data else [] + + data_count = len(data) + if limit and limit < data_count: + self.data_count = limit + self.total_count = limit + else: + self.data_count = data_count + self.total_count = data_count + self.state = 0 + + def __str__(self): + return self.__repr__() + + def __repr__(self): + if callable(self.constructor): + return 'Pagination Iterator' + else: + return "'{}' Iterator".format(self.constructor.__name__ if self.constructor else 'Unknown') + + def __bool__(self): + return bool(self.data) or bool(self.next_link) + + def __iter__(self): + return self + + def __next__(self): + if self.state < self.data_count: + value = self.data[self.state] + self.state += 1 + return value + else: + if self.limit and self.total_count >= self.limit: + raise StopIteration() + + if self.next_link is None: + raise StopIteration() + + response = self.con.get(self.next_link) + if not response: + raise StopIteration() + + data = response.json() + + self.next_link = data.get(NEXT_LINK_KEYWORD, None) or None + data = data.get('value', []) + if self.constructor: + # Everything received from the cloud must be passed with self._cloud_data_key + if callable(self.constructor) and not isinstance(self.constructor, type): # it's callable but its not a Class + self.data = [self.constructor(value)(parent=self.parent, **{self._cloud_data_key: value}) for value in data] + else: + self.data = [self.constructor(parent=self.parent, **{self._cloud_data_key: value}) for value in data] + else: + self.data = data + + items_count = len(data) + if self.limit: + dif = self.limit - (self.total_count + items_count) + if dif < 0: + self.data = self.data[:dif] + self.next_link = None # stop batching + items_count = items_count + dif + if items_count: + self.data_count = items_count + self.total_count += items_count + self.state = 0 + value = self.data[self.state] + self.state += 1 + return value + else: + raise StopIteration() + + +class Query: + """ Helper to conform OData filters """ + _mapping = { + 'from': 'from/emailAddress/address', + 'to': 'toRecipients/emailAddress/address', + 'start': 'start/DateTime', + 'end': 'end/DateTime' + } + + def __init__(self, attribute=None, *, protocol): + self.protocol = protocol() if isinstance(protocol, type) else protocol + self._attribute = None + self._chain = None + self.new(attribute) + self._negation = False + self._filters = [] + self._order_by = OrderedDict() + self._selects = set() + + def __str__(self): + return 'Filter: {}\nOrder: {}\nSelect: {}'.format(self.get_filters(), self.get_order(), self.get_selects()) + + def __repr__(self): + return self.__str__() + + def select(self, *attributes): + """ + Adds the attribute to the $select parameter + :param attributes: the attributes tuple to select. If empty, the on_attribute previously set is added. + """ + if attributes: + for attribute in attributes: + attribute = self.protocol.convert_case(attribute) if attribute and isinstance(attribute, str) else None + if attribute: + if '/' in attribute: + # only parent attribute can be selected + attribute = attribute.split('/')[0] + self._selects.add(attribute) + else: + if self._attribute: + self._selects.add(self._attribute) + + return self + + def as_params(self): + """ Returns the filters and orders as query parameters""" + params = {} + if self.has_filters: + params['$filter'] = self.get_filters() + if self.has_order: + params['$orderby'] = self.get_order() + if self.has_selects: + params['$select'] = self.get_selects() + return params + + @property + def has_filters(self): + return bool(self._filters) + + @property + def has_order(self): + return bool(self._order_by) + + @property + def has_selects(self): + return bool(self._selects) + + def get_filters(self): + """ Returns the result filters """ + if self._filters: + filters_list = self._filters + if isinstance(filters_list[-1], Enum): + filters_list = filters_list[:-1] + return ' '.join([fs.value if isinstance(fs, Enum) else fs[1] for fs in filters_list]).strip() + else: + return None + + def get_order(self): + """ Returns the result order by clauses """ + # first get the filtered attributes in order as they must appear in the order_by first + if not self.has_order: + return None + filter_order_clauses = OrderedDict([(filter_attr[0], None) + for filter_attr in self._filters + if isinstance(filter_attr, tuple)]) + + # any order_by attribute that appears in the filters is ignored + order_by_dict = self._order_by.copy() + for filter_oc in filter_order_clauses.keys(): + direction = order_by_dict.pop(filter_oc, None) + filter_order_clauses[filter_oc] = direction + + filter_order_clauses.update(order_by_dict) # append any remaining order_by clause + + if filter_order_clauses: + return ','.join(['{} {}'.format(attribute, direction if direction else '').strip() + for attribute, direction in filter_order_clauses.items()]) + else: + return None + + def get_selects(self): + """ Returns the result select clause """ + if self._selects: + return ','.join(self._selects) + else: + return None + + def _get_mapping(self, attribute): + if attribute: + mapping = self._mapping.get(attribute) + if mapping: + attribute = '/'.join([self.protocol.convert_case(step) for step in mapping.split('/')]) + else: + attribute = self.protocol.convert_case(attribute) + return attribute + return None + + def new(self, attribute, operation=ChainOperator.AND): + if isinstance(operation, str): + operation = ChainOperator(operation) + self._chain = operation + self._attribute = self._get_mapping(attribute) if attribute else None + self._negation = False + return self + + def clear_filters(self): + self._filters = [] + + def clear(self): + self._filters = [] + self._order_by = OrderedDict() + self._selects = set() + self.new(None) + return self + + def negate(self): + self._negation = not self._negation + return self + + def chain(self, operation=ChainOperator.AND): + if isinstance(operation, str): + operation = ChainOperator(operation) + self._chain = operation + return self + + def on_attribute(self, attribute): + self._attribute = self._get_mapping(attribute) + return self + + def _add_filter(self, filter_str): + if self._attribute: + if self._filters and not isinstance(self._filters[-1], ChainOperator): + self._filters.append(self._chain) + self._filters.append((self._attribute, filter_str)) + else: + raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)') + + def _parse_filter_word(self, word): + """ Converts the word parameter into the correct format """ + if isinstance(word, str): + word = "'{}'".format(word) + elif isinstance(word, dt.date): + if isinstance(word, dt.datetime): + if word.tzinfo is None: + # if it's a naive datetime, localize the datetime. + word = self.protocol.timezone.localize(word) # localize datetime into local tz + if word.tzinfo != pytz.utc: + word = word.astimezone(pytz.utc) # transform local datetime to utc + word = '{}'.format(word.isoformat()) # convert datetime to isoformat + elif isinstance(word, bool): + word = str(word).lower() + return word + + def logical_operator(self, operation, word): + word = self._parse_filter_word(word) + sentence = '{} {} {} {}'.format('not' if self._negation else '', self._attribute, operation, word).strip() + self._add_filter(sentence) + return self + + def equals(self, word): + return self.logical_operator('eq', word) + + def unequal(self, word): + return self.logical_operator('ne', word) + + def greater(self, word): + return self.logical_operator('gt', word) + + def greater_equal(self, word): + return self.logical_operator('ge', word) + + def less(self, word): + return self.logical_operator('lt', word) + + def less_equal(self, word): + return self.logical_operator('le', word) + + def function(self, function_name, word): + word = self._parse_filter_word(word) + + self._add_filter( + "{} {}({}, {})".format('not' if self._negation else '', function_name, self._attribute, word).strip()) + return self + + def contains(self, word): + return self.function('contains', word) + + def startswith(self, word): + return self.function('startswith', word) + + def endswith(self, word): + return self.function('endswith', word) + + def order_by(self, attribute=None, *, ascending=True): + """ applies a order_by clause""" + attribute = self._get_mapping(attribute) or self._attribute + if attribute: + self._order_by[attribute] = None if ascending else 'desc' + else: + raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)') + return self diff --git a/O365/utils/windows_tz.py b/O365/utils/windows_tz.py new file mode 100644 index 0000000000000..5371625371343 --- /dev/null +++ b/O365/utils/windows_tz.py @@ -0,0 +1,470 @@ +""" +Mapping from iana timezones to windows timezones and vice versa +""" + +IANA_TO_WIN = { + 'Africa/Abidjan': 'Greenwich Standard Time', + 'Africa/Accra': 'Greenwich Standard Time', + 'Africa/Addis_Ababa': 'E. Africa Standard Time', + 'Africa/Algiers': 'W. Central Africa Standard Time', + 'Africa/Asmera': 'E. Africa Standard Time', + 'Africa/Bamako': 'Greenwich Standard Time', + 'Africa/Bangui': 'W. Central Africa Standard Time', + 'Africa/Banjul': 'Greenwich Standard Time', + 'Africa/Bissau': 'Greenwich Standard Time', + 'Africa/Blantyre': 'South Africa Standard Time', + 'Africa/Brazzaville': 'W. Central Africa Standard Time', + 'Africa/Bujumbura': 'South Africa Standard Time', + 'Africa/Cairo': 'Egypt Standard Time', + 'Africa/Casablanca': 'Morocco Standard Time', + 'Africa/Ceuta': 'Romance Standard Time', + 'Africa/Conakry': 'Greenwich Standard Time', + 'Africa/Dakar': 'Greenwich Standard Time', + 'Africa/Dar_es_Salaam': 'E. Africa Standard Time', + 'Africa/Djibouti': 'E. Africa Standard Time', + 'Africa/Douala': 'W. Central Africa Standard Time', + 'Africa/El_Aaiun': 'Morocco Standard Time', + 'Africa/Freetown': 'Greenwich Standard Time', + 'Africa/Gaborone': 'South Africa Standard Time', + 'Africa/Harare': 'South Africa Standard Time', + 'Africa/Johannesburg': 'South Africa Standard Time', + 'Africa/Juba': 'E. Africa Standard Time', + 'Africa/Kampala': 'E. Africa Standard Time', + 'Africa/Khartoum': 'Sudan Standard Time', + 'Africa/Kigali': 'South Africa Standard Time', + 'Africa/Kinshasa': 'W. Central Africa Standard Time', + 'Africa/Lagos': 'W. Central Africa Standard Time', + 'Africa/Libreville': 'W. Central Africa Standard Time', + 'Africa/Lome': 'Greenwich Standard Time', + 'Africa/Luanda': 'W. Central Africa Standard Time', + 'Africa/Lubumbashi': 'South Africa Standard Time', + 'Africa/Lusaka': 'South Africa Standard Time', + 'Africa/Malabo': 'W. Central Africa Standard Time', + 'Africa/Maputo': 'South Africa Standard Time', + 'Africa/Maseru': 'South Africa Standard Time', + 'Africa/Mbabane': 'South Africa Standard Time', + 'Africa/Mogadishu': 'E. Africa Standard Time', + 'Africa/Monrovia': 'Greenwich Standard Time', + 'Africa/Nairobi': 'E. Africa Standard Time', + 'Africa/Ndjamena': 'W. Central Africa Standard Time', + 'Africa/Niamey': 'W. Central Africa Standard Time', + 'Africa/Nouakchott': 'Greenwich Standard Time', + 'Africa/Ouagadougou': 'Greenwich Standard Time', + 'Africa/Porto-Novo': 'W. Central Africa Standard Time', + 'Africa/Sao_Tome': 'W. Central Africa Standard Time', + 'Africa/Tripoli': 'Libya Standard Time', + 'Africa/Tunis': 'W. Central Africa Standard Time', + 'Africa/Windhoek': 'Namibia Standard Time', + 'America/Adak': 'Aleutian Standard Time', + 'America/Anchorage': 'Alaskan Standard Time', + 'America/Anguilla': 'SA Western Standard Time', + 'America/Antigua': 'SA Western Standard Time', + 'America/Araguaina': 'Tocantins Standard Time', + 'America/Argentina/La_Rioja': 'Argentina Standard Time', + 'America/Argentina/Rio_Gallegos': 'Argentina Standard Time', + 'America/Argentina/Salta': 'Argentina Standard Time', + 'America/Argentina/San_Juan': 'Argentina Standard Time', + 'America/Argentina/San_Luis': 'Argentina Standard Time', + 'America/Argentina/Tucuman': 'Argentina Standard Time', + 'America/Argentina/Ushuaia': 'Argentina Standard Time', + 'America/Aruba': 'SA Western Standard Time', + 'America/Asuncion': 'Paraguay Standard Time', + 'America/Bahia': 'Bahia Standard Time', + 'America/Bahia_Banderas': 'Central Standard Time (Mexico)', + 'America/Barbados': 'SA Western Standard Time', + 'America/Belem': 'SA Eastern Standard Time', + 'America/Belize': 'Central America Standard Time', + 'America/Blanc-Sablon': 'SA Western Standard Time', + 'America/Boa_Vista': 'SA Western Standard Time', + 'America/Bogota': 'SA Pacific Standard Time', + 'America/Boise': 'Mountain Standard Time', + 'America/Buenos_Aires': 'Argentina Standard Time', + 'America/Cambridge_Bay': 'Mountain Standard Time', + 'America/Campo_Grande': 'Central Brazilian Standard Time', + 'America/Cancun': 'Eastern Standard Time (Mexico)', + 'America/Caracas': 'Venezuela Standard Time', + 'America/Catamarca': 'Argentina Standard Time', + 'America/Cayenne': 'SA Eastern Standard Time', + 'America/Cayman': 'SA Pacific Standard Time', + 'America/Chicago': 'Central Standard Time', + 'America/Chihuahua': 'Mountain Standard Time (Mexico)', + 'America/Coral_Harbour': 'SA Pacific Standard Time', + 'America/Cordoba': 'Argentina Standard Time', + 'America/Costa_Rica': 'Central America Standard Time', + 'America/Creston': 'US Mountain Standard Time', + 'America/Cuiaba': 'Central Brazilian Standard Time', + 'America/Curacao': 'SA Western Standard Time', + 'America/Danmarkshavn': 'UTC', + 'America/Dawson': 'Pacific Standard Time', + 'America/Dawson_Creek': 'US Mountain Standard Time', + 'America/Denver': 'Mountain Standard Time', + 'America/Detroit': 'Eastern Standard Time', + 'America/Dominica': 'SA Western Standard Time', + 'America/Edmonton': 'Mountain Standard Time', + 'America/Eirunepe': 'SA Pacific Standard Time', + 'America/El_Salvador': 'Central America Standard Time', + 'America/Fort_Nelson': 'US Mountain Standard Time', + 'America/Fortaleza': 'SA Eastern Standard Time', + 'America/Glace_Bay': 'Atlantic Standard Time', + 'America/Godthab': 'Greenland Standard Time', + 'America/Goose_Bay': 'Atlantic Standard Time', + 'America/Grand_Turk': 'Turks And Caicos Standard Time', + 'America/Grenada': 'SA Western Standard Time', + 'America/Guadeloupe': 'SA Western Standard Time', + 'America/Guatemala': 'Central America Standard Time', + 'America/Guayaquil': 'SA Pacific Standard Time', + 'America/Guyana': 'SA Western Standard Time', + 'America/Halifax': 'Atlantic Standard Time', + 'America/Havana': 'Cuba Standard Time', + 'America/Hermosillo': 'US Mountain Standard Time', + 'America/Indiana/Knox': 'Central Standard Time', + 'America/Indiana/Marengo': 'US Eastern Standard Time', + 'America/Indiana/Petersburg': 'Eastern Standard Time', + 'America/Indiana/Tell_City': 'Central Standard Time', + 'America/Indiana/Vevay': 'US Eastern Standard Time', + 'America/Indiana/Vincennes': 'Eastern Standard Time', + 'America/Indiana/Winamac': 'Eastern Standard Time', + 'America/Indianapolis': 'US Eastern Standard Time', + 'America/Inuvik': 'Mountain Standard Time', + 'America/Iqaluit': 'Eastern Standard Time', + 'America/Jamaica': 'SA Pacific Standard Time', + 'America/Jujuy': 'Argentina Standard Time', + 'America/Juneau': 'Alaskan Standard Time', + 'America/Kentucky/Monticello': 'Eastern Standard Time', + 'America/Kralendijk': 'SA Western Standard Time', + 'America/La_Paz': 'SA Western Standard Time', + 'America/Lima': 'SA Pacific Standard Time', + 'America/Los_Angeles': 'Pacific Standard Time', + 'America/Louisville': 'Eastern Standard Time', + 'America/Lower_Princes': 'SA Western Standard Time', + 'America/Maceio': 'SA Eastern Standard Time', + 'America/Managua': 'Central America Standard Time', + 'America/Manaus': 'SA Western Standard Time', + 'America/Marigot': 'SA Western Standard Time', + 'America/Martinique': 'SA Western Standard Time', + 'America/Matamoros': 'Central Standard Time', + 'America/Mazatlan': 'Mountain Standard Time (Mexico)', + 'America/Mendoza': 'Argentina Standard Time', + 'America/Menominee': 'Central Standard Time', + 'America/Merida': 'Central Standard Time (Mexico)', + 'America/Metlakatla': 'Alaskan Standard Time', + 'America/Mexico_City': 'Central Standard Time (Mexico)', + 'America/Miquelon': 'Saint Pierre Standard Time', + 'America/Moncton': 'Atlantic Standard Time', + 'America/Monterrey': 'Central Standard Time (Mexico)', + 'America/Montevideo': 'Montevideo Standard Time', + 'America/Montreal': 'Eastern Standard Time', + 'America/Montserrat': 'SA Western Standard Time', + 'America/Nassau': 'Eastern Standard Time', + 'America/New_York': 'Eastern Standard Time', + 'America/Nipigon': 'Eastern Standard Time', + 'America/Nome': 'Alaskan Standard Time', + 'America/Noronha': 'UTC-02', + 'America/North_Dakota/Beulah': 'Central Standard Time', + 'America/North_Dakota/Center': 'Central Standard Time', + 'America/North_Dakota/New_Salem': 'Central Standard Time', + 'America/Ojinaga': 'Mountain Standard Time', + 'America/Panama': 'SA Pacific Standard Time', + 'America/Pangnirtung': 'Eastern Standard Time', + 'America/Paramaribo': 'SA Eastern Standard Time', + 'America/Phoenix': 'US Mountain Standard Time', + 'America/Port-au-Prince': 'Haiti Standard Time', + 'America/Port_of_Spain': 'SA Western Standard Time', + 'America/Porto_Velho': 'SA Western Standard Time', + 'America/Puerto_Rico': 'SA Western Standard Time', + 'America/Punta_Arenas': 'Magallanes Standard Time', + 'America/Rainy_River': 'Central Standard Time', + 'America/Rankin_Inlet': 'Central Standard Time', + 'America/Recife': 'SA Eastern Standard Time', + 'America/Regina': 'Canada Central Standard Time', + 'America/Resolute': 'Central Standard Time', + 'America/Rio_Branco': 'SA Pacific Standard Time', + 'America/Santa_Isabel': 'Pacific Standard Time (Mexico)', + 'America/Santarem': 'SA Eastern Standard Time', + 'America/Santiago': 'Pacific SA Standard Time', + 'America/Santo_Domingo': 'SA Western Standard Time', + 'America/Sao_Paulo': 'E. South America Standard Time', + 'America/Scoresbysund': 'Azores Standard Time', + 'America/Sitka': 'Alaskan Standard Time', + 'America/St_Barthelemy': 'SA Western Standard Time', + 'America/St_Johns': 'Newfoundland Standard Time', + 'America/St_Kitts': 'SA Western Standard Time', + 'America/St_Lucia': 'SA Western Standard Time', + 'America/St_Thomas': 'SA Western Standard Time', + 'America/St_Vincent': 'SA Western Standard Time', + 'America/Swift_Current': 'Canada Central Standard Time', + 'America/Tegucigalpa': 'Central America Standard Time', + 'America/Thule': 'Atlantic Standard Time', + 'America/Thunder_Bay': 'Eastern Standard Time', + 'America/Tijuana': 'Pacific Standard Time (Mexico)', + 'America/Toronto': 'Eastern Standard Time', + 'America/Tortola': 'SA Western Standard Time', + 'America/Vancouver': 'Pacific Standard Time', + 'America/Whitehorse': 'Pacific Standard Time', + 'America/Winnipeg': 'Central Standard Time', + 'America/Yakutat': 'Alaskan Standard Time', + 'America/Yellowknife': 'Mountain Standard Time', + 'Antarctica/Casey': 'W. Australia Standard Time', + 'Antarctica/Davis': 'SE Asia Standard Time', + 'Antarctica/DumontDUrville': 'West Pacific Standard Time', + 'Antarctica/Macquarie': 'Central Pacific Standard Time', + 'Antarctica/Mawson': 'West Asia Standard Time', + 'Antarctica/McMurdo': 'New Zealand Standard Time', + 'Antarctica/Palmer': 'Magallanes Standard Time', + 'Antarctica/Rothera': 'SA Eastern Standard Time', + 'Antarctica/Syowa': 'E. Africa Standard Time', + 'Antarctica/Vostok': 'Central Asia Standard Time', + 'Arctic/Longyearbyen': 'W. Europe Standard Time', + 'Asia/Aden': 'Arab Standard Time', + 'Asia/Almaty': 'Central Asia Standard Time', + 'Asia/Amman': 'Jordan Standard Time', + 'Asia/Anadyr': 'Russia Time Zone 11', + 'Asia/Aqtau': 'West Asia Standard Time', + 'Asia/Aqtobe': 'West Asia Standard Time', + 'Asia/Ashgabat': 'West Asia Standard Time', + 'Asia/Atyrau': 'West Asia Standard Time', + 'Asia/Baghdad': 'Arabic Standard Time', + 'Asia/Bahrain': 'Arab Standard Time', + 'Asia/Baku': 'Azerbaijan Standard Time', + 'Asia/Bangkok': 'SE Asia Standard Time', + 'Asia/Barnaul': 'Altai Standard Time', + 'Asia/Beirut': 'Middle East Standard Time', + 'Asia/Bishkek': 'Central Asia Standard Time', + 'Asia/Brunei': 'Singapore Standard Time', + 'Asia/Calcutta': 'India Standard Time', + 'Asia/Chita': 'Transbaikal Standard Time', + 'Asia/Choibalsan': 'Ulaanbaatar Standard Time', + 'Asia/Colombo': 'Sri Lanka Standard Time', + 'Asia/Damascus': 'Syria Standard Time', + 'Asia/Dhaka': 'Bangladesh Standard Time', + 'Asia/Dili': 'Tokyo Standard Time', + 'Asia/Dubai': 'Arabian Standard Time', + 'Asia/Dushanbe': 'West Asia Standard Time', + 'Asia/Famagusta': 'Turkey Standard Time', + 'Asia/Gaza': 'West Bank Standard Time', + 'Asia/Hebron': 'West Bank Standard Time', + 'Asia/Hong_Kong': 'China Standard Time', + 'Asia/Hovd': 'W. Mongolia Standard Time', + 'Asia/Irkutsk': 'North Asia East Standard Time', + 'Asia/Jakarta': 'SE Asia Standard Time', + 'Asia/Jayapura': 'Tokyo Standard Time', + 'Asia/Jerusalem': 'Israel Standard Time', + 'Asia/Kabul': 'Afghanistan Standard Time', + 'Asia/Kamchatka': 'Russia Time Zone 11', + 'Asia/Karachi': 'Pakistan Standard Time', + 'Asia/Katmandu': 'Nepal Standard Time', + 'Asia/Khandyga': 'Yakutsk Standard Time', + 'Asia/Kolkata': 'India Standard Time', + 'Asia/Krasnoyarsk': 'North Asia Standard Time', + 'Asia/Kuala_Lumpur': 'Singapore Standard Time', + 'Asia/Kuching': 'Singapore Standard Time', + 'Asia/Kuwait': 'Arab Standard Time', + 'Asia/Macau': 'China Standard Time', + 'Asia/Magadan': 'Magadan Standard Time', + 'Asia/Makassar': 'Singapore Standard Time', + 'Asia/Manila': 'Singapore Standard Time', + 'Asia/Muscat': 'Arabian Standard Time', + 'Asia/Nicosia': 'GTB Standard Time', + 'Asia/Novokuznetsk': 'North Asia Standard Time', + 'Asia/Novosibirsk': 'N. Central Asia Standard Time', + 'Asia/Omsk': 'Omsk Standard Time', + 'Asia/Oral': 'West Asia Standard Time', + 'Asia/Phnom_Penh': 'SE Asia Standard Time', + 'Asia/Pontianak': 'SE Asia Standard Time', + 'Asia/Pyongyang': 'North Korea Standard Time', + 'Asia/Qatar': 'Arab Standard Time', + 'Asia/Qyzylorda': 'Central Asia Standard Time', + 'Asia/Rangoon': 'Myanmar Standard Time', + 'Asia/Riyadh': 'Arab Standard Time', + 'Asia/Saigon': 'SE Asia Standard Time', + 'Asia/Sakhalin': 'Sakhalin Standard Time', + 'Asia/Samarkand': 'West Asia Standard Time', + 'Asia/Seoul': 'Korea Standard Time', + 'Asia/Shanghai': 'China Standard Time', + 'Asia/Singapore': 'Singapore Standard Time', + 'Asia/Srednekolymsk': 'Russia Time Zone 10', + 'Asia/Taipei': 'Taipei Standard Time', + 'Asia/Tashkent': 'West Asia Standard Time', + 'Asia/Tbilisi': 'Georgian Standard Time', + 'Asia/Tehran': 'Iran Standard Time', + 'Asia/Thimphu': 'Bangladesh Standard Time', + 'Asia/Tokyo': 'Tokyo Standard Time', + 'Asia/Tomsk': 'Tomsk Standard Time', + 'Asia/Ulaanbaatar': 'Ulaanbaatar Standard Time', + 'Asia/Urumqi': 'Central Asia Standard Time', + 'Asia/Ust-Nera': 'Vladivostok Standard Time', + 'Asia/Vientiane': 'SE Asia Standard Time', + 'Asia/Vladivostok': 'Vladivostok Standard Time', + 'Asia/Yakutsk': 'Yakutsk Standard Time', + 'Asia/Yekaterinburg': 'Ekaterinburg Standard Time', + 'Asia/Yerevan': 'Caucasus Standard Time', + 'Atlantic/Azores': 'Azores Standard Time', + 'Atlantic/Bermuda': 'Atlantic Standard Time', + 'Atlantic/Canary': 'GMT Standard Time', + 'Atlantic/Cape_Verde': 'Cape Verde Standard Time', + 'Atlantic/Faeroe': 'GMT Standard Time', + 'Atlantic/Madeira': 'GMT Standard Time', + 'Atlantic/Reykjavik': 'Greenwich Standard Time', + 'Atlantic/South_Georgia': 'UTC-02', + 'Atlantic/St_Helena': 'Greenwich Standard Time', + 'Atlantic/Stanley': 'SA Eastern Standard Time', + 'Australia/Adelaide': 'Cen. Australia Standard Time', + 'Australia/Brisbane': 'E. Australia Standard Time', + 'Australia/Broken_Hill': 'Cen. Australia Standard Time', + 'Australia/Currie': 'Tasmania Standard Time', + 'Australia/Darwin': 'AUS Central Standard Time', + 'Australia/Eucla': 'Aus Central W. Standard Time', + 'Australia/Hobart': 'Tasmania Standard Time', + 'Australia/Lindeman': 'E. Australia Standard Time', + 'Australia/Lord_Howe': 'Lord Howe Standard Time', + 'Australia/Melbourne': 'AUS Eastern Standard Time', + 'Australia/Perth': 'W. Australia Standard Time', + 'Australia/Sydney': 'AUS Eastern Standard Time', + 'CST6CDT': 'Central Standard Time', + 'EST5EDT': 'Eastern Standard Time', + 'Etc/GMT': 'UTC', + 'Etc/GMT+1': 'Cape Verde Standard Time', + 'Etc/GMT+10': 'Hawaiian Standard Time', + 'Etc/GMT+11': 'UTC-11', + 'Etc/GMT+12': 'Dateline Standard Time', + 'Etc/GMT+2': 'UTC-02', + 'Etc/GMT+3': 'SA Eastern Standard Time', + 'Etc/GMT+4': 'SA Western Standard Time', + 'Etc/GMT+5': 'SA Pacific Standard Time', + 'Etc/GMT+6': 'Central America Standard Time', + 'Etc/GMT+7': 'US Mountain Standard Time', + 'Etc/GMT+8': 'UTC-08', + 'Etc/GMT+9': 'UTC-09', + 'Etc/GMT-1': 'W. Central Africa Standard Time', + 'Etc/GMT-10': 'West Pacific Standard Time', + 'Etc/GMT-11': 'Central Pacific Standard Time', + 'Etc/GMT-12': 'UTC+12', + 'Etc/GMT-13': 'UTC+13', + 'Etc/GMT-14': 'Line Islands Standard Time', + 'Etc/GMT-2': 'South Africa Standard Time', + 'Etc/GMT-3': 'E. Africa Standard Time', + 'Etc/GMT-4': 'Arabian Standard Time', + 'Etc/GMT-5': 'West Asia Standard Time', + 'Etc/GMT-6': 'Central Asia Standard Time', + 'Etc/GMT-7': 'SE Asia Standard Time', + 'Etc/GMT-8': 'Singapore Standard Time', + 'Etc/GMT-9': 'Tokyo Standard Time', + 'Etc/UTC': 'UTC', + 'Europe/Amsterdam': 'W. Europe Standard Time', + 'Europe/Andorra': 'W. Europe Standard Time', + 'Europe/Astrakhan': 'Astrakhan Standard Time', + 'Europe/Athens': 'GTB Standard Time', + 'Europe/Belgrade': 'Central Europe Standard Time', + 'Europe/Berlin': 'W. Europe Standard Time', + 'Europe/Bratislava': 'Central Europe Standard Time', + 'Europe/Brussels': 'Romance Standard Time', + 'Europe/Bucharest': 'GTB Standard Time', + 'Europe/Budapest': 'Central Europe Standard Time', + 'Europe/Busingen': 'W. Europe Standard Time', + 'Europe/Chisinau': 'E. Europe Standard Time', + 'Europe/Copenhagen': 'Romance Standard Time', + 'Europe/Dublin': 'GMT Standard Time', + 'Europe/Gibraltar': 'W. Europe Standard Time', + 'Europe/Guernsey': 'GMT Standard Time', + 'Europe/Helsinki': 'FLE Standard Time', + 'Europe/Isle_of_Man': 'GMT Standard Time', + 'Europe/Istanbul': 'Turkey Standard Time', + 'Europe/Jersey': 'GMT Standard Time', + 'Europe/Kaliningrad': 'Kaliningrad Standard Time', + 'Europe/Kiev': 'FLE Standard Time', + 'Europe/Kirov': 'Russian Standard Time', + 'Europe/Lisbon': 'GMT Standard Time', + 'Europe/Ljubljana': 'Central Europe Standard Time', + 'Europe/London': 'GMT Standard Time', + 'Europe/Luxembourg': 'W. Europe Standard Time', + 'Europe/Madrid': 'Romance Standard Time', + 'Europe/Malta': 'W. Europe Standard Time', + 'Europe/Mariehamn': 'FLE Standard Time', + 'Europe/Minsk': 'Belarus Standard Time', + 'Europe/Monaco': 'W. Europe Standard Time', + 'Europe/Moscow': 'Russian Standard Time', + 'Europe/Oslo': 'W. Europe Standard Time', + 'Europe/Paris': 'Romance Standard Time', + 'Europe/Podgorica': 'Central Europe Standard Time', + 'Europe/Prague': 'Central Europe Standard Time', + 'Europe/Riga': 'FLE Standard Time', + 'Europe/Rome': 'W. Europe Standard Time', + 'Europe/Samara': 'Russia Time Zone 3', + 'Europe/San_Marino': 'W. Europe Standard Time', + 'Europe/Sarajevo': 'Central European Standard Time', + 'Europe/Saratov': 'Saratov Standard Time', + 'Europe/Simferopol': 'Russian Standard Time', + 'Europe/Skopje': 'Central European Standard Time', + 'Europe/Sofia': 'FLE Standard Time', + 'Europe/Stockholm': 'W. Europe Standard Time', + 'Europe/Tallinn': 'FLE Standard Time', + 'Europe/Tirane': 'Central Europe Standard Time', + 'Europe/Ulyanovsk': 'Astrakhan Standard Time', + 'Europe/Uzhgorod': 'FLE Standard Time', + 'Europe/Vaduz': 'W. Europe Standard Time', + 'Europe/Vatican': 'W. Europe Standard Time', + 'Europe/Vienna': 'W. Europe Standard Time', + 'Europe/Vilnius': 'FLE Standard Time', + 'Europe/Volgograd': 'Russian Standard Time', + 'Europe/Warsaw': 'Central European Standard Time', + 'Europe/Zagreb': 'Central European Standard Time', + 'Europe/Zaporozhye': 'FLE Standard Time', + 'Europe/Zurich': 'W. Europe Standard Time', + 'GMT': 'GMT Standard Time', + 'GB': 'GMT Standard Time', + 'Indian/Antananarivo': 'E. Africa Standard Time', + 'Indian/Chagos': 'Central Asia Standard Time', + 'Indian/Christmas': 'SE Asia Standard Time', + 'Indian/Cocos': 'Myanmar Standard Time', + 'Indian/Comoro': 'E. Africa Standard Time', + 'Indian/Kerguelen': 'West Asia Standard Time', + 'Indian/Mahe': 'Mauritius Standard Time', + 'Indian/Maldives': 'West Asia Standard Time', + 'Indian/Mauritius': 'Mauritius Standard Time', + 'Indian/Mayotte': 'E. Africa Standard Time', + 'Indian/Reunion': 'Mauritius Standard Time', + 'MST7MDT': 'Mountain Standard Time', + 'PST8PDT': 'Pacific Standard Time', + 'Pacific/Apia': 'Samoa Standard Time', + 'Pacific/Auckland': 'New Zealand Standard Time', + 'Pacific/Bougainville': 'Bougainville Standard Time', + 'Pacific/Chatham': 'Chatham Islands Standard Time', + 'Pacific/Easter': 'Easter Island Standard Time', + 'Pacific/Efate': 'Central Pacific Standard Time', + 'Pacific/Enderbury': 'UTC+13', + 'Pacific/Fakaofo': 'UTC+13', + 'Pacific/Fiji': 'Fiji Standard Time', + 'Pacific/Funafuti': 'UTC+12', + 'Pacific/Galapagos': 'Central America Standard Time', + 'Pacific/Gambier': 'UTC-09', + 'Pacific/Guadalcanal': 'Central Pacific Standard Time', + 'Pacific/Guam': 'West Pacific Standard Time', + 'Pacific/Honolulu': 'Hawaiian Standard Time', + 'Pacific/Johnston': 'Hawaiian Standard Time', + 'Pacific/Kiritimati': 'Line Islands Standard Time', + 'Pacific/Kosrae': 'Central Pacific Standard Time', + 'Pacific/Kwajalein': 'UTC+12', + 'Pacific/Majuro': 'UTC+12', + 'Pacific/Marquesas': 'Marquesas Standard Time', + 'Pacific/Midway': 'UTC-11', + 'Pacific/Nauru': 'UTC+12', + 'Pacific/Niue': 'UTC-11', + 'Pacific/Norfolk': 'Norfolk Standard Time', + 'Pacific/Noumea': 'Central Pacific Standard Time', + 'Pacific/Pago_Pago': 'UTC-11', + 'Pacific/Palau': 'Tokyo Standard Time', + 'Pacific/Pitcairn': 'UTC-08', + 'Pacific/Ponape': 'Central Pacific Standard Time', + 'Pacific/Port_Moresby': 'West Pacific Standard Time', + 'Pacific/Rarotonga': 'Hawaiian Standard Time', + 'Pacific/Saipan': 'West Pacific Standard Time', + 'Pacific/Tahiti': 'Hawaiian Standard Time', + 'Pacific/Tarawa': 'UTC+12', + 'Pacific/Tongatapu': 'Tonga Standard Time', + 'Pacific/Truk': 'West Pacific Standard Time', + 'Pacific/Wake': 'UTC+12', + 'Pacific/Wallis': 'UTC+12', + 'UTC': 'UTC', +} + +WIN_TO_IANA = {v: k for k, v in IANA_TO_WIN.items()} diff --git a/README.md b/README.md index 89e086ae64450..acface8061343 100644 --- a/README.md +++ b/README.md @@ -1,235 +1,620 @@ -# Python-O365 - Office365 for you server +# pyo365 - Microsoft Graph and Office 365 API made easy -The objective O365 is to make it easy to make utilities that are to be run against an Office 365 account. If you wanted to script sending an email it could be as simple as: +This project aims is to make it easy to interact with Microsoft Graph and Office 365 Email, Contacts, Calendar, OneDrive, etc. + +This project is inspired on the super work done by [Toben Archer](https://github.com/Narcolapser) [Python-O365](https://github.com/Narcolapser/python-o365). +The oauth part is based on the work done by [Royce Melborn](https://github.com/roycem90) which is now integrated with the original project. + +I just want to make this project different in almost every sense, and make it also more pythonic. +So I ended up rewriting the whole project from scratch. + +The result is a package that provides a lot of the Microsoft Graph and Office 365 API capabilities. + +This is for example how you send a message: ```python -from O365 import Message -authenticiation = ('YourAccount@office365.com','YourPassword') -m = Message(auth=authenticiation) -m.setRecipients('reciving@office365.com') -m.setSubject('I made an email script.') -m.setBody('Talk to the computer, cause the human does not want to hear it any more.') -m.sendMessage() +from pyo365 import Account + +credentials = ('client_id', 'client_secret') + +account = Account(credentials) +m = account.new_message() +m.to.add('to_example@example.com') +m.subject = 'Testing!' +m.body = "George Best quote: I've stopped drinking, but only while I'm asleep." +m.send() ``` -To keep the library simple but powerful there are wrapper methods to access most of the attributes: + +**Python 3.4 is the minimum required**... I was very tempted to just go for 3.6 and use f-strings. Those are fantastic! + +This project was also a learning resource for me. This is a list of not so common python characteristics used in this project: +- New unpacking technics: `def method(argument, *, with_name=None, **other_params):` +- Enums: `from enum import Enum` +- Factory paradigm +- Package organization +- Timezone conversion and timezone aware datetimes +- Etc. (see the code!) + +> **This project is in early development.** Changes that can break your code may be commited. If you want to help please feel free to fork and make pull requests. + + +What follows is kind of a wiki... but you will get more insights by looking at the code. + +## Table of contents + +- [Install](#install) +- [Protocols](#protocols) +- [Authentication](#authentication) +- [Account Class and Modularity](#account) +- [MailBox](#mailbox) +- [AddressBook](#addressbook) +- [Calendar](#calendar) +- [OneDrive](#onedrive) +- [Sharepoint](#sharepoint) +- [Utils](#utils) + + +## Install +pyo365 is available on pypi.org. Simply run `pip install pyo365` to install it. + +Project dependencies installed by pip: + - requests + - requests-oauthlib + - beatifulsoup4 + - stringcase + - python-dateutil + - tzlocal + - pytz + + The first step to be able to work with this library is to register an application and retrieve the auth token. See [Authentication](#authentication). + +## Protocols +Protocols handles the aspects of comunications between different APIs. +This project uses by default either the Office 365 APIs or Microsoft Graph APIs. +But, you can use many other Microsoft APIs as long as you implement the protocol needed. + +You can use one or the other: + +- `MSGraphProtocol` to use the [Microsoft Graph API](https://developer.microsoft.com/en-us/graph/docs/concepts/overview) +- `MSOffice365Protocol` to use the [Office 365 API](https://msdn.microsoft.com/en-us/office/office365/api/api-catalog) + +Both protocols are similar but consider the following: + +Reasons to use `MSGraphProtocol`: +- It is the recommended Protocol by Microsoft. +- It can access more resources over Office 365 (for example OneDrive) + +Reasons to use `MSOffice365Protocol`: +- It can send emails with attachments up to 150 MB. MSGraph only allows 4MB on each request. + +The default protocol used by the `Account` Class is `MSGraphProtocol`. + +You can implement your own protocols by inheriting from `Protocol` to communicate with other Microsoft APIs. + +You can instantiate protocols like this: ```python -m.setBody('a body!') +from pyo365 import MSGraphProtocol + +# try the api version beta of the Microsoft Graph endpoint. +protocol = MSGraphProtocol(api_version='beta') # MSGraphProtocol defaults to v1.0 api version ``` -But all attributes listed on the documenation for the [Office365 API](https://msdn.microsoft.com/office/office365/APi/api-catalog) are available through the json representation stored in the instance of every O365 object: +##### Resources: +Each API endpoint requires a resource. This usually defines the owner of the data. +Every protocol defaults to resource 'ME'. 'ME' is the user which has given consent, but you can change this behaviour by providing a different default resource to the protocol constructor. + +For example when accesing a shared mailbox: + + ```python -if m.json['IsReadReceiptRequested']: - m.reply('Got it.') +# ... +account = Account(credentials=my_credentials, main_resource='shared_mailbox@example.com') +# Any instance created using account will inherit the resource defined for account. ``` -## Table of contents +This can be done however at any point. For example at the protocol level: +```python +# ... +my_protocol = MSGraphProtocol(default_resource='shared_mailbox@example.com') -- [Email](#email) -- [Calendar](#calendar) -- [Contacts](#contacts) -- [Connection](#connection) -- [FluentInbox](#fluent-inbox) - -## Email -There are two classes for working with emails in O365. -#### Inbox -A collection of emails. This is used when ever you are requesting an email or emails. It can be set with filters so that you only download the emails which your script is interested in. -#### Message -An actual email with all it's associated data. +account = Account(credentials=my_credentials, protocol=my_protocol) + +# now account is accesing the shared_mailbox@example.com in every api call. +shared_mailbox_messages = account.mailbox().get_messages() +``` + + +Instead of defining the resource used at the account or protocol level, you can provide it per use case as follows: +```python +# ... +account = Account(credentials=my_credentials) # account defaults to 'ME' resource + +mailbox = account.mailbox('shared_mailbox@example.com') # mailbox is using 'shared_mailbox@example.com' resource instead of 'ME' + +# or: + +message = Message(parent=account, main_resource='shared_mailbox@example.com') # message is using 'shared_mailbox@example.com' resource +``` + +Usually you will work with the default 'ME' resuorce, but you can also use one of the following: + +- **'me'**: the user which has given consent. the default for every protocol. +- **'user:user@domain.com'**: a shared mailbox or a user account for which you have permissions. If you don't provide 'user:' will be infered anyways. +- **'sharepoint:sharepoint-site-id'**: a sharepoint site id. +- **'group:group-site-id'**: a office365 group id. + +## Authentication +You can only authenticate using oauth athentication as Microsoft deprecated basic oauth on November 1st 2018. + +- Oauth authentication: using an authentication token provided after user consent. + +The `Connection` Class handles the authentication. + +#### Oauth Authentication +This section is explained using Microsoft Graph Protocol, almost the same applies to the Office 365 REST API. + + +##### Permissions and Scopes: +When using oauth you create an application and allow some resources to be accesed and used by it's users. +Then the user can request access to one or more of this resources by providing scopes to the oauth provider. -In the [Fetch File](https://github.com/Narcolapser/python-o365/blob/master/examples/fetchFile.py) example a filter is used to get only the unread messages: +For example your application can have Calendar.Read, Mail.ReadWrite and Mail.Send permissions, but the application can request access only to the Mail.ReadWrite and Mail.Send permission. +This is done by providing scopes to the connection object like so: ```python -i = Inbox(auth,getNow=False) #Email, Password, Delay fetching so I can change the filters. +from pyo365 import Connection -i.setFilter("IsRead eq false") +credentials = ('client_id', 'client_secret') -i.getMessages() +scopes = ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send'] + +con = Connection(credentials, scopes=scopes) ``` -When the inbox has run it's getMessages method, whether when it is instanced or later, all the messages it retrieves will be stored in a list local to the instance of inbox. Inbox.messages +Scope implementation depends on the protocol used. So by using protocol data you can automatically set the scopes needed: + +You can get the same scopes as before using protocols like this: -While the Inbox class is used exclusively for incoming mail, as the name might imply, the message class is incoming and out going. In the fetch file example in it's processMessage method it work with both an incoming message, "m", and prepares an out going message, "resp": ```python -def processMessage(m): - path = m.json['BodyPreview'] +protocol_graph = MSGraphProtocol() - path = path[:path.index('\n')] - if path[-1] == '\r': - path = path[:-1] +scopes_graph = protocol.get_scopes_for('message all') +# scopes here are: ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send'] - att = Attachment(path=path) +protocol_office = MSOffice365Protocol() - resp = Message(auth=auth) - resp.setRecipients(m.getSender()) +scopes_office = protocol.get_scopes_for('message all') +# scopes here are: ['https://outlook.office.com/Mail.ReadWrite', 'https://outlook.office.com/Mail.Send'] - resp.setSubject('Your file sir!') - resp.setBody(path) - resp.attachments.append(att) - resp.sendMessage() +con = Connection(credentials, scopes=scopes_graph) +``` - return True + +##### Authentication Flow +1. To work with oauth you first need to register your application at [Microsoft Application Registration Portal](https://apps.dev.microsoft.com/). + + 1. Login at [Microsoft Application Registration Portal](https://apps.dev.microsoft.com/) + 2. Create an app, note your app id (client_id) + 3. Generate a new password (client_secret) under "Application Secrets" section + 4. Under the "Platform" section, add a new Web platform and set "https://outlook.office365.com/owa/" as the redirect URL + 5. Under "Microsoft Graph Permissions" section, add the delegated permissions you want (see scopes), as an example, to read and send emails use: + 1. Mail.ReadWrite + 2. Mail.Send + 3. User.Read + +2. Then you need to login for the first time to get the access token by consenting the application to access the resources it needs. + 1. First get the authorization url. + ```python + url = account.connection.get_authorization_url() + ``` + 2. The user must visit this url and give consent to the application. When consent is given, the page will rediret to: "https://outlook.office365.com/owa/". + + Then the user must copy the resulting page url and give it to the connection object: + + ```python + result_url = input('Paste the result url here...') + + account.connection.request_token(result_url) # This, if succesful, will store the token in a txt file on the user project folder. + ``` + + Take care, the access token must remain protected from unauthorized users. + + 3. At this point you will have an access token that will provide valid credentials when using the api. If you change the scope requested, then the current token won't work, and you will need the user to give consent again on the application to gain access to the new scopes requested. + + The access token only lasts 60 minutes, but the app will automatically request new tokens through the refresh tokens, but note that a refresh token only lasts for 90 days. So you must use it before or you will need to request a new access token again (no new consent needed by the user, just a login). + + If your application needs to work for more than 90 days without user interaction and without interacting with the API, then you must implement a periodic call to `Connection.refresh_token` before the 90 days have passed. + + +##### Using pyo365 to authenticate + +You can manually authenticate by using a single `Connection` instance as described before or use the helper methods provided by the library. + +1. `account.authenticate`: + + This is the preferred way for performing authentication. + + Create an `Account` instance and authenticate using the `authenticate` method: + ```python + from pyo365 import Account + + account = Account(credentials=('client_id', 'client_secret')) + result = account.authenticate(scopes=['basic', 'message_all']) # request a token for this scopes + + # this will ask to visit the app consent screen where the user will be asked to give consent on the requested scopes. + # then the user will have to provide the result url afeter consent. + # if all goes as expected, result will be True and a token will be stored in the default location. + ``` + +2. `oauth_authentication_flow`: + + ```python + from pyo365 import oauth_authentication_flow + + result = oauth_authentication_flow('client_id', 'client_secret', ['scopes_required']) + ``` + +## Account Class and Modularity +Usually you will only need to work with the `Account` Class. This is a wrapper around all functionality. + +But you can also work only with the pieces you want. + +For example, instead of: +```python +from pyo365 import Account + +account = Account(('client_id', 'client_secret')) +message = account.new_message() +# ... +mailbox = account.mailbox() +# ... ``` -In this method we pull the BodyPreview, less likely to have Markup, and pull out it's first line to get the path to a file. That path is then sent to the attachment class and a response message is created and sent. Simple and straight forward. -The attachment class is a relatively simple class for handling downloading and creating attachments. Attachments in Office365 are stored seperately from the email in most cases and as such will have to be downloaded and uploaded seperately as well. This however is also taken care of behind the scenes with O365. Simply call a message's getAttachments method to download the attachments locally to your process. This creates a list of attachments local to the instance of Message, as is seen in the [Email Printing example](https://github.com/Narcolapser/python-o365/blob/master/examples/EmailPrinting/emailprinting.py): +You can work only with the required pieces: + ```python -m.fetchAttachments() -for att in m.attachments: - processAttachment(att,resp) -#various un-related bits left out for brevity. +from pyo365 import Connection, MSGraphProtocol, Message, MailBox + +my_protocol = MSGraphProtocol() +con = Connection(('client_id', 'client_secret')) + +message = Message(con=con, protocol=my_protocol) +# ... +mailbox = MailBox(con=con, protocol=my_protocol) +message2 = Message(parent=mailbox) # message will inherit the connection and protocol from mailbox when using parent. +# ... ``` -The attachment class stores the files as base64 encoded files. But this doesn't matter to you! The attachment class can work with you if you want to just send/receive raw binary or base64. You can also just give it a path to a file if you want to creat an attachment: + +It's also easy to implement a custom Class. + +Just Inherit from `ApiComponent`, define the endpoints, and use the connection to make requests. If needed also inherit from Protocol to handle different comunications aspects with the API server. + +```python +from pyo365.utils import ApiComponent + +class CustomClass(ApiComponent): + _endpoints = {'my_url_key': '/customendpoint'} + + def __init__(self, *, parent=None, con=None, **kwargs): + super().__init__(parent=parent, con=con, **kwargs) + # ... + + def do_some_stuff(self): + + # self.build_url just merges the protocol service_url with the enpoint passed as a parameter + # to change the service_url implement your own protocol inherinting from Protocol Class + url = self.build_url(self._endpoints.get('my_url_key')) + + my_params = {'param1': 'param1'} + + response = self.con.get(url, params=my_params) # note the use of the connection here. + + # handle response and return to the user... +``` + +## MailBox +Mailbox groups the funcionality of both the messages and the email folders. + ```python -att = Attachment(path=path) +mailbox = account.mailbox() + +inbox = mailbox.inbox_folder() + +for message in inbox.get_messages(): + print(message) + +sent_folder = mailbox.sent_folder() + +for message in sent_folder.get_messages(): + print(message) + +m = mailbox.new_message() + +m.to.add('to_example@example.com') +m.body = 'George Best quote: In 1969 I gave up women and alcohol - it was the worst 20 minutes of my life.' +m.save_draft() ``` -or if you want to save the file + +#### Email Folder +Represents a `Folder` within your email mailbox. + +You can get any folder in your mailbox by requesting child folders or filtering by name. + +```python +mailbox = account.mailbox() + +archive = mailbox.get_folder(folder_name='archive') # get a folder with 'archive' name + +child_folders = archive.get_folders(25) # get at most 25 child folders of 'archive' folder + +for folder in child_folders: + print(folder.name, folder.parent_id) + +new_folder = archive.create_child_folder('George Best Quotes') ``` -att.save(path) + +#### Message +An email object with all it's data and methods. + +Creating a draft message is as easy as this: +```python +message = mailbox.new_message() +message.to.add(['example1@example.com', 'example2@example.com']) +message.sender.address = 'my_shared_account@example.com' # changing the from address +message.body = 'George Best quote: I might go to Alcoholics Anonymous, but I think it would be difficult for me to remain anonymous' +message.attachments.add('george_best_quotes.txt') +message.save_draft() # save the message on the cloud as a draft in the drafts folder +new_contact.name = 'George Best' +new_contact.job_title = 'football player' +new_contact.emails.add('george@best.com') + +new_contact.save() # saved on the cloud + +message = new_contact.new_message() # Bonus: send a message to this contact + +# ... + +new_contact.delete() # Bonus: deteled from the cloud ``` + ## Calendar -Events are on a Calendar, Calendars are grouped into a Schedule. In the [Vehicle Booking](https://github.com/Narcolapser/python-o365/blob/master/examples/VehicleBookings/veh.py) example the purpose of the script is to create a json file with information to be imported into another program for presentation. We want to know all of the times the vehicles are booked out, for each vehicle, and by who, etc. This is done by simple getting the schedule and calendar for each vehicle and spitting out it's events: +The calendar and events functionality is group in a `Schedule` object. + +A `Schedule` instance can list and create calendars. It can also list or create events on the default user calendar. +To use other calendars use a `Calendar` instance. + +Working with the `Schedule` instance: ```python -for veh in vj: - e = veh['email'] - p = veh['password'] +import datetime as dt + +# ... +schedule = account.schedule() + +new_event = schedule.new_event() # creates a new event in the user default calendar +new_event.subject = 'Recruit George Best!' +new_event.location = 'England' - schedule = Schedule((e,p)) - try: - result = schedule.getCalendars() - print 'Fetched calendars for',e,'was successful:',result - except: - print 'Login failed for',e +# naive datetimes will automatically be converted to timezone aware datetime +# objects using the local timezone detected or the protocol provided timezone - bookings = [] +new_event.start = dt.datetime(2018, 9, 5, 19, 45) +# so new_event.start becomes: datetime.datetime(2018, 9, 5, 19, 45, tzinfo=) - for cal in schedule.calendars: - print 'attempting to fetch events for',e - try: - result = cal.getEvents() - print 'Got events',result,'got',len(cal.events) - except: - print 'failed to fetch events' - print 'attempting for event information' - for event in cal.events: - print 'HERE!' - bookings.append(event.fullcalendarioJson()) - json_outs[e] = bookings +new_event.recurrence.set_daily(1, end=dt.datetime(2018, 9, 10)) +new_event.remind_before_minutes = 45 + +new_event.save() ``` -Events can be made relatively easily too. You just have to create a event class: +Working with `Calendar` instances: ```python -e = Event(auth=authentication,cal=parentCalendar) +calendar = schedule.get_calendar(calendar_name='Birthdays') + +calendar.name = 'Football players birthdays' +calendar.update() + +q = calendar.new_query('start').ge(dt.datetime(2018, 5, 20)).chain('and').on_attribute('end').le(dt.datetime(2018, 5, 24)) + +birthdays = calendar.get_events(query=q) + +for event in birthdays: + if event.subject == 'George Best Birthday': + # He died in 2005... but we celebrate anyway! + event.accept("I'll attend!") # send a response accepting + else: + event.decline("No way I'm comming, I'll be in Spain", send_response=False) # decline the event but don't send a reponse to the organizer ``` -and give it a few nesessary details: + +## OneDrive +The `Storage` class handles all functionality around One Drive and Document Library Storage in Sharepoint. + +The `Storage` instance allows to retrieve `Drive` instances which handles all the Files and Folders from within the selected `Storage`. +Usually you will only need to work with the default drive. But the `Storage` instances can handle multiple drives. + + +A `Drive` will allow you to work with Folders and Files. + ```python -import time -e.setSubject('Coffee!') -e.setStart(time.gmtime(time.time()+3600)) #start an hour from now. -e.setEnd(time.gmtime(time.time()+7200)) #end two hours from now. -new_e = e.create() +account = Account(credentials=my_credentials) + +storage = account.storage() # here we get the storage instance that handles all the storage options. + +# list all the drives: +drives = storage.get_drives() + +# get the default drive +my_drive = storage.get_default_drive() # or get_drive('drive-id') + +# get some folders: +root_folder = my_drive.get_root_folder() +attachments_folder = my_drive.get_special_folder('attachments') + +# iterate over the first 25 items on the root folder +for item in root_folder.get_items(limit=25): + if item.is_folder: + print(item.get_items(2)) # print the first to element on this folder. + elif item.is_file: + if item.is_photo: + print(item.camera_model) # print some metadata of this photo + elif item.is_image: + print(item.dimensione) # print the image dimensions + else: + # regular file: + print(item.mime_type) # print the mime type ``` -## Contacts -Contacts are a small part of this library, but can have their use. You can store email addresses in your contacts list in folders and then use this as a form of mailing list: +Both Files and Folders are DriveItems. Both Image and Photo are Files, but Photo is also an Image. All have some different methods and properties. +Take care when using 'is_xxxx'. + +When coping a DriveItem the api can return a direct copy of the item or a pointer to a resource that will inform on the progress of the copy operation. + ```python -e = 'youremail@office365.com' -p = 'embarrassingly simple password.' -group = Group(e,p,'Contact folder name') -m = Message(auth=(e,p)) -m.setSubject('News for today') -m.setBody(open('news.html','r').read()) -m.setRecipients(group) -m.sendMessage() +# copy a file to the documents special folder + +documents_folder = drive.get_special_folder('documents') + +files = drive.search('george best quotes', limit=1) + +if files: + george_best_quotes = files[0] + operation = george_best_quotes.copy(target=documents_folder) # operation here is an instance of CopyOperation + + # to check for the result just loop over check_status. + # check_status is a generator that will yield a new status and progress until the file is finally copied + for status, progress in operation.check_status(): # if it's an async operations, this will request to the api for the status in every loop + print('{} - {}'.format(status, progress)) # prints 'in progress - 77.3' until finally completed: 'completed - 100.0' + copied_item = operation.get_item() # the copy operation is completed so you can get the item. + if copied_item: + copied_item.delete() # ... oops! ``` -## Connection -Connection is a singleton class to take care of all authentication to the Office 365 api. -Connection has 2 different types of authentication and 1 additional function -1. Basic - using Username and Password -2. OAuth2 - using client id and client secret +You can also work with share permissions: -#### Basic Authentication ```python -from O365 import Connection, FluentInbox +current_permisions = file.get_permissions() # get all the current permissions on this drive_item (some may be inherited) + +# share with link +permission = file.share_with_link(share_type='edit') +if permission: + print(permission.share_link) # the link you can use to share this drive item +# share with invite +permission = file.share_with_invite(recipients='george_best@best.com', send_email=True, message='Greetings!!', share_type='edit') +if permission: + print(permission.granted_to) # the person you share this item with +``` + +You can also: +```python +# download files: +file.download(to_path='/quotes/') + +# upload files: + +# if the uploaded file is bigger than 4MB the file will be uploaded in chunks of 5 MB until completed. +# this can take several requests and can be time consuming. +uploaded_file = folder.upload_file(item='path_to_my_local_file') -# Setup connection object -# Proxy call is required only if you are behind proxy -Connection.login('email_id@company.com', 'password to login') -Connection.proxy(url='proxy.company.com', port=8080, username='proxy_username', password='proxy_password') +# restore versions: +versiones = file.get_versions() +for version in versions: + if version.name == '2.0': + version.restore() # restore the version 2.0 of this file + +# ... and much more ... ``` -#### OAuth2 Authentication -You will need to register your application at Microsoft Apps(https://apps.dev.microsoft.com/). Steps below -1. Login to https://apps.dev.microsoft.com/ -2. Create an app, note your app id (client_id) -3. Generate a new password (client_secret) under "Application Secrets" section -4. Under the "Platform" section, add a new Web platform and set "https://outlook.office365.com/owa/" as the redirect URL -5. Under "Microsoft Graph Permissions" section, Add the below delegated permission - 1. email - 2. Mail.ReadWrite - 3. Mail.Send - 4. User.Read + + +## Sharepoint +Work in progress + + +## Utils + +#### Pagination + +When using certain methods, it is possible that you request more items than the api can return in a single api call. +In this case the Api, returns a "next link" url where you can pull more data. + +When this is the case, the methods in this library will return a `Pagination` object which abstracts all this into a single iterator. +The pagination object will request "next links" as soon as they are needed. + +For example: + ```python -from O365 import Connection, FluentInbox +maibox = account.mailbox() + +messages = mailbox.get_messages(limit=1500) # the Office 365 and MS Graph API have a 999 items limit returned per api call. -# Setup connection object -# This will provide you with auth url, open it and authentication and copy the resulting page url and paste it back in the input -c = Connection.oauth2("your client_id", "your client_secret", store_token=True) +# Here messages is a Pagination instance. It's an Iterator so you can iterate over. -# Proxy call is required only if you are behind proxy -Connection.proxy(url='proxy.company.com', port=8080, username='proxy_username', password='proxy_password') +# The first 999 iterations will be normal list iterations, returning one item at a time. +# When the iterator reaches the 1000 item, the Pagination instance will call the api again requesting exactly 500 items +# or the items specified in the batch parameter (see later). -# Start a message with OAuth2 -m = Message(oauth=c.oauth) +for message in messages: + print(message.subject) ``` +When using certain methods you will have the option to specify not only a limit option (the number of items to be returned) but a batch option. +This option will indicate the method to request data to the api in batches until the limit is reached or the data consumed. +This is usefull when you want to optimize memory or network latency. +For example: -## Fluent Inbox -FluentInbox is a new class introduced to enhance usage of inbox fluently (check the below example to understand clearly) ```python -from O365 import Connection, FluentInbox +messages = mailbox.get_messages(limit=100, batch=25) -# Setup connection object -# Proxy call is required only if you are behind proxy -Connection.login('email_id@company.com', 'password to login') -Connection.proxy(url='proxy.company.com', port=8080, username='proxy_username', password='proxy_password') +# messages here is a Pagination instance +# when iterating over it will call the api 4 times (each requesting 25 items). + +for message in messages: # 100 loops with 4 requests to the api server + print(message.subject) +``` -# Create an inbox reference -inbox = FluentInbox() +#### The Query helper -# Fetch 20 messages from "Temp" folder containing "Test" in the subject -for message in inbox.from_folder('Temp').search('Subject:Test').fetch(count=20): - # Just print the message subject - print(message.getSubject()) +When using the Office 365 API you can filter some fields. +This filtering is tedious as is using [Open Data Protocol (OData)](http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html). + +Every `ApiComponent` (such as `MailBox`) implements a new_query method that will return a `Query` instance. +This `Query` instance can handle the filtering (and sorting and selecting) very easily. + +For example: + +```python +query = mailbox.new_query() -# Fetch the next 15 messages from the results -for message in inbox.fetch_next(15): - # Just print the message subject - print(message.getSubject()) +query = query.on_attribute('subject').contains('george best').chain('or').startswith('quotes') -# Alternately you can do the below for same result, just a different way of accessing the messages -inbox.from_folder('Temp').search('Subject:Test').fetch(count=20) -inbox.fetch_next(15) -for message in inbox.messages: - # Just print the message subject - print(message.getSubject()) +# 'created_date_time' will automatically be converted to the protocol casing. +# For example when using MS Graph this will become 'createdDateTime'. -# If you would like to get only the 2nd result -for message in inbox.search('Category:some_cat').skip(1).fetch(1): - # Just print the message subject - print(message.getSubject()) +query = query.chain('and').on_attribute('created_date_time').greater(datetime(2018, 3, 21)) -# If you want the results from beginning by ignoring any currently read count -inbox.fetch_first(10) +print(query) + +# contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z' +# note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format + +# To use Query objetcs just pass it to the query parameter: +filtered_messages = mailbox.get_messages(query=query) +``` + +You can also specify specific data to be retrieved with "select": + +```python +# select only some properties for the retrieved messages: +query = mailbox.new_query().select('subject', 'to_recipients', 'created_date_time') + +messages_with_selected_properties = mailbox.get_messages(query=query) ``` -### Support for shared mailboxes -Basic support for working with shared mailboxes exists. The following functions take `user_id` as a keyword argument specifying the email address of the shared mailbox. +#### Request Error Handling and Custom Errors + +Whenever a Request error raises, the connection object will raise an exception. +Then the exception will be captured and logged it to the stdout with it's message, an return Falsy (None, False, [], etc...) -* `FluentInbox.from_folder(..)` - read messages messages -* `FluentInbox.get_folder(..)` - list folders -* `FluentMessage.sendMessage(..)` - send as shared mailbox +HttpErrors 4xx (Bad Request) and 5xx (Internal Server Error) are considered exceptions and raised also by the connection (you can configure this on the connection). #### Soli Deo Gloria diff --git a/_config.yml b/_config.yml deleted file mode 100644 index 3397c9a4928e1..0000000000000 --- a/_config.yml +++ /dev/null @@ -1 +0,0 @@ -theme: jekyll-theme-architect \ No newline at end of file diff --git a/devdocs/15 03 17 b/devdocs/15 03 17 deleted file mode 100644 index 5ce5efde1fcf0..0000000000000 --- a/devdocs/15 03 17 +++ /dev/null @@ -1,41 +0,0 @@ -I'm starting to have some change of mind in how this whole library is structured. It has been x -sensible enough to have the information saved as local variables, but I think that in the long run -that won't be as useful as it seems. I'm leaning towards using instead json to store all the -information for the class. It feels a little weird to be honest. Why even have classes then? Well, I -think the reason for that would be that people using this are still going to be thinking object -oriented programming as they try to use it. - -I wonder if there is a way I could blend it. Like using getattr to make the json elements seem like -local variables. must test! - -haha! score: - ->>> class Val: -... def __init__(self,bob): -... self.bob = bob -... def __getattr__(self,name): -... return self.bob[name] -... ->>> v = Val(j) ->>> v.bar -2 ->>> v.foo -5 ->>> - -I shall integrate this into my classes. That will allow the classes to fake having the json as -their local variables. - -__getattr__ -__setattr__ - - -*tries to implement* - -Ok. that was a bad idea. The problem is that this leads to a lot of infinite recussion. So I'm -probably going to give up this idea and simply move to just having a class.json arrangement. this -would allow me to have the json and any time a user wants data or wants to change something, he -can do it through that. - -I like the idea of having the json form the data structure, even if it is a little ridiculous. - diff --git a/devdocs/15 04 20 b/devdocs/15 04 20 deleted file mode 100644 index 8ffa5ab877948..0000000000000 --- a/devdocs/15 04 20 +++ /dev/null @@ -1,5 +0,0 @@ -Getting tonsilitous doesn't help with development. - -I've gotten O365 uploaded to PyPi and to my suprise in 10 days there have been 246 downloads! Awesome! But this means I need to work out a lot of the problems. - -I've got a lot of proper work setup on github as well. Milestones and issues and such to help me keep track of what I'm trying to do. It's going along quite nicely. diff --git a/examples/EmailPrinting/emailprinting.lock b/examples/EmailPrinting/emailprinting.lock deleted file mode 100644 index d00491fd7e5bb..0000000000000 --- a/examples/EmailPrinting/emailprinting.lock +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/examples/EmailPrinting/emailprinting.py b/examples/EmailPrinting/emailprinting.py deleted file mode 100644 index 0d1d97a0efb3b..0000000000000 --- a/examples/EmailPrinting/emailprinting.py +++ /dev/null @@ -1,160 +0,0 @@ -from O365 import * -from printing import * -import json -import os -import sys -import time -import logging - -logging.basicConfig(filename='ep.log',level=logging.DEBUG) - -log = logging.getLogger('ep') - -''' -This script represents a way that O365 could be used to integrate parts of your enviorment. This is -not a theoretical example, this is a production script that I use at our facility. The objective -here is to aliviate a problem where the printer rests inside a protected network but students who -want to print are outside of that network. By sending print jobs they have to send it to an email address they -do not need to install the printer on their local device, nor do they need direct access to the -device. - -The basic architecture of this script is as follows: -1. spin off as a server. If you don't have access to cron or cron is not working for you, this is a - work around. -2. Global Exception Handling. Because we don't have cron to spin us backup, we need to catch any - problem that mich crash the whole process. -3. Check for messages -4. Check that the sender is from our domain. -5. Create a username that is compatible with the printer (max 8 chars, no punctuation) -6. Verify attachment type -7. Download attachment -8. Send attachment to be printed -5-8: notify the user if there is problems or successes at any point in here. - -Feel free to rework this to your enviorment. You'll want to change the verification method and the -printer.py file to match your needs. -''' - - -def userFromEmail(email): - name = email[:email.index('@')] - fname, lname = name.split('.') - if fname > 7: - fname = fname[:7] - lname = lname[0] - if fname < 4: - lname = lname[:2] - name = fname+lname - log.debug('Exctracted username: {0}'.format(name)) - return name - -def verifyUser(email): - if '@om.org' not in email.lower(): - log.debug('Not an OM address: {0}'.format(email)) - return False - - log.debug('Valid OM address: {0}'.format(email)) - return True - -def getLock(): - f = open('emailprinting.lock','r').read() - lock = int(f) - return lock - -def processMessage(m,auth): - m.fetchAttachments() - m.markAsRead() - - resp = Message(auth=auth) - resp.setSubject('Printing failed.') - resp.setRecipients(m.getSender()) - - sender = m.json['From']['EmailAddress']['Address'] - num_att = len(m.attachments) - - if not verifyUser(sender): - resp.setBody('I am sorry, but you must email from your om.org email address.') - resp.sendMessage() - return False - - if num_att == 0: - resp.setBody('Did you remember to attach the file?') - resp.sendMessage() - log.debug('No attachments found.') - - log.debug('\t I have {0} attachments from {1} in their email "{2}"'.format(num_att,sender,m.json['Subject'])) - - printer.setFlag('U',userFromEmail(m.json['From']['EmailAddress']['Address'])) - - for att in m.attachments: - if not verifyPDF(att,resp): - continue - - processAttachment(att,resp) - - return True - -def verifyPDF(att,resp): - if '.pdf' not in att.json['Name'].lower(): - log.debug('{0} is not a pdf. skipping!'.format(att.json['Name'])) - resp.setBody('I can only print pdfs. please convert your file and send it again.\n Problematic File: {0}'.format(att.json['Name'])) - resp.sendMessage() - return False - return True - -def processAttachment(att,resp): - p = att.getByteString() - if not p: - log.debug('Something went wrong with decoding attachment: {0} {1}'.format(att.json['Name'],str(p))) - resp.setBody('Did you remember to attach the file?') - resp.sendMessage() - return False - - log.debug('length of byte string: {0} for attachment: {1}'.format(len(p),att.json['Name'])) - if p: - log.debug('ready. set. PRINT!') - printer.setFlag('t',att.json['Name']) - ret = printer.sendPrint(p) - resp.setBody('Your print has been passed on to the printer. You can now go to the printer to collect it. It will be locked, the password is 1234. \n\n{0}'.format(str(ret))) - resp.setSubject('Printing succeeded') - resp.sendMessage() - log.debug('Response from printer: {0}'.format(ret)) - - return True - -emails = open('./emails.pw','r').read().split('\n') -printer = getRicoh() - -if __name__ == '__main__': - newpid = os.fork() - if newpid > 0: - print newpid - f = open('pid','a') - f.write(str(newpid)) - f.write('\n') - f.close() - sys.exit(0) - - while getLock(): - if True: -# try: - print "checking for emails" - with open('./ep.pw','r') as configFile: - config = configFile.read() - cjson = json.loads(config) - - e = cjson ['email'] - p = cjson ['password'] - - auth = (e,p) - - i = Inbox(auth) - - log.debug("messages: {0}".format(len(i.messages))) - for m in i.messages: - processMessage(m,auth) - time.sleep(55) -# except Exception as e: - log.critical('something went really really bad: {0}'.format(str(e))) - -#To the King! diff --git a/examples/EmailPrinting/printing.py b/examples/EmailPrinting/printing.py deleted file mode 100644 index 474e1f68bfd71..0000000000000 --- a/examples/EmailPrinting/printing.py +++ /dev/null @@ -1,172 +0,0 @@ -import subprocess - -class Printer( object ): - - - def __init__(self, name, flags=None, options=None): - self.name = name - - if flags: - self.flags = flags - else: - self.options = {} - - if options: - self.options = options - else: - self.options = [] - - def __str__(self): - ret = 'Printer: ' + self.name + '\n' - ret += 'With the call of: ' - for flag in self.flags.keys(): - ret += '-{0} {1} '.format(flag,self.flags[flag]) - - for op in self.options: - o = str(op) - if o != '': - ret += o + ' ' - - return ret - - - def setFlag(self,flag,value): - if flag == 'd': - return False - try: - self.flags[flag] = value - except: - return False - return True - - - def getFlag(self,flag): - try: - return self.flags[flag] - except: - return False - - - def addOption(self,new_op): - for i,op in enumerate(self.options): - if op.name == new_op.name: - self.options[i] = new_op - return True - - self.options.append(op) - - - def getOption(self,name): - for op in self.options: - if op.name == name: - return op - - return False - - def __call__(self,item): - self.sendPrint(item) - - - def sendPrint(self,item): - #command = ['lp','-d',self.name] - command = ['/usr/bin/lp'] - for flag in self.flags.keys(): - command.append('-{0} {1}'.format(flag,self.flags[flag])) - - for op in self.options: - o = str(op) - if o != '': - command.append(str(op)) - - print command - p = subprocess.Popen(command,stdout=subprocess.PIPE,stdin=subprocess.PIPE) - #outs = p.communicate(input=item)[0] - p.stdin.write(item) - outs = p.communicate() - print outs - - -class Option( object ): - - - def __init__(self,name,options,default=None,human_name=None): - self.name = name - self.options = options - self.human_name = human_name - if default: - self.default = default - else: - self.default = self.options[0] - - - def __str__(self): - if self.default: - return '-o{0}={1} '.format(self.name,self.default) - return '' - - def setDefault(self,op): - self.default = op - return True - - -def listPrinters(): - lpsc = subprocess.Popen(['lpstat','-s'],stdout=subprocess.PIPE) - lpstats = lpsc.communicate()[0] - - lpsplit = lpstats.split('\n')[1:-1] - - printers = [] - for p in lpsplit: - printers.append(p.split()[2:4]) - - return printers - - -def listOptions(printer): - lpop = subprocess.Popen(['lpoptions','-p',printer,'-l'],stdout=subprocess.PIPE) - lpout = lpop.communicate()[0].split('\n')[:-1] - ops = [] - - for line in lpout: - name, values = line.split(':') - human_name = name[name.index('/')+1:] - name = name[:name.index('/')] - valuelist = values.split(' ') - for i,v in enumerate(valuelist): - if '*' in v: - valuelist[i] = valuelist[i].replace('*','') - - ops.append(Option(name,valuelist,None,human_name)) - - return ops - - -def getRicoh(): - ops = listOptions('ricoh-double') - prin = Printer('ricoh-double',{'U':'tester','t':'testPrint.pdf'},ops) - - op = prin.getOption('ColorModel') - op.setDefault('Gray') - prin.addOption(op) - - op = prin.getOption('Duplex') - op.setDefault('DuplexNoTumble') - prin.addOption(op) - - op = prin.getOption('JobType') - op.setDefault('LockedPrint') - prin.addOption(op) - - op = prin.getOption('LockedPrintPassword') - op.setDefault('1234') - prin.addOption(op) - - return prin - -if __name__ == '__main__': - r = getRicoh() - print r - - r(open('printing.py','r').read()) - -#To the King! diff --git a/examples/FolderOperations/get_folder.py b/examples/FolderOperations/get_folder.py deleted file mode 100644 index 3bdbc9154f299..0000000000000 --- a/examples/FolderOperations/get_folder.py +++ /dev/null @@ -1,23 +0,0 @@ -import sys -import getpass -from O365 import Connection, FluentInbox - - -def main(): - if len(sys.argv) == 1: - sys.stderr.write("Usage: %s BY VALUE\n" % sys.argv[0]) - return 1 - - username = input("Username: ") - password = getpass.getpass("Password: ") - authentication = (username, password) - Connection.login(*authentication) - inbox = FluentInbox() - - print(inbox.get_folder(by=sys.argv[1], value=sys.argv[2])) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/examples/FolderOperations/list_folders.py b/examples/FolderOperations/list_folders.py deleted file mode 100644 index 467193e56bf0a..0000000000000 --- a/examples/FolderOperations/list_folders.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys -import getpass -from O365 import Connection, FluentInbox - - -def main(): - username = input("Username: ") - password = getpass.getpass("Password: ") - authentication = (username, password) - Connection.login(*authentication) - inbox = FluentInbox() - - # If given arguments, treat them as folder_ids to use as parents - if len(sys.argv) > 1: - for folder_id in sys.argv[1:]: - for folder in inbox.list_folders(parent_id=folder_id): - print(folder['Id'], folder['DisplayName']) - else: - for folder in inbox.list_folders(): - print(folder['Id'], folder['DisplayName']) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/examples/FolderOperations/trash_message.py b/examples/FolderOperations/trash_message.py deleted file mode 100644 index 588d898861f2e..0000000000000 --- a/examples/FolderOperations/trash_message.py +++ /dev/null @@ -1,26 +0,0 @@ -import sys -import getpass -from O365 import Connection, FluentInbox - - -def main(): - if len(sys.argv) == 1: - sys.stderr.write("Usage: %s 'subject to search for'\n" % sys.argv[0]) - return 1 - - username = input("Username: ") - password = getpass.getpass("Password: ") - authentication = (username, password) - Connection.login(*authentication) - inbox = FluentInbox() - - trash_folder = inbox.get_folder(by='DisplayName', value='Trash') - - for message in inbox.search("Subject:%s" % sys.argv[1]).fetch(count=1): - print(message.moveToFolder(trash_folder['Id'])) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/examples/VehicleBookings/veh.py b/examples/VehicleBookings/veh.py deleted file mode 100644 index 000ee38a3298c..0000000000000 --- a/examples/VehicleBookings/veh.py +++ /dev/null @@ -1,42 +0,0 @@ -from O365 import * -import json - - - -if __name__ == '__main__': - veh = open('./pw/veh.pw','r').read() - vj = json.loads(veh) - - schedules = [] - json_outs = {} - - for veh in vj: - e = veh['email'] - p = veh['password'] - - schedule = Schedule((e,p)) - try: - result = schedule.getCalendars() - print('Fetched calendars for',e,'was successful:',result) - except: - print('Login failed for',e) - - bookings = [] - - for cal in schedule.calendars: - print('attempting to fetch events for',e) - try: - result = cal.getEvents() - print('Got events',result,'got',len(cal.events)) - except: - print('failed to fetch events') - print('attempting for event information') - for event in cal.events: - print('HERE!') - bookings.append(event.fullcalendarioJson()) - json_outs[e] = bookings - - with open('bookings.json','w') as outs: - outs.write(json.dumps(json_outs,sort_keys=True,indent=4)) - -#To the King! diff --git a/examples/fetchFile.py b/examples/fetchFile.py deleted file mode 100644 index b98ef986bc7c3..0000000000000 --- a/examples/fetchFile.py +++ /dev/null @@ -1,52 +0,0 @@ -from O365 import * -import json -import os -import sys -import time -import logging - -logging.basicConfig(filename='ff.log',level=logging.DEBUG) - -log = logging.getLogger('ff') - -def processMessage(m): - path = m.json['BodyPreview'] - - path = path[:path.index('\n')] - if path[-1] == '\r': - path = path[:-1] - - att = Attachment(path=path) - - resp = Message(auth=auth) - resp.setRecipients(m.getSender()) - - resp.setSubject('Your file sir!') - resp.setBody(path) - resp.attachments.append(att) - resp.sendMessage() - - return True - - -print "checking for emails" -with open('./ff.pw','r') as configFile: - config = configFile.read() - cjson = json.loads(config) - -e = cjson ['email'] -p = cjson ['password'] - -auth = (e,p) - -i = Inbox( auth, getNow=False) #Email, Password, Delay fetching so I can change the filters. - -i.setFilter("IsRead eq false & Subject eq 'Fetch File'") - -i.getMessages() - -log.debug("messages: {0}".format(len(i.messages))) -for m in i.messages: - processMessage(m) - -#To the King! diff --git a/examples/pipemail.py b/examples/pipemail.py deleted file mode 100644 index 3710f0c522c53..0000000000000 --- a/examples/pipemail.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -#this is a simple script that can be used in conjunction with a unix pipeline. -# args must still provide: sending email, sending email password, reciving email, and subject. -from O365 import * -from sys import argv -import sys - -'''Usage: -This script is designed to provide a simple means of scripting the output of a program into -email. You pass it several arguments but the body of the email is to be sent from stdin. the -args in order are: -pipemail.py sender@example.com password recipient@example.com subject -''' - -if argv[1] == '/?': - print usage - exit() - -auth = (argv[1],argv[2]) - -rec = argv[3] - -subject = argv[4] - -body = sys.stdin.read() - -#Give the authentication to the message as instantiate it. then set it's values. -m = Message(auth=auth) -m.setRecipients(rec) -m.setSubject(subject) -m.setBody(body) -m.sendMessage() - - - diff --git a/examples/simple-message.py b/examples/simple-message.py deleted file mode 100644 index da5173626b613..0000000000000 --- a/examples/simple-message.py +++ /dev/null @@ -1,55 +0,0 @@ -from O365 import * -import getpass -import json - -from sys import argv - -usage = '''Welcome to the O365 simple message script! Usage is pretty straight forward. -Run the script and you will be asked for username, password, reciving address, -subject, and then a body. When these have all come and gone your message will -be sent straight way. - -For attachments, include the path to the attachment in the call and the script -will attach the files or crash trying. (hopefully not the latter) -e.g.: python simple-message.py that_file_you_want_but_could_only_ssh_in.jpg -''' - -if len(argv) > 1: - if argv[1] == '/?': - print usage - exit() - -#get login credentials that will be needed to send the message. -uname = raw_input('Enter your user name: ') -password = getpass.getpass('Enter your password: ') -auth = (uname,password) - -#get the address that the message is to be sent to. -rec = raw_input('Reciving address: ') - -#get the subject line. -subject = raw_input('Subject line: ') - -#get the body. -line = 'please ignore.' -body = '' -print 'Now enter the body of the message. leave a blank line when you are done.' -while line != '': - line = raw_input() - body += line - -#Give the authentication to the message as instantiate it. then set it's values. -m = Message(auth=auth) -m.setRecipients(rec) -m.setSubject(subject) -m.setBody(body) - -if len(argv) > 1: - for arg in argv[1:]: - a = Attachment(path=arg) - m.attachments.append(a) - -#send the message and report back. -print 'Sending message...' -print m.sendMessage() - diff --git a/release.py b/release.py new file mode 100644 index 0000000000000..85e3b28e056b8 --- /dev/null +++ b/release.py @@ -0,0 +1,81 @@ +""" +Release script +""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import click + + +DIST_PATH = 'dist' +DIST_PATH_DELETE = 'dist_delete' +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + + +@click.group(context_settings=CONTEXT_SETTINGS) +def cli(): + pass + + +@cli.command() +@click.option('--force/--no-force', default=False) +def build(force): + """ Builds the distribution files: wheels and source. """ + dist_path = Path(DIST_PATH) + if dist_path.exists() and list(dist_path.glob('*')): + if force or click.confirm('{} is not empty - delete contents?'.format(dist_path)): + dist_path.rename(DIST_PATH_DELETE) + shutil.rmtree(Path(DIST_PATH_DELETE)) + dist_path.mkdir() + else: + click.echo('Aborting') + sys.exit(1) + + subprocess.check_call(['python', 'setup.py', 'bdist_wheel']) + subprocess.check_call(['python', 'setup.py', 'sdist', + '--formats=gztar']) + + +@cli.command() +@click.option('--release/--no-release', default=False) +@click.option('--rebuild/--no-rebuild', default=True) +@click.pass_context +def upload(ctx, release, rebuild): + """ Uploads distribuition files to pypi or pypitest. """ + dist_path = Path(DIST_PATH) + if rebuild is False: + if not dist_path.exists() or not list(dist_path.glob('*')): + print("No distribution files found. Please run 'build' command first") + return + else: + ctx.invoke(build, force=True) + + if release: + args = ['twine', 'upload', 'dist/*'] + else: + repository = 'https://test.pypi.org/legacy/' + args = ['twine', 'upload', '--repository-url', repository, 'dist/*'] + + env = os.environ.copy() + + p = subprocess.Popen(args, env=env) + p.wait() + + +@cli.command() +def check(): + """ Checks the long description. """ + dist_path = Path(DIST_PATH) + if not dist_path.exists() or not list(dist_path.glob('*')): + print("No distribution files found. Please run 'build' command first") + return + + subprocess.check_call(['twine', 'check', 'dist/*']) + + +if __name__ == "__main__": + cli() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000..690256d6d3115 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,11 @@ +beautifulsoup4==4.6.3 +python-dateutil==2.7.4 +pytz==2018.6 +requests==2.20.0 +requests-oauthlib==1.0.0 +stringcase==1.2.0 +tzlocal==1.5.1 +Click==7.0 +pytest==3.9.1 +twine==1.12.1 +wheel==0.32.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9fb03e459173b..9aa37adeabedb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,7 @@ -## Principle requirement for the entire library ## -requests - -## Required to get OAuth to work ## -oauthlib -requests_oauthlib - -## Used for the metaclass functionality ## -future +beautifulsoup4==4.6.3 +python-dateutil==2.7.4 +pytz==2018.6 +requests==2.20.0 +requests-oauthlib==1.0.0 +stringcase==1.2.0 +tzlocal==1.5.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b88034e414bc7..0000000000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md diff --git a/setup.py b/setup.py index d77aab44e495e..8f2007bbfe20b 100644 --- a/setup.py +++ b/setup.py @@ -1,41 +1,55 @@ -#!/usr/bin/env python +import os +from setuptools import setup, find_packages -from setuptools import setup +# Available classifiers: https://pypi.org/pypi?%3Aaction=list_classifiers CLASSIFIERS = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Topic :: Office/Business :: Office Suites', - 'Topic :: Software Development :: Libraries' + 'Topic :: Software Development :: Libraries', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Operating System :: OS Independent', ] -with open("README.md", "r") as fh: - long_description = fh.read() -setup(name='O365', - version='0.9.17', - description='Python library for working with Microsoft Office 365', - long_description=long_description, - long_description_content_type="text/markdown", - author='Toben Archer', - author_email='sandslash+O365@gmail.com', - url='https://github.com/Narcolapser/python-o365', - packages=['O365'], - install_requires=['requests', 'oauthlib', 'requests_oauthlib', 'future'], - license='Apache 2.0', - classifiers=CLASSIFIERS - ) +def read(fname): + with open(os.path.join(os.path.dirname(__file__), fname), 'r') as file: + return file.read() -""" -Quick reference: -Generate dist: -python setup.py sdist bdist_wheel - -Upload to TestPyPI -twine upload --repository-url https://test.pypi.org/legacy/ dist/* +requires = [ + 'requests>=2.0.0', + 'requests_oauthlib>=1.0.0', + 'python-dateutil>=2.7', + 'pytz>=2018.5', + 'tzlocal>=1.5.0', + 'beautifulsoup4>=4.0.0', + 'stringcase>=1.2.0' +] -Upload to PyPI -twine upload dist/* -""" +setup( + name='O365', + version='1.0.0', + # packages=['O365', 'O365.utils'], + packages=find_packages(), + url='https://github.com/O365/python-o365', + license='Apache License 2.0', + author='Janscas, Roycem90, Narcolapser', + author_email='janscas@users.noreply.github.com', + maintainer='Janscas', + maintainer_email='janscas@users.noreply.github.com', + description='Microsoft Graph and Office 365 API made easy', + long_description=read('README.md'), + long_description_content_type="text/markdown", + classifiers=CLASSIFIERS, + python_requires=">=3.4", + install_requires=requires, +) diff --git a/tests/attachment.json b/tests/attachment.json deleted file mode 100644 index 8d2ec346ace17..0000000000000 --- a/tests/attachment.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages('bigoldguid')/Attachments", - "value":[ - { - "@odata.type":"#Microsoft.OutlookServices.FileAttachment", - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('bigoldguid')/Attachments('attguid')", - "Id":"attguid", - "Name":"test.txt", - "ContentType":null, - "Size":73560, - "IsInline":false, - "DateTimeLastModified":"2015-01-11T14:55:16Z", - "ContentId":null, - "ContentLocation":null, - "IsContactPhoto":false, - "ContentBytes":"dGVzdGluZyB3MDB0IQ==\n" - } - ] -} diff --git a/tests/attachment_message.json b/tests/attachment_message.json deleted file mode 100644 index 792bcafdc6711..0000000000000 --- a/tests/attachment_message.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('bigoldguid')", - "@odata.etag":"W/\"ck\"", - "Id":"bigoldguid", - "ChangeKey":"ck", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T11:31:18Z", - "DateTimeLastModified":"2015-04-22T11:32:08Z", - "Subject":"I've got a lovely bunch of coconuts", - "BodyPreview":"This email has an at", - "Body":{ - "ContentType":"HTML", - "Content":"This email has an attachment!" - }, - "Importance":"Normal", - "HasAttachments":true, - "ParentFolderId":"parfoid", - "From":{ - "EmailAddress":{ - "Address":"Speach@unit.com", - "Name":"For talking" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"Speach@unit.com", - "Name":"For talking" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"addyconvoid=", - "DateTimeReceived":"2015-04-22T11:31:18Z", - "DateTimeSent":"2015-04-22T11:30:46Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":false, - "WebLink":"https://outlook.office365.com/owa/?ItemID=addywebid&exvsurl=1&viewmodel=ReadMessageItem" - } - ] -} diff --git a/tests/conbill.json b/tests/conbill.json deleted file mode 100644 index 7306b1359454d..0000000000000 --- a/tests/conbill.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Contacts", - - "value":[ - - { - - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid2')", - - "@odata.etag":"W/\"etag2\"", - - "Id":"bigguid2", - - "ChangeKey":"etag2", - - "Categories":[ - - - - ], - - "DateTimeCreated":"2015-05-14T15:39:21Z", - - "DateTimeLastModified":"2015-05-18T10:59:36Z", - - "ParentFolderId":"Engineers", - - "FileAs":"Kerman, Bill", - - "DisplayName":"Bill Kerman (KSP)", - - "GivenName":"Bill", - - "Initials":"B.K.", - - "Surname":"Kerman", - - "EmailAddresses":[ - - { - - "Address":"Bill.Kerman@ksp.org", - - "Name":"Bill Kerman (KSP)" - - }, - - null, - - null - - ] - - } - - ] - -} diff --git a/tests/contacts.json b/tests/contacts.json deleted file mode 100644 index 9e88eeabb88a6..0000000000000 --- a/tests/contacts.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Contacts", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid1')", - "@odata.etag":"etag1", - "Id":"bigguid1", - "ChangeKey":"etag1", - "Categories":[ - - ], - "DateTimeCreated":"2015-05-14T15:39:22Z", - "DateTimeLastModified":"2015-05-20T01:59:31Z", - "ParentFolderId":"parentfolder", - "Birthday":null, - "FileAs":"Kerman, Jebediah", - "DisplayName":"Jebediah Kerman (KSP)", - "GivenName":"Jebediah", - "Initials":"J.K.", - "MiddleName":"", - "NickName":null, - "Surname":"Kerman", - "Title":"", - "Generation":"(KSP)", - "EmailAddresses":[ - { - "Address":"Jebediah.Kerman@ksp.org", - "Name":"Jebediah Kerman (KSP)" - }, - null, - null - ] - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid2')", - "@odata.etag":"W/\"etag2\"", - "Id":"bigguid2", - "ChangeKey":"etag2", - "Categories":[ - - ], - "DateTimeCreated":"2015-05-14T15:39:21Z", - "DateTimeLastModified":"2015-05-18T10:59:36Z", - "ParentFolderId":"Engineers", - "FileAs":"Kerman, Bill", - "DisplayName":"Bill Kerman (KSP)", - "GivenName":"Bill", - "Initials":"B.K.", - "Surname":"Kerman", - "EmailAddresses":[ - { - "Address":"Bill.Kerman@ksp.org", - "Name":"Bill Kerman (KSP)" - }, - null, - null - ] - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid3')", - "@odata.etag":"W/\"etag3\"", - "Id":"bigguid3", - "ChangeKey":"etag3", - "Categories":[], - "DateTimeCreated":"2015-05-14T15:38:57Z", - "DateTimeLastModified":"2015-05-18T10:59:35Z", - "ParentFolderId":"parentfolder", - "FileAs":"Kerman, Bob", - "DisplayName":"Bob Kerman (KSP)", - "GivenName":"Bob", - "Initials":"B.K.", - "Surname":"Kerman", - "EmailAddresses":[ - { - "Address":"Bob.Kerman@ksp.org", - "Name":"Bob Kerman (KSP)" - }, - null, - null - ] - } - ] -} diff --git a/tests/events.json b/tests/events.json deleted file mode 100644 index 0768f6f804b5d..0000000000000 --- a/tests/events.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Calendars('bigolguid')/CalendarView", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Events('bigolguid=')", - "@odata.etag":"W/\"ck1\"", - "Id":"bigolguid=", - "ChangeKey":"ck1", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T11:04:08.5260627Z", - "DateTimeLastModified":"2015-04-22T11:03:34.025Z", - "Subject":"da vent", - "BodyPreview":"Viva la reddit.", - "Body":{ - "ContentType":"HTML", - "Content":"Viva la reddit. content" - }, - "Importance":"Normal", - "HasAttachments":false, - "Start":"2015-04-22T12:00:00Z", - "StartTimeZone":"GMT Standard Time", - "End":"2015-04-22T12:30:00Z", - "EndTimeZone":"GMT Standard Time", - "Reminder":15, - "Location":{ - "DisplayName":"da place", - "Address":{ - "Street":"", - "City":"", - "State":"", - "CountryOrRegion":"", - "PostalCode":"" - }, - "Coordinates":{ - "Accuracy":"NaN", - "Altitude":"NaN", - "AltitudeAccuracy":"NaN", - "Latitude":"NaN", - "Longitude":"NaN" - } - }, - "ResponseStatus":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "ShowAs":"Busy", - "IsAllDay":false, - "IsCancelled":false, - "IsOrganizer":true, - "ResponseRequested":true, - "Type":"SingleInstance", - "SeriesMasterId":null, - "Attendees":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - }, - "Status":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "Type":"Required" - } - ], - "Recurrence":null, - "Organizer":{ - "EmailAddress":{ - "Address":"organizer@unit.com", - "Name":"The Choosen One" - } - }, - "iCalUId":"calid", - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid1&exvsurl=1&viewmodel=ICalendarItemDetailsViewModelFactory" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Events('otherguid')", - "@odata.etag":"W/\"crusader kings II\"", - "Id":"otherguid", - "ChangeKey":"crusader kings II", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T11:05:49.0137691Z", - "DateTimeLastModified":"2015-04-22T11:03:54.775Z", - "Subject":"dat oughter", - "BodyPreview":"Blub blub", - "Body":{ - "ContentType":"HTML", - "Content":"blub blub goes Microsoft's flag ship." - }, - "Importance":"Normal", - "HasAttachments":false, - "Start":"2015-04-24T00:00:00Z", - "StartTimeZone":"UTC", - "End":"2015-04-25T00:00:00Z", - "EndTimeZone":"UTC", - "Reminder":1080, - "Location":{ - "DisplayName":"dat ocean", - "Address":{ - "Street":"", - "City":"", - "State":"", - "CountryOrRegion":"", - "PostalCode":"" - }, - "Coordinates":{ - "Accuracy":"NaN", - "Altitude":"NaN", - "AltitudeAccuracy":"NaN", - "Latitude":"NaN", - "Longitude":"NaN" - } - }, - "ResponseStatus":{ - "Response":"Organizer", - "Time":"0001-01-01T00:00:00Z" - }, - "ShowAs":"Free", - "IsAllDay":true, - "IsCancelled":false, - "IsOrganizer":true, - "ResponseRequested":true, - "Type":"SingleInstance", - "SeriesMasterId":null, - "Attendees":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - }, - "Status":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "Type":"Required" - }, - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"test@unit.com" - }, - "Status":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "Type":"Required" - } - ], - "Recurrence":null, - "Organizer":{ - "EmailAddress":{ - "Address":"mrfins@splashy.whale", - "Name":"Mr. Splashy Fins" - } - }, - "iCalUId":"clid2", - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid22&exvsurl=1&viewmodel=ICalendarItemDetailsViewModelFactory" - } - ] -} diff --git a/tests/groups.json b/tests/groups.json deleted file mode 100644 index 18830f6cfcde2..0000000000000 --- a/tests/groups.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/ContactFolders", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/ContactFolders('engiID')", - "Id":"engiID", - "ParentFolderId":"parentfolder", - "DisplayName":"Engineers" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/ContactFolders('OrangesID')", - "Id":"OrangesID", - "ParentFolderId":"parentfolder", - "DisplayName":"Oranges" - } - ] -} diff --git a/tests/newmessage.json b/tests/newmessage.json deleted file mode 100644 index e34601647b5d3..0000000000000 --- a/tests/newmessage.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "Message": { - "Subject": "Meet for lunch?", - "Body": { - "ContentType": "Text", - "Content": "The new cafeteria is open." - }, - "ToRecipients": [ - { - "EmailAddress": { - "Address": "garthf@a830edad9050849NDA1.onmicrosoft.com" - } - } - ], - "Attachments": [ - { - "@odata.type": "#Microsoft.OutlookServices.FileAttachment", - "Name": "menu.txt", - "ContentBytes": "bWFjIGFuZCBjaGVlc2UgdG9kYXk=" - } - ] - }, - "SaveToSentItems": "false" -} diff --git a/tests/read_message.json b/tests/read_message.json deleted file mode 100644 index 06ff78adcca92..0000000000000 --- a/tests/read_message.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid1')", - "@odata.etag":"ck1", - "Id":"guid1", - "ChangeKey":"CQAAABYAAAD9eTSzqEULQLIWnNHLzkE/AADSuoSD", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T09:30:16Z", - "DateTimeLastModified":"2015-04-22T09:30:38Z", - "Subject":"m1", - "BodyPreview":"rb1", - "Body":{ - "ContentType":"HTML", - "Content":"read body 1" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId1", - "DateTimeReceived":"2015-04-22T09:30:16Z", - "DateTimeSent":"2015-04-22T09:30:09Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid1&exvsurl=1&viewmodel=ReadMessageItem" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid2')", - "@odata.etag":"W/\"ck2\"", - "Id":"guid2", - "ChangeKey":"ck2", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T09:22:01Z", - "DateTimeLastModified":"2015-04-22T09:23:38Z", - "Subject":"FW: Microsoft Office Not Activating", - "BodyPreview":"rb2", - "Body":{ - "ContentType":"HTML", - "Content":"read body 2" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId2", - "DateTimeReceived":"2015-04-22T09:22:01Z", - "DateTimeSent":"2015-04-22T09:22:00Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid2&exvsurl=1&viewmodel=ReadMessageItem" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid3')", - "@odata.etag":"W/\"ck3\"", - "Id":"guid3", - "ChangeKey":"ck3", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-21T16:04:30Z", - "DateTimeLastModified":"2015-04-22T08:54:50Z", - "Subject":"sub 3", - "BodyPreview":"rb3", - "Body":{ - "ContentType":"HTML", - "Content":"Read body 3" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"Another@unit.com", - "Name":"Another One" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"Another@unit.com", - "Name":"Another One" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId3", - "DateTimeReceived":"2015-04-21T16:04:30Z", - "DateTimeSent":"2015-04-21T16:04:28Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=weblink3&exvsurl=1&viewmodel=ReadMessageItem" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid4')", - "@odata.etag":"W/\"ck4\"", - "Id":"guid4", - "ChangeKey":"ck4", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-21T01:36:21Z", - "DateTimeLastModified":"2015-04-22T08:49:41Z", - "Subject":"Windows 10 is still underwhealming.", - "BodyPreview":"rb4", - "Body":{ - "ContentType":"HTML", - "Content":"rb4" - }, - "Importance":"High", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"super@unit.com", - "Name":"Sudo Su" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"super@unit.com", - "Name":"Sudo Su" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId", - "DateTimeReceived":"2015-04-21T01:36:22Z", - "DateTimeSent":"2015-04-21T01:35:54Z", - "IsDeliveryReceiptRequested":null, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=weblink4&exvsurl=1&viewmodel=ReadMessageItem" - } - ], - "@odata.nextLink":"https://outlook.office365.com/api/v1.0/me/messages/?%24filter=IsRead+eq+true&%24skip=10" -} diff --git a/tests/run_test.sh b/tests/run_test.sh deleted file mode 100755 index 4caf91bfad69b..0000000000000 --- a/tests/run_test.sh +++ /dev/null @@ -1,18 +0,0 @@ -echo "python test_attachment.py" -python test_attachment.py -echo "python test_cal.py" -python test_cal.py -echo "python test_event.py" -python test_event.py -echo "python test_inbox.py" -python test_inbox.py -echo "python test_message.py" -python test_message.py -echo "python test_schedule.py" -python test_schedule.py -echo "python test_group.py" -python test_group.py -echo "python test_contact.py" -python test_contact.py - -echo "all tests done." diff --git a/tests/run_test3.sh b/tests/run_test3.sh deleted file mode 100755 index 71ce788b930df..0000000000000 --- a/tests/run_test3.sh +++ /dev/null @@ -1,18 +0,0 @@ -echo "python3 test_attachment.py" -python3 test_attachment.py -echo "python3 test_cal.py" -python3 test_cal.py -echo "python3 test_event.py" -python3 test_event.py -echo "python3 test_inbox.py" -python3 test_inbox.py -echo "python3 test_message.py" -python3 test_message.py -echo "python3 test_schedule.py" -python3 test_schedule.py -echo "python3 test_group.py" -python3 test_group.py -echo "python3 test_contact.py" -python3 test_contact.py - -echo "all tests done." diff --git a/tests/run_tests_notes.txt b/tests/run_tests_notes.txt new file mode 100644 index 0000000000000..1cbeafb1e6918 --- /dev/null +++ b/tests/run_tests_notes.txt @@ -0,0 +1,8 @@ +To run this tests you will need pytest installed. + +This tests also needs a "config.py" file with two variables: + +CLIENT_ID = 'you client_id' +CLIENT_SECRET = 'your client_secret' + +For oauth to work you will need to include the o365_token.txt file inside the tests folder once it's configured from the standard oauth authorization flow. \ No newline at end of file diff --git a/tests/test_attachment.py b/tests/test_attachment.py deleted file mode 100644 index cf3fc4a950145..0000000000000 --- a/tests/test_attachment.py +++ /dev/null @@ -1,69 +0,0 @@ -from O365 import attachment -import unittest -import json -import base64 -from random import randint - -att_rep = open('attachment.json','r').read() -att_j = json.loads(att_rep) - -class TestAttachment (unittest.TestCase): - - def setUp(self): - self.att = attachment.Attachment(att_j['value'][0]) - - def test_isType(self): - self.assertTrue(self.att.isType('txt')) - - def test_getType(self): - self.assertEqual(self.att.getType(),'.txt') - - def test_save(self): - name = self.att.json['Name'] - name1 = self.newFileName(name) - self.att.json['Name'] = name1 - self.assertTrue(self.att.save('/tmp')) - with open('/tmp/'+name1,'r') as ins: - f = ins.read() - self.assertEqual('testing w00t!',f) - - name2 = self.newFileName(name) - self.att.json['Name'] = name2 - self.assertTrue(self.att.save('/tmp/')) - with open('/tmp/'+name2,'r') as ins: - f = ins.read() - self.assertEqual('testing w00t!',f) - - def newFileName(self,val): - for i in range(4): - val = str(randint(0,9)) + val - - return val - - def test_getByteString(self): - self.assertEqual(self.att.getByteString(),b'testing w00t!') - - def test_getBase64(self): - self.assertEqual(self.att.getBase64(),'dGVzdGluZyB3MDB0IQ==\n') - - def test_setByteString(self): - test_string = b'testing testie test' - self.att.setByteString(test_string) - - enc = base64.encodebytes(test_string) - - self.assertEqual(self.att.json['ContentBytes'],enc) - - def setBase64(self): - wrong_test_string = 'I am sooooo not base64 encoded.' - right_test_string = 'Base64 <3 all around!' - enc = base64.encodestring(right_test_string) - - self.assertRaises(self.att.setBase64(wrong_test_string)) - self.assertEqual(self.att.json['ContentBytes'],'dGVzdGluZyB3MDB0IQ==\n') - - self.att.setBase64(enc) - self.assertEqual(self.att.json['ContentBytes'],enc) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_cal.py b/tests/test_cal.py deleted file mode 100644 index 4baa9978c68c3..0000000000000 --- a/tests/test_cal.py +++ /dev/null @@ -1,93 +0,0 @@ -from O365 import cal -import unittest -import json -import time - -class Event: - '''mock up event class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -cal.Event = Event - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -event_rep = open('events.json','r').read() -no_event_rep = '''{"@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Calendars('bigolguid')/CalendarView","value":[]}''' - -sch_rep = '''{"@odata.context": "https://outlook.office365.com/EWS/OData/$metadata#Me/Calendars", "value": [{"Name": "Calendar", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigolguid=\')", "ChangeKey": "littleguid=", "Id": "bigolguid=", "@odata.etag": "W/\\"littleguid=\\""}, {"Name": "dat other cal", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigoldguid2=\')", "ChangeKey": "littleguid2=", "Id": "bigoldguid2=", "@odata.etag": "W/\\"littleguid2=\\""}]}''' - -t_string = '%Y-%m-%dT%H:%M:%SZ' - -s1 = '2015-04-20T17:18:25Z' -e1 = '2016-04-20T17:18:25Z' - -s2 = time.strftime(t_string) -e2 = time.time() -e2 += 3600*24*365 -e2 = time.gmtime(e2) -e2 = time.strftime(t_string,e2) - -s3 = s1 -e3 = '2015-04-25T17:18:25Z' - -def get(url,**params): - t1_url = 'https://outlook.office365.com/api/v1.0/me/calendars/bigoldguid2=/calendarview?startDateTime={0}&endDateTime={1}'.format(s1,e1) - t2_url = 'https://outlook.office365.com/api/v1.0/me/calendars/bigoldguid2=/calendarview?startDateTime={0}&endDateTime={1}'.format(s2,e2) - t3_url = 'https://outlook.office365.com/api/v1.0/me/calendars/bigoldguid2=/calendarview?startDateTime={0}&endDateTime={1}'.format(s3,e3) - if url == t1_url: - ret = Resp(event_rep) - elif url == t2_url: - ret = Resp(no_event_rep) - elif url == t3_url: - ret = Resp(event_rep) - else: - print(url) - print(t1_url) - print(t2_url) - print(t3_url) - raise - if params['auth'][0] != 'test@unit.com': - raise - if params['auth'][1] != 'pass': - raise - - return ret - -cal.requests.get = get - -auth = ('test@unit.com','pass') - -class TestCalendar (unittest.TestCase): - - def setUp(self): - caljson = json.loads(sch_rep) - self.cal = cal.Calendar(caljson['value'][1],auth) - - def test_getName(self): - self.assertEqual('dat other cal',self.cal.getName()) - - def test_getCalendarId(self): - self.assertEqual('bigoldguid2=',self.cal.getCalendarId()) - - def test_getId(self): - self.assertEqual('bigoldguid2=',self.cal.getCalendarId()) - - def test_getEvents_blank(self): - self.assertEqual(0,len(self.cal.events)) - self.cal.getEvents() - self.assertEqual(0,len(self.cal.events)) - - def test_auth(self): - self.assertEqual('test@unit.com',self.cal.auth[0]) - self.assertEqual('pass',self.cal.auth[1]) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_contact.py b/tests/test_contact.py deleted file mode 100644 index a010101213f44..0000000000000 --- a/tests/test_contact.py +++ /dev/null @@ -1,108 +0,0 @@ -from O365 import contact -import unittest -import json -import time - - -class Resp: - def __init__(self,json_string,code=None): - self.jsons = json_string - self.status_code = code - - def json(self): - return json.loads(self.jsons) - -contact_rep = open('contacts.json','r').read() -contacts_json = json.loads(contact_rep) -jeb = contacts_json['value'][0] -bob = contacts_json['value'][2] - -t_string = '%Y-%m-%dT%H:%M:%SZ' -urls = ['https://outlook.office365.com/api/v1.0/me/contacts/', - 'https://outlook.office365.com/api/v1.0/me/contacts/bigguid1', - 'https://outlook.office365.com/api/v1.0/me/contacts/bigguid2', - 'https://outlook.office365.com/api/v1.0/me/contacts/bigguid3'] - -def delete(url,headers,auth): - if url not in urls: - print(url) - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'text/plain': - raise BaseException('header accept wrong.') - - return Resp(None,204) - -contact.requests.delete = delete - -def post(url,data,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - if json.loads(data) != jeb and json.loads(data) != bob: - raise BaseException('data is wrong.') - - return Resp(data,202) - #return True - -contact.requests.post = post - -def patch(url,data,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - return Resp(data,202) - #return True - -contact.requests.patch = patch - -auth = ('test@unit.com','pass') - -class TestInbox (unittest.TestCase): - - def setUp(self): - self.jeb = contact.Contact(jeb,auth) - self.bob = contact.Contact(bob,auth) - - def test_create(self): - self.assertTrue(self.jeb.create()) - self.assertTrue(self.bob.create()) - - def test_update(self): - self.assertTrue(self.jeb.update()) - self.assertTrue(self.bob.update()) - - def test_delete(self): - self.assertTrue(self.jeb.delete()) - self.assertTrue(self.bob.delete()) - - def test_auth(self): - self.assertEqual('test@unit.com',self.jeb.auth[0]) - self.assertEqual('pass',self.jeb.auth[1]) - - self.assertEqual('test@unit.com',self.bob.auth[0]) - self.assertEqual('pass',self.bob.auth[1]) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_event.py b/tests/test_event.py deleted file mode 100644 index 8443ee8611452..0000000000000 --- a/tests/test_event.py +++ /dev/null @@ -1,111 +0,0 @@ -from O365 import event -import unittest -import json -import time - -class Calendar: - '''mock up calendar class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - self.calendarId = json['Id'] - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -event_rep = open('events.json','r').read() -events_json = json.loads(event_rep) -lough = events_json['value'][0] -oughter = events_json['value'][1] - -t_string = '%Y-%m-%dT%H:%M:%SZ' -urls = ['https://outlook.office365.com/api/v1.0/me/events/bigolguid=', - 'https://outlook.office365.com/api/v1.0/me/events/otherguid'] - -def delete(url,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'text/plain': - raise BaseException('header accept wrong.') - - return True - -event.requests.delete = delete - -def post(url,data,headers,auth): - if url != 'https://outlook.office365.com/api/v1.0/me/calendars/0/events': - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - if json.loads(data) != lough and json.loads(data) != oughter: - raise BaseException('data is wrong.') - - return Resp(data) - -event.requests.post = post - -def patch(url,data,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - return Resp(data) - -event.requests.patch = patch - -auth = ('test@unit.com','pass') - -cal_json = {'Id':0} -cal = Calendar(cal_json,auth) - -class TestInbox (unittest.TestCase): - - def setUp(self): - self.lough = event.Event(lough,auth,cal) - self.oughter = event.Event(oughter,auth,cal) - - def test_create(self): - self.assertTrue(self.lough.create()) - self.assertTrue(self.oughter.create()) - - def test_update(self): - self.assertTrue(self.lough.update()) - self.assertTrue(self.oughter.update()) - - def test_delete(self): - self.assertTrue(self.lough.delete()) - self.assertTrue(self.oughter.delete()) - - def test_auth(self): - self.assertEqual('test@unit.com',self.lough.auth[0]) - self.assertEqual('pass',self.lough.auth[1]) - - self.assertEqual('test@unit.com',self.oughter.auth[0]) - self.assertEqual('pass',self.oughter.auth[1]) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_group.py b/tests/test_group.py deleted file mode 100644 index a8532beb63223..0000000000000 --- a/tests/test_group.py +++ /dev/null @@ -1,85 +0,0 @@ -from O365 import group -import unittest -import json - -class Contact: - '''mock up Contact class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -group.Contact = Contact - -class Resp: - def __init__(self,json_string,status_code=204): - self.jsons = json_string - self.status_code = status_code - - def json(self): - return json.loads(self.jsons) - -cat = open('contacts.json','r').read() -grop = open('groups.json','r').read() -bill = open('conbill.json','r').read() - - -con_folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders/{0}/contacts' -folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders?$filter=DisplayName eq \'{0}\'' - -engiurl = 'https://outlook.office365.com/api/v1.0/me/contactfolders?$filter=DisplayName eq \'Engineers\'' -billurl = 'https://outlook.office365.com/api/v1.0/me/contactfolders/engiID/contacts' -con_url = 'https://outlook.office365.com/api/v1.0/me/contacts' - -def get(url,auth,params=None): - ret = True - if url == engiurl: - ret = Resp(grop) - elif url == con_url: - ret = Resp(cat) - elif url == billurl: - ret = Resp(bill) - else: - raise Exception('Wrong URL') - if auth[0] != 'Wernher.VonKerman@ksp.org': - raise Exception('Wrong Email') - if auth[1] != 'rakete': - raise Exception('Wrong Password') - - return ret - -group.requests.get = get - -class TestGroup (unittest.TestCase): - - def setUp(self): - self.cons = group.Group(('Wernher.VonKerman@ksp.org','rakete')) - self.folds = group.Group(('Wernher.VonKerman@ksp.org','rakete'),'Engineers') - - def test_getContacts(self): - #Sanity check - self.assertEqual(len(self.cons.contacts),0) - - #real test - self.assertTrue(self.cons.getContacts()) - - self.assertEqual(len(self.cons.contacts),3) - - def test_folders(self): - #Sanity check - self.assertEqual(len(self.folds.contacts),0) - - #real test - self.assertTrue(self.folds.getContacts()) - - self.assertEqual(len(self.folds.contacts),1) - - def test_auth(self): - self.assertEqual('Wernher.VonKerman@ksp.org',self.cons.auth[0]) - self.assertEqual('rakete',self.cons.auth[1]) - - self.assertEqual('Wernher.VonKerman@ksp.org',self.folds.auth[0]) - self.assertEqual('rakete',self.folds.auth[1]) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_inbox.py b/tests/test_inbox.py deleted file mode 100644 index 00815b16f8b39..0000000000000 --- a/tests/test_inbox.py +++ /dev/null @@ -1,91 +0,0 @@ -from O365 import inbox -import unittest -import json - -class Message: - '''mock up Message class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -inbox.Message = Message - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -read_rep = open('read_message.json','r').read() -un_rep = open('unread_message.json','r').read() - - -def get(url,auth,params): - if url == 'https://outlook.office365.com/api/v1.0/me/messages': -# print params - if params['$filter'] == 'IsRead eq false': -# print 'getting the unread' - ret = Resp(un_rep) - else: -# print 'getting the read' - ret = Resp(read_rep) - else: - raise Exception('Wrong URL') - if auth[0] != 'test@unit.com': - raise Exception('Wrong Email') - if auth[1] != 'pass': - raise Exception('Wrong Password') - - return ret - -inbox.requests.get = get - -class TestInbox (unittest.TestCase): - - def setUp(self): - self.preFetch = inbox.Inbox(('test@unit.com','pass')) - self.JITFetch = inbox.Inbox(('test@unit.com','pass'),getNow=False) - - def test_getMessages(self): - #test to see if they got the messages already, should only work for prefetch - self.assertEqual(len(self.preFetch.messages),1) - self.assertEqual(len(self.JITFetch.messages),0) - - #test to see what happens when they try to download again. this specifically - #addresses an issue raised in on github for issue #3 - self.preFetch.getMessages() - self.JITFetch.setFilter('IsRead eq false') - self.JITFetch.getMessages() - self.assertEqual(len(self.preFetch.messages),1) - self.assertEqual(len(self.JITFetch.messages),1) - - - def test_getRead(self): - #sanity check - self.assertEqual(len(self.preFetch.messages),1) - self.assertEqual(len(self.JITFetch.messages),0) - - - #now fetch the un-read emails. prefetch should still have one extra. - self.preFetch.setFilter('IsRead eq true') - self.preFetch.getMessages() - self.JITFetch.setFilter('IsRead eq true') - self.JITFetch.getMessages() - self.assertEqual(len(self.JITFetch.messages),4) - self.assertEqual(len(self.preFetch.messages),5) - - - def test_auth(self): - self.assertEqual('test@unit.com',self.preFetch.auth[0]) - self.assertEqual('pass',self.preFetch.auth[1]) - - self.assertEqual('test@unit.com',self.JITFetch.auth[0]) - self.assertEqual('pass',self.JITFetch.auth[1]) - - def test_filters(self): - pass - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_mailbox.py b/tests/test_mailbox.py new file mode 100644 index 0000000000000..864e0abfd62f4 --- /dev/null +++ b/tests/test_mailbox.py @@ -0,0 +1,10 @@ +#from O365 import Account + + +class TestMailBox: + + def setup_class(self): + pass + + def teardown_class(self): + pass diff --git a/tests/test_message.py b/tests/test_message.py index 410ae973e1157..ca41e56899c38 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,159 +1,11 @@ +from pathlib import Path +#from O365 import Account -from O365 import message -import unittest -import json +class TestMessage: -class Attachment: - '''mock up Message class''' - def __init__(self,json): - self.json = json -message.Attachment = Attachment + def setup_class(self): + pass -class Resp: - def __init__(self,json_string,code=200): - self.jsons = json_string - self.status_code = code - - def json(self): - return json.loads(self.jsons) - -read_rep = open('read_message.json','r').read() -un_rep = open('unread_message.json','r').read() -att_m_rep = open('attachment_message.json','r').read() -att_rep = open('attachment.json','r').read() -new_rep = open('newmessage.json','r').read() - -def get(url,**params): - if url == 'https://outlook.office365.com/api/v1.0/me/messages/bigoldguid/attachments': - ret = Resp(att_rep) - else: - raise - if params['auth'][0] != 'test@unit.com': - raise - if params['auth'][1] != 'pass': - raise - - return ret - -message.requests.get = get - -def post(url,data,headers,auth): - if url != 'https://outlook.office365.com/api/v1.0/me/sendmail': - raise - if auth[0] != 'test@unit.com': - raise - if auth[1] != 'pass': - raise - if headers['Content-type'] != 'application/json': - raise - if headers['Accept'] != 'text/plain': - raise - - if isinstance(data,dict) and 'Message' in data.keys(): - if data['Message']['Body']['Content'] == 'The new Cafetaria is open.': - return Resp(None,202) - else: - return Resp(None,400) - else: - return Resp(None,202) - - - -message.requests.post = post - -def patch(url,data,headers,auth): - if url != 'https://outlook.office365.com/api/v1.0/me/messages/big guid=': - raise - if auth[0] != 'test@unit.com': - raise - if auth[1] != 'pass': - raise - if headers['Content-type'] != 'application/json': - raise - if headers['Accept'] != 'application/json': - raise - return True - -message.requests.patch = patch - -auth = ('test@unit.com','pass') - -class TestMessage (unittest.TestCase): - - def setUp(self): - ur = json.loads(un_rep)['value'][0] - self.unread = message.Message(ur,auth) - re = json.loads(read_rep)['value'][0] - self.read = message.Message(re,auth) - att = json.loads(att_m_rep)['value'][0] - self.att = message.Message(att,auth) - - self.newm = message.Message(auth=auth) - - def test_fetchAttachments(self): - self.assertTrue(len(self.att.attachments) == 0) - self.assertTrue(len(self.unread.attachments) == 0) - self.assertTrue(len(self.read.attachments) == 0) - - self.assertEqual(1,self.att.fetchAttachments()) - self.assertEqual(0,self.unread.fetchAttachments()) - self.assertEqual(0,self.read.fetchAttachments()) - - self.assertTrue(len(self.att.attachments) == 1) - self.assertTrue(len(self.unread.attachments) == 0) - self.assertTrue(len(self.read.attachments) == 0) - - def test_sendMessage(self): - self.assertTrue(self.read.sendMessage()) - - self.assertFalse(self.newm.sendMessage()) - - self.newm.setSubject('Meet for lunch?') - self.newm.setBody('The new cafeteria is open.') - self.newm.setRecipients('garthf@1830edad9050849NDA1.onmicrosoft.com') - self.assertTrue(self.newm.sendMessage()) - - def test_markAsRead(self): - self.unread.markAsRead() - - def test_setCategories(self): - self.unread.setCategories(["Green", "Yellow"]) - - def test_setRecipients(self): - self.assertTrue(len(self.read.json['ToRecipients']) == 1) - self.assertTrue(len(self.unread.json['ToRecipients']) == 1) - self.assertTrue(len(self.att.json['ToRecipients']) == 1) - - self.read.setRecipients('bob@unit.com') - self.assertTrue(self.read.json['ToRecipients'][0]['EmailAddress']['Address'] == 'bob@unit.com') - - self.unread.setRecipients({'EmailAddress':{'Address':'bob@unit.com','Name':'What about'}}) - self.assertTrue(self.unread.json['ToRecipients'][0]['EmailAddress']['Address'] == 'bob@unit.com') - self.assertTrue(self.unread.json['ToRecipients'][0]['EmailAddress']['Name'] == 'What about') - - self.att.setRecipients([{'EmailAddress':{'Address':'bob@unit.com','Name':'What about'}}]) - self.assertTrue(self.att.json['ToRecipients'][0]['EmailAddress']['Address'] == 'bob@unit.com') - self.assertTrue(self.att.json['ToRecipients'][0]['EmailAddress']['Name'] == 'What about') - - def test_addRecipient(self): - self.assertTrue(len(self.read.json['ToRecipients']) == 1) - - self.read.addRecipient('second@unit.com','later') - - self.assertTrue(len(self.read.json['ToRecipients']) == 2) - - self.assertTrue(self.read.json['ToRecipients'][1]['EmailAddress']['Address'] == 'second@unit.com') - self.assertTrue(self.read.json['ToRecipients'][1]['EmailAddress']['Name'] == 'later') - - - def test_auth(self): - self.assertEqual(auth[0],self.read.auth[0]) - self.assertEqual(auth[1],self.read.auth[1]) - self.assertEqual(auth[0],self.unread.auth[0]) - self.assertEqual(auth[1],self.unread.auth[1]) - self.assertEqual(auth[0],self.att.auth[0]) - self.assertEqual(auth[1],self.att.auth[1]) - -if __name__ == '__main__': - unittest.main() + def teardown_class(self): + pass diff --git a/tests/test_schedule.py b/tests/test_schedule.py deleted file mode 100644 index 6b35d111dff53..0000000000000 --- a/tests/test_schedule.py +++ /dev/null @@ -1,51 +0,0 @@ -from O365 import schedule -import unittest -import json - -class Calendar: - '''mock up calendar class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -schedule.Calendar = Calendar - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -sch_rep = '''{"@odata.context": "https://outlook.office365.com/EWS/OData/$metadata#Me/Calendars", "value": [{"Name": "Calendar", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigolguid=\')", "ChangeKey": "littleguid=", "Id": "bigolguid=", "@odata.etag": "W/\\"littleguid=\\""}, {"Name": "dat other cal", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigoldguid2=\')", "ChangeKey": "littleguid2=", "Id": "bigoldguid2=", "@odata.etag": "W/\\"littleguid2=\\""}]}''' - - -def get(url,**params): - if url != 'https://outlook.office365.com/api/v1.0/me/calendars': - raise - if params['auth'][0] != 'test@unit.com': - raise - if params['auth'][1] != 'pass': - raise - - ret = Resp(sch_rep) - return ret - -schedule.requests.get = get - -class TestSchedule (unittest.TestCase): - - def setUp(self): - self.val = schedule.Schedule(('test@unit.com','pass')) - - def test_getCalendar(self): - self.val.getCalendars() - self.assertEqual(2,len(self.val.calendars)) - - def test_auth(self): - self.assertEqual('test@unit.com',self.val.auth[0]) - self.assertEqual('pass',self.val.auth[1]) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/unread_message.json b/tests/unread_message.json deleted file mode 100644 index 3cda7eb9a422a..0000000000000 --- a/tests/unread_message.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('big guid=')", - "@odata.etag":"lil guid", - "Id":"big guid=", - "ChangeKey":"lil guid", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-20T16:17:42Z", - "DateTimeLastModified":"2015-04-22T10:10:38Z", - "Subject":"unread test email", - "BodyPreview":"this is the ", - "Body":{ - "ContentType":"HTML", - "Content":"this is the body fully" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId=", - "From":{ - "EmailAddress":{ - "Address":"sender@unit.com", - "Name":"Sender for Test" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"sender@unit.com", - "Name":"Sender for Test" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester of Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId=", - "DateTimeReceived":"2015-04-20T16:17:42Z", - "DateTimeSent":"2015-04-20T16:17:39Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":false, - "WebLink":"https://outlook.office365.com/owa/?ItemID=daitemidyo&exvsurl=1&viewmodel=ReadMessageItem" - } - ] -}