├── version.json
├── requirements.txt
├── requirements-dev.txt
├── pyo365
├── __version__.py
├── utils
│ ├── __init__.py
│ ├── utils.py
│ ├── attachment.py
│ └── windows_tz.py
├── __init__.py
├── account.py
├── mailbox.py
├── address_book.py
├── message.py
└── connection.py
├── tests
├── run_tests_notes.txt
├── test_mailbox.py
└── test_message.py
├── .gitignore
├── setup.py
├── release.py
├── LICENSE
└── README.md
/version.json:
--------------------------------------------------------------------------------
1 | {"version": "0.1.3"}
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | beautifulsoup4==4.6.3
2 | python-dateutil==2.7.4
3 | pytz==2018.6
4 | requests==2.20.0
5 | requests-oauthlib==1.0.0
6 | stringcase==1.2.0
7 | tzlocal==1.5.1
8 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | beautifulsoup4==4.6.3
2 | python-dateutil==2.7.4
3 | pytz==2018.6
4 | requests==2.20.0
5 | requests-oauthlib==1.0.0
6 | stringcase==1.2.0
7 | tzlocal==1.5.1
8 | Click==7.0
9 | pytest==3.9.1
10 | twine==1.12.1
11 | wheel==0.32.1
--------------------------------------------------------------------------------
/pyo365/__version__.py:
--------------------------------------------------------------------------------
1 | from pkg_resources import get_distribution, DistributionNotFound
2 |
3 |
4 | try:
5 | __version__ = get_distribution(__name__.split('.')[0]).version
6 | except DistributionNotFound:
7 | # Package is not installed.
8 | __version__ = None
9 |
--------------------------------------------------------------------------------
/pyo365/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
2 | from .utils import ApiComponent, OutlookWellKnowFolderNames, OneDriveWellKnowFolderNames, \
3 | Pagination, Query, NEXT_LINK_KEYWORD, ME_RESOURCE, ImportanceLevel, TrackerSet
4 | from .attachment import BaseAttachments, BaseAttachment, AttachableMixin
5 | from .windows_tz import IANA_TO_WIN, WIN_TO_IANA
6 |
--------------------------------------------------------------------------------
/tests/run_tests_notes.txt:
--------------------------------------------------------------------------------
1 | To run this tests you will need pytest installed.
2 |
3 | This tests also needs a "config.py" file with two variables:
4 |
5 | CLIENT_ID = 'you client_id'
6 | CLIENT_SECRET = 'your client_secret'
7 |
8 | 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.
--------------------------------------------------------------------------------
/pyo365/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | A simple python library to interact with Microsoft Graph and Office 365 API
3 | """
4 |
5 | from .__version__ import __version__
6 |
7 | from .account import Account
8 | from .connection import Connection, Protocol, MSGraphProtocol, MSOffice365Protocol, oauth_authentication_flow
9 | from .mailbox import MailBox
10 | from .message import Message, MessageAttachment, Recipient
11 | from .address_book import AddressBook, Contact, RecipientType
12 | from .calendar import Schedule, Calendar, Event, EventResponse, AttendeeType, EventSensitivity, EventShowAs, CalendarColors, EventAttachment
13 | from .drive import Storage, Drive, Folder, File, Image, Photo
14 | from .utils import OneDriveWellKnowFolderNames, OutlookWellKnowFolderNames, ImportanceLevel
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | lib/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | *.egg-info/
22 | .installed.cfg
23 | *.egg
24 |
25 | # PyInstaller
26 | # Usually these files are written by a python script from a template
27 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
28 | *.manifest
29 | *.spec
30 |
31 | # Installer logs
32 | pip-log.txt
33 | pip-delete-this-directory.txt
34 |
35 | # Unit test / coverage reports
36 | htmlcov/
37 | .tox/
38 | .coverage
39 | .cache
40 | nosetests.xml
41 | coverage.xml
42 |
43 | # Translations
44 | *.mo
45 | *.pot
46 |
47 | # Django stuff:
48 | *.log
49 |
50 | # Sphinx documentation
51 | docs/_build/
52 |
53 | # PyBuilder
54 | target/
55 |
56 | # Passwords and other things I care to publish
57 | *.pw
58 | *bookings.json
59 | */pid
60 |
61 | .idea/
62 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from setuptools import setup, find_packages
3 |
4 |
5 | def read(fname):
6 | """ Returns the contents of the fname file """
7 | with open(os.path.join(os.path.dirname(__file__), fname), 'r') as file:
8 | return file.read()
9 |
10 |
11 | # Available classifiers: https://pypi.org/pypi?%3Aaction=list_classifiers
12 | CLASSIFIERS = [
13 | 'Development Status :: 4 - Beta',
14 | 'Intended Audience :: Developers',
15 | 'License :: OSI Approved :: Apache Software License',
16 | 'Topic :: Office/Business :: Office Suites',
17 | 'Topic :: Software Development :: Libraries',
18 | 'Programming Language :: Python',
19 | 'Programming Language :: Python :: 3 :: Only',
20 | 'Programming Language :: Python :: 3.4',
21 | 'Programming Language :: Python :: 3.5',
22 | 'Programming Language :: Python :: 3.6',
23 | 'Programming Language :: Python :: 3.7',
24 | 'Programming Language :: Python :: 3.8',
25 | 'Operating System :: OS Independent',
26 | ]
27 |
28 |
29 | requires = [
30 | 'requests>=2.0.0',
31 | 'requests_oauthlib>=1.0.0',
32 | 'python-dateutil>=2.7',
33 | 'pytz>=2018.5',
34 | 'tzlocal>=1.5.0',
35 | 'beautifulsoup4>=4.0.0',
36 | 'stringcase>=1.2.0'
37 | ]
38 |
39 | setup(
40 | name='pyo365',
41 | version='0.1.3',
42 | packages=find_packages(),
43 | url=' https://github.com/janscas/pyo365',
44 | license='Apache License 2.0',
45 | author='Janscas',
46 | author_email='janscas@users.noreply.github.com',
47 | maintainer='Janscas',
48 | maintainer_email='janscas@users.noreply.github.com',
49 | description='A simple python library to interact with Microsoft Graph and Office 365 API',
50 | long_description=read('README.md'),
51 | long_description_content_type="text/markdown",
52 | classifiers=CLASSIFIERS,
53 | python_requires=">=3.4",
54 | install_requires=requires,
55 | )
56 |
--------------------------------------------------------------------------------
/tests/test_mailbox.py:
--------------------------------------------------------------------------------
1 | from tests.config import CLIENT_ID, CLIENT_SECRET
2 | from pyo365 import Account
3 |
4 |
5 | class TestMailBox:
6 |
7 | def setup_class(self):
8 | credentials = (CLIENT_ID, CLIENT_SECRET)
9 | self.account = Account(credentials)
10 | self.mailbox = self.account.mailbox()
11 | self.folder_name = 'Test Drafts Subfolder'
12 |
13 | def teardown_class(self):
14 | pass
15 |
16 | def test_get_mailbox_folders(self):
17 | folders = self.mailbox.get_folders(limit=5)
18 |
19 | assert len(folders) > 0
20 |
21 | def test_create_child_folder(self):
22 | drafts = self.mailbox.drafts_folder()
23 |
24 | new_folder = drafts.create_child_folder(self.folder_name)
25 |
26 | assert new_folder is not None
27 |
28 | def test_get_folder_by_name(self):
29 | drafts = self.mailbox.drafts_folder()
30 |
31 | q = self.mailbox.q('display_name').equals(self.folder_name)
32 |
33 | folder = drafts.get_folder(folder_name=self.folder_name)
34 |
35 | assert folder is not None
36 |
37 | def test_get_parent_folder(self):
38 | new_folder = self.mailbox.drafts_folder().get_folder(folder_name=self.folder_name)
39 |
40 | if new_folder:
41 | parent_folder = new_folder.get_parent_folder()
42 |
43 | assert new_folder and parent_folder is not None
44 |
45 | def test_get_child_folders(self):
46 | new_folder = self.mailbox.drafts_folder().get_folder(folder_name=self.folder_name)
47 |
48 | if new_folder:
49 | parent_folder = new_folder.get_parent_folder()
50 | child_folders = parent_folder.get_folders(limit=2)
51 |
52 | assert new_folder and parent_folder and len(child_folders) >= 1 and any(folder.name == self.folder_name for folder in child_folders)
53 |
54 | def test_move_folder(self):
55 | new_folder = self.mailbox.drafts_folder().get_folder(folder_name=self.folder_name)
56 | sent_folder = self.mailbox.sent_folder()
57 | if new_folder:
58 | moved = new_folder.move_folder(sent_folder)
59 |
60 | assert new_folder and moved
61 |
62 | def test_copy_folder(self):
63 | new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now
64 | drafts_folder = self.mailbox.drafts_folder()
65 |
66 | if new_folder:
67 | copied_folder = new_folder.copy_folder(drafts_folder)
68 | deleted = copied_folder.delete() # delete this copy early on
69 |
70 | assert new_folder and copied_folder is not None and deleted
71 |
72 | def test_refresh_folder(self):
73 | # new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now
74 |
75 | sent_folder = self.mailbox.sent_folder()
76 |
77 | old_id = sent_folder.folder_id
78 | refreshed = sent_folder.refresh_folder()
79 | new_id = sent_folder.folder_id
80 |
81 | assert refreshed and old_id != new_id
82 |
83 | def test_update_folder_name(self):
84 | new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now
85 |
86 | if new_folder:
87 | old_name = new_folder.name
88 | updated = new_folder.update_folder_name(self.folder_name + ' new name!')
89 |
90 | assert new_folder and updated and old_name != new_folder.name
91 |
92 | def test_delete_folder(self):
93 | new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now
94 |
95 | if new_folder:
96 | deleted = new_folder.delete()
97 |
98 | assert new_folder and deleted
99 |
--------------------------------------------------------------------------------
/release.py:
--------------------------------------------------------------------------------
1 | """
2 | Release script
3 | """
4 |
5 | import os
6 | import shutil
7 | import subprocess
8 | import sys
9 | import requests
10 | from pathlib import Path
11 |
12 | # noinspection PyPackageRequirements
13 | import click
14 |
15 |
16 | PYPI_PACKAGE_NAME = 'pyo365'
17 | PYPI_URL = 'https://pypi.org/pypi/{package}/json'
18 | DIST_PATH = 'dist'
19 | DIST_PATH_DELETE = 'dist_delete'
20 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
21 |
22 |
23 | @click.group(context_settings=CONTEXT_SETTINGS)
24 | def cli():
25 | pass
26 |
27 |
28 | @cli.command()
29 | @click.option('--force/--no-force', default=False, help='Will force a new build removing the previous ones')
30 | def build(force):
31 | """ Builds the distribution files: wheels and source. """
32 | dist_path = Path(DIST_PATH)
33 | if dist_path.exists() and list(dist_path.glob('*')):
34 | if force or click.confirm('{} is not empty - delete contents?'.format(dist_path)):
35 | dist_path.rename(DIST_PATH_DELETE)
36 | shutil.rmtree(Path(DIST_PATH_DELETE))
37 | dist_path.mkdir()
38 | else:
39 | click.echo('Aborting')
40 | sys.exit(1)
41 |
42 | subprocess.check_call(['python', 'setup.py', 'bdist_wheel'])
43 | subprocess.check_call(['python', 'setup.py', 'sdist',
44 | '--formats=gztar'])
45 |
46 |
47 | @cli.command()
48 | @click.option('--release/--no-release', default=False, help='--release to upload to pypi otherwise upload to test.pypi')
49 | @click.option('--rebuild/--no-rebuild', default=True, help='Will force a rebuild of the build files (src and wheels)')
50 | @click.pass_context
51 | def upload(ctx, release, rebuild):
52 | """ Uploads distribuition files to pypi or pypitest. """
53 |
54 | dist_path = Path(DIST_PATH)
55 | if rebuild is False:
56 | if not dist_path.exists() or not list(dist_path.glob('*')):
57 | print("No distribution files found. Please run 'build' command first")
58 | return
59 | else:
60 | ctx.invoke(build, force=True)
61 |
62 | if release:
63 | args = ['twine', 'upload', 'dist/*']
64 | else:
65 | repository = 'https://test.pypi.org/legacy/'
66 | args = ['twine', 'upload', '--repository-url', repository, 'dist/*']
67 |
68 | env = os.environ.copy()
69 |
70 | p = subprocess.Popen(args, env=env)
71 | p.wait()
72 |
73 |
74 | @cli.command()
75 | def check():
76 | """ Checks the long description. """
77 | dist_path = Path(DIST_PATH)
78 | if not dist_path.exists() or not list(dist_path.glob('*')):
79 | print("No distribution files found. Please run 'build' command first")
80 | return
81 |
82 | subprocess.check_call(['twine', 'check', 'dist/*'])
83 |
84 |
85 | # noinspection PyShadowingBuiltins
86 | @cli.command(name='list')
87 | def list_releases():
88 | """ Lists all releases published on pypi"""
89 | response = requests.get(PYPI_URL.format(package=PYPI_PACKAGE_NAME))
90 | if response:
91 | data = response.json()
92 |
93 | releases_dict = data.get('releases', {})
94 |
95 | if releases_dict:
96 | for version, release in releases_dict.items():
97 | release_formats = []
98 | published_on_date = None
99 | for fmt in release:
100 | release_formats.append(fmt.get('packagetype'))
101 | published_on_date = fmt.get('upload_time')
102 |
103 | release_formats = ' | '.join(release_formats)
104 | print('{:<10}{:>15}{:>25}'.format(version, published_on_date, release_formats))
105 | else:
106 | print('No releases found for {}'.format(PYPI_PACKAGE_NAME))
107 | else:
108 | print('Package "{}" not found on Pypi.org'.format(PYPI_PACKAGE_NAME))
109 |
110 |
111 | if __name__ == "__main__":
112 | cli()
113 |
--------------------------------------------------------------------------------
/pyo365/account.py:
--------------------------------------------------------------------------------
1 | from pyo365.connection import Connection, Protocol, MSGraphProtocol, oauth_authentication_flow
2 | from pyo365.drive import Storage
3 | from pyo365.utils import ME_RESOURCE
4 | from pyo365.message import Message
5 | from pyo365.mailbox import MailBox
6 | from pyo365.address_book import AddressBook, GlobalAddressList
7 | from pyo365.calendar import Schedule
8 |
9 |
10 | class Account(object):
11 | """ Class helper to integrate all components into a single object """
12 |
13 | def __init__(self, credentials, *, protocol=None, main_resource=ME_RESOURCE, **kwargs):
14 | """
15 | Account constructor.
16 | :param credentials: a tuple containing the client_id and client_secret
17 | :param protocol: the protocol to be used in this account instance
18 | :param main_resource: the resource to be used by this account
19 | :param kwargs: any extra args to be passed to the Connection instance
20 | """
21 | protocol = protocol or MSGraphProtocol # defaults to Graph protocol
22 | self.protocol = protocol(default_resource=main_resource, **kwargs) if isinstance(protocol, type) else protocol
23 |
24 | if not isinstance(self.protocol, Protocol):
25 | raise ValueError("'protocol' must be a subclass of Protocol")
26 |
27 | self.con = Connection(credentials, **kwargs)
28 | self.main_resource = main_resource
29 |
30 | def __repr__(self):
31 | if self.con.auth:
32 | return 'Account Client Id: {}'.format(self.con.auth[0])
33 | else:
34 | return 'Unidentified Account'
35 |
36 | def authenticate(self, *, scopes, **kwargs):
37 | """
38 | Performs the oauth authentication flow resulting in a stored token.
39 | It uses the credentials passed on instantiation
40 | :param scopes: a list of protocol user scopes to be converted by the protocol
41 | :param kwargs: other configuration to be passed to the Connection instance
42 | """
43 | kwargs.setdefault('token_file_name', self.con.token_path.name)
44 |
45 | return oauth_authentication_flow(*self.con.auth, scopes=scopes, protocol=self.protocol, **kwargs)
46 |
47 | @property
48 | def connection(self):
49 | """ Alias for self.con """
50 | return self.con
51 |
52 | def new_message(self, resource=None):
53 | """
54 | Creates a new message to be send or stored
55 | :param resource: Custom resource to be used in this message. Defaults to parent main_resource.
56 | """
57 | return Message(parent=self, main_resource=resource, is_draft=True)
58 |
59 | def mailbox(self, resource=None):
60 | """
61 | Creates MailBox Folder instance
62 | :param resource: Custom resource to be used in this mailbox. Defaults to parent main_resource.
63 | """
64 | return MailBox(parent=self, main_resource=resource, name='MailBox')
65 |
66 | def address_book(self, *, resource=None, address_book='personal'):
67 | """
68 | Creates Address Book instance
69 | :param resource: Custom resource to be used in this address book. Defaults to parent main_resource.
70 | :param address_book: Choose from Personal or Gal (Global Address List)
71 | """
72 | if address_book == 'personal':
73 | return AddressBook(parent=self, main_resource=resource, name='Personal Address Book')
74 | elif address_book == 'gal':
75 | return GlobalAddressList(parent=self)
76 | else:
77 | raise RuntimeError('Addres_book must be either "personal" (resource address book) or "gal" (Global Address List)')
78 |
79 | def schedule(self, *, resource=None):
80 | """
81 | Creates Schedule instance to handle calendars
82 | :param resource: Custom resource to be used in this schedule object. Defaults to parent main_resource.
83 | """
84 | return Schedule(parent=self, main_resource=resource)
85 |
86 | def storage(self, *, resource=None):
87 | """
88 | Creates a Storage instance to handle file storage like OneDrive or Sharepoint document libraries
89 | :param resource: Custom resource to be used in this drive object. Defaults to parent main_resource.
90 | """
91 | if not isinstance(self.protocol, MSGraphProtocol):
92 | # TODO: a custom protocol accessing OneDrive or Sharepoint Api will fail here.
93 | raise RuntimeError('Drive options only works on Microsoft Graph API')
94 |
95 | return Storage(parent=self, main_resource=resource)
96 |
--------------------------------------------------------------------------------
/tests/test_message.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from tests.config import CLIENT_ID, CLIENT_SECRET
3 | from pyo365 import Account
4 |
5 |
6 | class TestMessage:
7 |
8 | def setup_class(self):
9 | credentials = (CLIENT_ID, CLIENT_SECRET)
10 | self.account = Account(credentials)
11 | self.mailbox = self.account.mailbox()
12 | self.inbox = self.mailbox.inbox_folder()
13 | self.drafts = self.mailbox.drafts_folder()
14 | self.test_msg_subject1 = 'Test Msg 1548lop102'
15 | self.test_msg_subject2 = 'Test Msg 1548lop103'
16 |
17 | def teardown_class(self):
18 | pass
19 |
20 | def test_get_inbox_mails(self):
21 | messages = self.inbox.get_messages(5)
22 |
23 | assert len(messages) != 0
24 |
25 | def test_new_email_draft(self):
26 | msg = self.account.new_message()
27 | msg.subject = self.test_msg_subject1
28 | msg.body = 'A message test'
29 | msg.save_draft()
30 |
31 | message = self.drafts.get_message(self.drafts.q('subject').equals(self.test_msg_subject1))
32 |
33 | assert message is not None
34 |
35 | def test_update_email(self):
36 | q = self.drafts.q('subject').equals(self.test_msg_subject1)
37 |
38 | message = self.drafts.get_message(q)
39 | message2 = None
40 |
41 | if message:
42 | message.to.add('test@example.com')
43 | saved = message.save_draft()
44 |
45 | message2 = self.drafts.get_message(q)
46 |
47 | assert message and saved and message2 and message2.to and message2.to[0].address == 'test@example.com'
48 |
49 | def test_add_attachment(self):
50 | q = self.drafts.q('subject').equals(self.test_msg_subject1)
51 |
52 | message = self.drafts.get_message(q)
53 | message2 = None
54 |
55 | if message:
56 | dummy_file = Path('dummy.txt')
57 | with dummy_file.open(mode='w') as file:
58 | file.write('Test file')
59 | message.attachments.add(dummy_file) # add this file as an attachment
60 | saved = message.save_draft()
61 | dummy_file.unlink() # delete dummy file
62 |
63 | message2 = self.drafts.get_message(q)
64 |
65 | assert message and saved and message2 and message2.has_attachments
66 |
67 | def test_remove_attachment(self):
68 | q = self.drafts.q('subject').equals(self.test_msg_subject1)
69 |
70 | message = self.drafts.get_message(q, download_attachments=True)
71 | message2 = None
72 |
73 | if message:
74 | message.attachments.clear()
75 | saved = message.save_draft()
76 |
77 | message2 = self.drafts.get_message(q)
78 |
79 | assert message and saved and message2 and not message2.has_attachments
80 |
81 | def test_delete_email(self):
82 | q = self.drafts.q('subject').equals(self.test_msg_subject1)
83 |
84 | message = self.drafts.get_message(q)
85 |
86 | if message:
87 | deleted = message.delete()
88 |
89 | message = self.drafts.get_message(q)
90 |
91 | assert deleted and message is None
92 |
93 | def test_reply(self):
94 | message = self.inbox.get_message() # get first message in inbox
95 |
96 | reply = None
97 | reply_text = 'New reply on top of the message trail.'
98 |
99 | if message:
100 | reply = message.reply()
101 | reply.body = reply_text
102 | reply.subject = self.test_msg_subject2
103 | saved = reply.save_draft()
104 |
105 | assert message and reply and reply.body != reply_text and saved
106 |
107 | def test_move_to_folder(self):
108 | q = self.mailbox.q('subject').equals(self.test_msg_subject2)
109 |
110 | message = self.drafts.get_message(q)
111 |
112 | deleted_folder = self.mailbox.deleted_folder()
113 | if message:
114 | moved = message.move(deleted_folder)
115 |
116 | message = deleted_folder.get_message(q)
117 |
118 | assert message and moved and message
119 |
120 | def test_copy_message(self):
121 | q = self.mailbox.q('subject').equals(self.test_msg_subject2)
122 |
123 | deleted_folder = self.mailbox.deleted_folder()
124 | message = deleted_folder.get_message(q)
125 |
126 | if message:
127 | copied = message.copy(self.drafts)
128 | deleted = copied.delete()
129 |
130 | assert message and copied and deleted
131 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
203 |
--------------------------------------------------------------------------------
/pyo365/utils/utils.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from enum import Enum
3 | import datetime as dt
4 | import pytz
5 | from collections import OrderedDict
6 |
7 | ME_RESOURCE = 'me'
8 | USERS_RESOURCE = 'users'
9 |
10 | NEXT_LINK_KEYWORD = '@odata.nextLink'
11 |
12 | log = logging.getLogger(__name__)
13 |
14 | MAX_RECIPIENTS_PER_MESSAGE = 500 # Actual limit on Office 365
15 |
16 |
17 | class ImportanceLevel(Enum):
18 | Normal = 'normal'
19 | Low = 'low'
20 | High = 'high'
21 |
22 |
23 | class OutlookWellKnowFolderNames(Enum):
24 | INBOX = 'Inbox'
25 | JUNK = 'JunkEmail'
26 | DELETED = 'DeletedItems'
27 | DRAFTS = 'Drafts'
28 | SENT = 'SentItems'
29 | OUTBOX = 'Outbox'
30 |
31 |
32 | class OneDriveWellKnowFolderNames(Enum):
33 | DOCUMENTS = 'documents'
34 | PHOTOS = 'photos'
35 | CAMERA_ROLL = 'cameraroll'
36 | APP_ROOT = 'approot'
37 | MUSIC = 'music'
38 | ATTACHMENTS = 'attachments'
39 |
40 |
41 | class ChainOperator(Enum):
42 | AND = 'and'
43 | OR = 'or'
44 |
45 |
46 | class TrackerSet(set):
47 | """ A Custom Set that changes the casing of it's keys """
48 |
49 | def __init__(self, *args, casing=None, **kwargs):
50 | self.cc = casing
51 | super().__init__(*args, **kwargs)
52 |
53 | def add(self, value):
54 | value = self.cc(value)
55 | super().add(value)
56 |
57 |
58 | class ApiComponent:
59 | """ Base class for all object interactions with the Cloud Service API
60 |
61 | Exposes common access methods to the api protocol within all Api objects
62 | """
63 |
64 | _cloud_data_key = '__cloud_data__' # wrapps cloud data with this dict key
65 | _endpoints = {} # dict of all API service endpoints needed
66 |
67 | def __init__(self, *, protocol=None, main_resource=None, **kwargs):
68 | """ Object initialization
69 | :param protocol: A protocol class or instance to be used with this connection
70 | :param main_resource: main_resource to be used in these API comunications
71 | :param kwargs: Extra arguments
72 | """
73 | self.protocol = protocol() if isinstance(protocol, type) else protocol
74 | if self.protocol is None:
75 | raise ValueError('Protocol not provided to Api Component')
76 | self.main_resource = self._parse_resource(main_resource or protocol.default_resource)
77 | self._base_url = '{}{}'.format(self.protocol.service_url, self.main_resource)
78 | super().__init__()
79 |
80 | @staticmethod
81 | def _parse_resource(resource):
82 | """ Parses and completes resource information """
83 | resource = resource.strip() if resource else resource
84 |
85 | if resource == ME_RESOURCE:
86 | return resource
87 | elif resource == USERS_RESOURCE:
88 | return resource
89 | elif '/' not in resource and USERS_RESOURCE not in resource:
90 | # when for example accesing a shared mailbox the resouse is set to the email address.
91 | # we have to prefix the email with the resource 'users/' so --> 'users/email_address'
92 | return '{}/{}'.format(USERS_RESOURCE, resource)
93 | else:
94 | return resource
95 |
96 | def build_url(self, endpoint):
97 | """ Returns a url for a given endpoint using the protocol service url """
98 | return '{}{}'.format(self._base_url, endpoint)
99 |
100 | def _gk(self, keyword):
101 | """ Alias for protocol.get_service_keyword """
102 | return self.protocol.get_service_keyword(keyword)
103 |
104 | def _cc(self, dict_key):
105 | """ Alias for protocol.convert_case """
106 | return self.protocol.convert_case(dict_key)
107 |
108 | def new_query(self, attribute=None):
109 | return Query(attribute=attribute, protocol=self.protocol)
110 |
111 | q = new_query # alias for new query
112 |
113 |
114 | class Pagination(ApiComponent):
115 | """ Utility class that allows batching requests to the server """
116 |
117 | def __init__(self, *, parent=None, data=None, constructor=None, next_link=None, limit=None):
118 | """
119 | Returns an iterator that returns data until it's exhausted. Then will request more data
120 | (same amount as the original request) to the server until this data is exhausted as well.
121 | Stops when no more data exists or limit is reached.
122 |
123 | :param parent: the parent class. Must implement attributes:
124 | con, api_version, main_resource
125 | :param data: the start data to be return
126 | :param constructor: the data constructor for the next batch. It can be a function.
127 | :param next_link: the link to request more data to
128 | :param limit: when to stop retrieving more data
129 | """
130 | if parent is None:
131 | raise ValueError('Parent must be another Api Component')
132 |
133 | super().__init__(protocol=parent.protocol, main_resource=parent.main_resource)
134 |
135 | self.parent = parent
136 | self.con = parent.con
137 | self.constructor = constructor
138 | self.next_link = next_link
139 | self.limit = limit
140 | self.data = data if data else []
141 |
142 | data_count = len(data)
143 | if limit and limit < data_count:
144 | self.data_count = limit
145 | self.total_count = limit
146 | else:
147 | self.data_count = data_count
148 | self.total_count = data_count
149 | self.state = 0
150 |
151 | def __str__(self):
152 | return self.__repr__()
153 |
154 | def __repr__(self):
155 | if callable(self.constructor):
156 | return 'Pagination Iterator'
157 | else:
158 | return "'{}' Iterator".format(self.constructor.__name__ if self.constructor else 'Unknown')
159 |
160 | def __bool__(self):
161 | return bool(self.data) or bool(self.next_link)
162 |
163 | def __iter__(self):
164 | return self
165 |
166 | def __next__(self):
167 | if self.state < self.data_count:
168 | value = self.data[self.state]
169 | self.state += 1
170 | return value
171 | else:
172 | if self.limit and self.total_count >= self.limit:
173 | raise StopIteration()
174 |
175 | if self.next_link is None:
176 | raise StopIteration()
177 |
178 | response = self.con.get(self.next_link)
179 | if not response:
180 | raise StopIteration()
181 |
182 | data = response.json()
183 |
184 | self.next_link = data.get(NEXT_LINK_KEYWORD, None) or None
185 | data = data.get('value', [])
186 | if self.constructor:
187 | # Everything received from the cloud must be passed with self._cloud_data_key
188 | if callable(self.constructor) and not isinstance(self.constructor, type):
189 | self.data = [self.constructor(value)(parent=self.parent, **{self._cloud_data_key: value}) for value in data]
190 | else:
191 | self.data = [self.constructor(parent=self.parent, **{self._cloud_data_key: value}) for value in data]
192 | else:
193 | self.data = data
194 |
195 | items_count = len(data)
196 | if self.limit:
197 | dif = self.limit - (self.total_count + items_count)
198 | if dif < 0:
199 | self.data = self.data[:dif]
200 | self.next_link = None # stop batching
201 | items_count = items_count + dif
202 | if items_count:
203 | self.data_count = items_count
204 | self.total_count += items_count
205 | self.state = 0
206 | value = self.data[self.state]
207 | self.state += 1
208 | return value
209 | else:
210 | raise StopIteration()
211 |
212 |
213 | class Query:
214 | """ Helper to conform OData filters """
215 | _mapping = {
216 | 'from': 'from/emailAddress/address',
217 | 'to': 'toRecipients/emailAddress/address',
218 | 'start': 'start/DateTime',
219 | 'end': 'end/DateTime'
220 | }
221 |
222 | def __init__(self, attribute=None, *, protocol):
223 | self.protocol = protocol() if isinstance(protocol, type) else protocol
224 | self._attribute = None
225 | self._chain = None
226 | self.new(attribute)
227 | self._negation = False
228 | self._filters = []
229 | self._order_by = OrderedDict()
230 | self._selects = set()
231 |
232 | def __str__(self):
233 | return 'Filter: {}\nOrder: {}\nSelect: {}'.format(self.get_filters(), self.get_order(), self.get_selects())
234 |
235 | def __repr__(self):
236 | return self.__str__()
237 |
238 | def select(self, *attributes):
239 | """
240 | Adds the attribute to the $select parameter
241 | :param attributes: the attributes tuple to select. If empty, the on_attribute previously set is added.
242 | """
243 | if attributes:
244 | for attribute in attributes:
245 | attribute = self.protocol.convert_case(attribute) if attribute and isinstance(attribute, str) else None
246 | if attribute:
247 | if '/' in attribute:
248 | # only parent attribute can be selected
249 | attribute = attribute.split('/')[0]
250 | self._selects.add(attribute)
251 | else:
252 | if self._attribute:
253 | self._selects.add(self._attribute)
254 |
255 | return self
256 |
257 | def as_params(self):
258 | """ Returns the filters and orders as query parameters"""
259 | params = {}
260 | if self.has_filters:
261 | params['$filter'] = self.get_filters()
262 | if self.has_order:
263 | params['$orderby'] = self.get_order()
264 | if self.has_selects:
265 | params['$select'] = self.get_selects()
266 | return params
267 |
268 | @property
269 | def has_filters(self):
270 | return bool(self._filters)
271 |
272 | @property
273 | def has_order(self):
274 | return bool(self._order_by)
275 |
276 | @property
277 | def has_selects(self):
278 | return bool(self._selects)
279 |
280 | def get_filters(self):
281 | """ Returns the result filters """
282 | if self._filters:
283 | filters_list = self._filters
284 | if isinstance(filters_list[-1], Enum):
285 | filters_list = filters_list[:-1]
286 | return ' '.join([fs.value if isinstance(fs, Enum) else fs[1] for fs in filters_list]).strip()
287 | else:
288 | return None
289 |
290 | def get_order(self):
291 | """ Returns the result order by clauses """
292 | # first get the filtered attributes in order as they must appear in the order_by first
293 | if not self.has_order:
294 | return None
295 | filter_order_clauses = OrderedDict([(filter_attr[0], None)
296 | for filter_attr in self._filters
297 | if isinstance(filter_attr, tuple)])
298 |
299 | # any order_by attribute that appears in the filters is ignored
300 | order_by_dict = self._order_by.copy()
301 | for filter_oc in filter_order_clauses.keys():
302 | direction = order_by_dict.pop(filter_oc, None)
303 | filter_order_clauses[filter_oc] = direction
304 |
305 | filter_order_clauses.update(order_by_dict) # append any remaining order_by clause
306 |
307 | if filter_order_clauses:
308 | return ','.join(['{} {}'.format(attribute, direction if direction else '').strip()
309 | for attribute, direction in filter_order_clauses.items()])
310 | else:
311 | return None
312 |
313 | def get_selects(self):
314 | """ Returns the result select clause """
315 | if self._selects:
316 | return ','.join(self._selects)
317 | else:
318 | return None
319 |
320 | def _get_mapping(self, attribute):
321 | if attribute:
322 | mapping = self._mapping.get(attribute)
323 | if mapping:
324 | attribute = '/'.join([self.protocol.convert_case(step) for step in mapping.split('/')])
325 | else:
326 | attribute = self.protocol.convert_case(attribute)
327 | return attribute
328 | return None
329 |
330 | def new(self, attribute, operation=ChainOperator.AND):
331 | if isinstance(operation, str):
332 | operation = ChainOperator(operation)
333 | self._chain = operation
334 | self._attribute = self._get_mapping(attribute) if attribute else None
335 | self._negation = False
336 | return self
337 |
338 | def clear_filters(self):
339 | self._filters = []
340 |
341 | def clear(self):
342 | self._filters = []
343 | self._order_by = OrderedDict()
344 | self._selects = set()
345 | self.new(None)
346 | return self
347 |
348 | def negate(self):
349 | self._negation = not self._negation
350 | return self
351 |
352 | def chain(self, operation=ChainOperator.AND):
353 | if isinstance(operation, str):
354 | operation = ChainOperator(operation)
355 | self._chain = operation
356 | return self
357 |
358 | def on_attribute(self, attribute):
359 | self._attribute = self._get_mapping(attribute)
360 | return self
361 |
362 | def _add_filter(self, filter_str):
363 | if self._attribute:
364 | if self._filters and not isinstance(self._filters[-1], ChainOperator):
365 | self._filters.append(self._chain)
366 | self._filters.append((self._attribute, filter_str))
367 | else:
368 | raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)')
369 |
370 | def _parse_filter_word(self, word):
371 | """ Converts the word parameter into the correct format """
372 | if isinstance(word, str):
373 | word = "'{}'".format(word)
374 | elif isinstance(word, dt.date):
375 | if isinstance(word, dt.datetime):
376 | if word.tzinfo is None:
377 | # if it's a naive datetime, localize the datetime.
378 | word = self.protocol.timezone.localize(word) # localize datetime into local tz
379 | if word.tzinfo != pytz.utc:
380 | word = word.astimezone(pytz.utc) # transform local datetime to utc
381 | word = '{}'.format(word.isoformat()) # convert datetime to isoformat
382 | elif isinstance(word, bool):
383 | word = str(word).lower()
384 | return word
385 |
386 | def logical_operator(self, operation, word):
387 | word = self._parse_filter_word(word)
388 | sentence = '{} {} {} {}'.format('not' if self._negation else '', self._attribute, operation, word).strip()
389 | self._add_filter(sentence)
390 | return self
391 |
392 | def equals(self, word):
393 | return self.logical_operator('eq', word)
394 |
395 | def unequal(self, word):
396 | return self.logical_operator('ne', word)
397 |
398 | def greater(self, word):
399 | return self.logical_operator('gt', word)
400 |
401 | def greater_equal(self, word):
402 | return self.logical_operator('ge', word)
403 |
404 | def less(self, word):
405 | return self.logical_operator('lt', word)
406 |
407 | def less_equal(self, word):
408 | return self.logical_operator('le', word)
409 |
410 | def function(self, function_name, word):
411 | word = self._parse_filter_word(word)
412 |
413 | self._add_filter(
414 | "{} {}({}, {})".format('not' if self._negation else '', function_name, self._attribute, word).strip())
415 | return self
416 |
417 | def contains(self, word):
418 | return self.function('contains', word)
419 |
420 | def startswith(self, word):
421 | return self.function('startswith', word)
422 |
423 | def endswith(self, word):
424 | return self.function('endswith', word)
425 |
426 | def order_by(self, attribute=None, *, ascending=True):
427 | """ applies a order_by clause"""
428 | attribute = self._get_mapping(attribute) or self._attribute
429 | if attribute:
430 | self._order_by[attribute] = None if ascending else 'desc'
431 | else:
432 | raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)')
433 | return self
434 |
--------------------------------------------------------------------------------
/pyo365/utils/attachment.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import base64
3 | from pathlib import Path
4 |
5 | from pyo365.utils.utils import ApiComponent
6 |
7 |
8 | log = logging.getLogger(__name__)
9 |
10 |
11 | class AttachableMixin:
12 | """
13 | Defines the functionality for an object to be attachable.
14 | Any object that inherits from this class will be attachable (if the underlying api allows that)
15 | """
16 |
17 | def __init__(self, attachment_name_property=None, attachment_type=None):
18 | self.__attachment_name = None
19 | self.__attachment_name_property = attachment_name_property
20 | self.__attachment_type = self._gk(attachment_type)
21 |
22 | @property
23 | def attachment_name(self):
24 | if self.__attachment_name is not None:
25 | return self.__attachment_name
26 | if self.__attachment_name_property:
27 | return getattr(self, self.__attachment_name_property, '')
28 | else:
29 | # property order resolution:
30 | # 1) try property 'subject'
31 | # 2) try property 'name'
32 | try:
33 | attachment_name = getattr(self, 'subject')
34 | except AttributeError:
35 | attachment_name = getattr(self, 'name', '')
36 | return attachment_name
37 |
38 | @attachment_name.setter
39 | def attachment_name(self, value):
40 | self.__attachment_name = value
41 |
42 | @property
43 | def attachment_type(self):
44 | return self.__attachment_type
45 |
46 | def to_api_data(self):
47 | raise NotImplementedError()
48 |
49 |
50 | class BaseAttachment(ApiComponent):
51 | """ BaseAttachment class is the base object for dealing with attachments """
52 |
53 | _endpoints = {'attach': '/messages/{id}/attachments'}
54 |
55 | def __init__(self, attachment=None, *, parent=None, **kwargs):
56 | """
57 | Creates a new attachment class, optionally from existing cloud data.
58 |
59 | :param attachment: attachment data (dict = cloud data, other = user data)
60 | :param parent: the parent Attachments
61 | :param kwargs: extra options:
62 | - 'protocol' when using attachment standalone
63 | - 'main_resource' when using attachment standalone and is not the default_resource of protocol
64 | """
65 | kwargs.setdefault('protocol', getattr(parent, 'protocol', None))
66 | kwargs.setdefault('main_resource', getattr(parent, 'main_resource', None))
67 |
68 | super().__init__(**kwargs)
69 | self.name = None
70 | self.attachment_type = 'file'
71 | self.attachment_id = None
72 | self.attachment = None
73 | self.content = None
74 | self.on_disk = False
75 | self.on_cloud = kwargs.get('on_cloud', False)
76 |
77 | if attachment:
78 | if isinstance(attachment, dict):
79 | if self._cloud_data_key in attachment:
80 | # data from the cloud
81 | attachment = attachment.get(self._cloud_data_key)
82 | self.attachment_id = attachment.get(self._cc('id'), None)
83 | self.name = attachment.get(self._cc('name'), None)
84 | self.content = attachment.get(self._cc('contentBytes'), None)
85 | self.attachment_type = 'item' if 'item' in attachment.get('@odata.type', '').lower() else 'file'
86 | self.on_disk = False
87 | else:
88 | file_path = attachment.get('path', attachment.get('name'))
89 | if file_path is None:
90 | raise ValueError('Must provide a valid "path" or "name" for the attachment')
91 | self.content = attachment.get('content')
92 | self.on_disk = attachment.get('on_disk')
93 | self.attachment_id = attachment.get('attachment_id')
94 | self.attachment = Path(file_path) if self.on_disk else None
95 | self.name = self.attachment.name if self.on_disk else attachment.get('name')
96 | elif isinstance(attachment, str):
97 | self.attachment = Path(attachment)
98 | self.name = self.attachment.name
99 | elif isinstance(attachment, Path):
100 | self.attachment = attachment
101 | self.name = self.attachment.name
102 | elif isinstance(attachment, (tuple, list)):
103 | file_path, custom_name = attachment
104 | self.attachment = Path(file_path)
105 | self.name = custom_name
106 | elif isinstance(attachment, AttachableMixin):
107 | # Object that can be attached (Message for example)
108 | self.attachment_type = 'item'
109 | self.attachment = attachment
110 | self.name = attachment.attachment_name
111 | self.content = attachment.to_api_data()
112 | self.content['@odata.type'] = attachment.attachment_type
113 |
114 | if self.content is None and self.attachment and self.attachment.exists():
115 | with self.attachment.open('rb') as file:
116 | self.content = base64.b64encode(file.read()).decode('utf-8')
117 | self.on_disk = True
118 |
119 | def to_api_data(self):
120 | data = {'@odata.type': self._gk('{}_attachment_type'.format(self.attachment_type)), self._cc('name'): self.name}
121 |
122 | if self.attachment_type == 'file':
123 | data[self._cc('contentBytes')] = self.content
124 | else:
125 | data[self._cc('item')] = self.content
126 |
127 | return data
128 |
129 | def save(self, location=None, custom_name=None):
130 | """ Save the attachment locally to disk.
131 | :param location: path string to where the file is to be saved.
132 | :param custom_name: a custom name to be saved as
133 | """
134 | if not self.content:
135 | return False
136 |
137 | location = Path(location or '')
138 | if not location.exists():
139 | log.debug('the location provided does not exist')
140 | return False
141 |
142 | name = custom_name or self.name
143 | name = name.replace('/', '-').replace('\\', '')
144 | try:
145 | path = location / name
146 | with path.open('wb') as file:
147 | file.write(base64.b64decode(self.content))
148 | self.attachment = path
149 | self.on_disk = True
150 | log.debug('file saved locally.')
151 | except Exception as e:
152 | log.error('file failed to be saved: %s', str(e))
153 | return False
154 | return True
155 |
156 | def attach(self, api_object, on_cloud=False):
157 | """
158 | Attach this attachment to an existing api_object
159 | This BaseAttachment object must be an orphan BaseAttachment created for the
160 | sole purpose of attach it to something and therefore run this method.
161 | """
162 |
163 | if self.on_cloud:
164 | # item is already saved on the cloud.
165 | return True
166 |
167 | # api_object must exist and if implements attachments then we can attach to it.
168 | if api_object and getattr(api_object, 'attachments', None):
169 | if on_cloud:
170 | if not api_object.object_id:
171 | raise RuntimeError('A valid object id is needed in order to attach a file')
172 | # api_object builds its own url using its resource and main configuration
173 | url = api_object.build_url(self._endpoints.get('attach').format(id=api_object.object_id))
174 |
175 | response = api_object.con.post(url, data=self.to_api_data())
176 |
177 | return bool(response)
178 | else:
179 | if self.attachment_type == 'file':
180 | api_object.attachments.add([{
181 | 'attachment_id': self.attachment_id, # TODO: copy attachment id? or set to None?
182 | 'path': str(self.attachment) if self.attachment else None,
183 | 'name': self.name,
184 | 'content': self.content,
185 | 'on_disk': self.on_disk
186 | }])
187 | else:
188 | raise RuntimeError('Only file attachments can be attached')
189 |
190 | def __str__(self):
191 | return self.__repr__()
192 |
193 | def __repr__(self):
194 | return 'Attachment: {}'.format(self.name)
195 |
196 |
197 | class BaseAttachments(ApiComponent):
198 | """ A Collection of BaseAttachments """
199 |
200 | _endpoints = {
201 | 'attachments': '/messages/{id}/attachments',
202 | 'attachment': '/messages/{id}/attachments/{ida}'
203 | }
204 | _attachment_constructor = BaseAttachment
205 |
206 | def __init__(self, parent, attachments=None):
207 | """ Attachments must be a list of path strings or dictionary elements """
208 | super().__init__(protocol=parent.protocol, main_resource=parent.main_resource)
209 | self._parent = parent
210 | self.__attachments = []
211 | self.__removed_attachments = [] # holds on_cloud attachments removed from the parent object
212 | self.untrack = True
213 | if attachments:
214 | self.add(attachments)
215 | self.untrack = False
216 |
217 | def __iter__(self):
218 | return iter(self.__attachments)
219 |
220 | def __getitem__(self, key):
221 | return self.__attachments[key]
222 |
223 | def __contains__(self, item):
224 | return item in {attachment.name for attachment in self.__attachments}
225 |
226 | def __len__(self):
227 | return len(self.__attachments)
228 |
229 | def __str__(self):
230 | attachments = len(self.__attachments)
231 | parent_has_attachments = getattr(self._parent, 'has_attachments', False)
232 | if parent_has_attachments and attachments == 0:
233 | return 'Number of Attachments: unknown'
234 | else:
235 | return 'Number of Attachments: {}'.format(attachments)
236 |
237 | def __repr__(self):
238 | return self.__str__()
239 |
240 | def __bool__(self):
241 | return bool(len(self.__attachments))
242 |
243 | def to_api_data(self):
244 | return [attachment.to_api_data() for attachment in self.__attachments if attachment.on_cloud is False]
245 |
246 | def clear(self):
247 | for attachment in self.__attachments:
248 | if attachment.on_cloud:
249 | self.__removed_attachments.append(attachment)
250 | self.__attachments = []
251 | self._update_parent_attachments()
252 | self._track_changes()
253 |
254 | def _track_changes(self):
255 | """ Update the track_changes on the parent to reflect a needed update on this field """
256 | if getattr(self._parent, '_track_changes', None) is not None and self.untrack is False:
257 | self._parent._track_changes.add('attachments')
258 |
259 | def _update_parent_attachments(self):
260 | """ Tries to update the parent property 'has_attachments' """
261 | try:
262 | self._parent.has_attachments = bool(len(self.__attachments))
263 | except AttributeError:
264 | pass
265 |
266 | def add(self, attachments):
267 | """ Attachments must be a Path or string or a list of Paths, path strings or dictionary elements """
268 |
269 | if attachments:
270 | if isinstance(attachments, (str, Path)):
271 | attachments = [attachments]
272 | if isinstance(attachments, (list, tuple, set)):
273 | # User provided attachments
274 | attachments_temp = [self._attachment_constructor(attachment, parent=self)
275 | for attachment in attachments]
276 | elif isinstance(attachments, dict) and self._cloud_data_key in attachments:
277 | # Cloud downloaded attachments. We pass on_cloud=True to track if this attachment is saved on the server
278 | attachments_temp = [self._attachment_constructor({self._cloud_data_key: attachment}, parent=self, on_cloud=True)
279 | for attachment in attachments.get(self._cloud_data_key, [])]
280 | else:
281 | raise ValueError('Attachments must be a str or Path or a list, tuple or set of the former')
282 |
283 | self.__attachments.extend(attachments_temp)
284 | self._update_parent_attachments()
285 | self._track_changes()
286 |
287 | def remove(self, attachments):
288 | """ Remove attachments from this collection of attachments """
289 | if isinstance(attachments, (list, tuple)):
290 | attachments = {attachment.name if isinstance(attachment, BaseAttachment) else attachment for attachment in attachments}
291 | elif isinstance(attachments, str):
292 | attachments = {attachments}
293 | elif isinstance(attachments, BaseAttachment):
294 | attachments = {attachments.name}
295 | else:
296 | raise ValueError('Incorrect parameter type for attachments')
297 |
298 | new_attachments = []
299 | for attachment in self.__attachments:
300 | if attachment.name not in attachments:
301 | new_attachments.append(attachment)
302 | else:
303 | if attachment.on_cloud:
304 | self.__removed_attachments.append(attachment) # add to removed_attachments so later we can delete them
305 | self.__attachments = new_attachments
306 | self._update_parent_attachments()
307 | self._track_changes()
308 |
309 | def download_attachments(self):
310 | """ Downloads this message attachments into memory. Need a call to 'attachment.save' to save them on disk. """
311 |
312 | if not self._parent.has_attachments:
313 | log.debug('Parent {} has no attachments, skipping out early.'.format(self._parent.__class__.__name__))
314 | return False
315 |
316 | if not self._parent.object_id:
317 | raise RuntimeError('Attempted to download attachments of an unsaved {}'.format(self._parent.__class__.__name__))
318 |
319 | url = self.build_url(self._endpoints.get('attachments').format(id=self._parent.object_id))
320 |
321 | response = self._parent.con.get(url)
322 | if not response:
323 | return False
324 |
325 | attachments = response.json().get('value', [])
326 |
327 | # Everything received from the cloud must be passed with self._cloud_data_key
328 | self.untrack = True
329 | self.add({self._cloud_data_key: attachments})
330 | self.untrack = False
331 |
332 | # TODO: when it's a item attachment the attachment itself is not downloaded. We must download it...
333 | # TODO: idea: retrieve the attachments ids' only with select and then download one by one.
334 | return True
335 |
336 | def _update_attachments_to_cloud(self):
337 | """
338 | Push new, unsaved attachments to the cloud and remove removed attachments
339 | This method should not be called for non draft messages.
340 | """
341 |
342 | url = self.build_url(self._endpoints.get('attachments').format(id=self._parent.object_id))
343 |
344 | # ! potencially several api requests can be made by this method.
345 |
346 | for attachment in self.__attachments:
347 | if attachment.on_cloud is False:
348 | # upload attachment:
349 | response = self._parent.con.post(url, data=attachment.to_api_data())
350 | if not response:
351 | return False
352 |
353 | data = response.json()
354 |
355 | # update attachment data
356 | attachment.attachment_id = data.get('id')
357 | attachment.content = data.get(self._cc('contentBytes'), None)
358 | attachment.on_cloud = True
359 |
360 | for attachment in self.__removed_attachments:
361 | if attachment.on_cloud and attachment.attachment_id is not None:
362 | # delete attachment
363 | url = self.build_url(self._endpoints.get('attachment').format(id=self._parent.object_id, ida=attachment.attachment_id))
364 |
365 | response = self._parent.con.delete(url)
366 | if not response:
367 | return False
368 |
369 | self.__removed_attachments = [] # reset the removed attachments
370 |
371 | log.debug('Successfully updated attachments on {}'.format(self._parent.object_id))
372 |
373 | return True
374 |
--------------------------------------------------------------------------------
/pyo365/mailbox.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import datetime as dt
3 |
4 | from pyo365.message import Message
5 | from pyo365.utils import Pagination, NEXT_LINK_KEYWORD, OutlookWellKnowFolderNames, ApiComponent
6 |
7 | log = logging.getLogger(__name__)
8 |
9 |
10 | class Folder(ApiComponent):
11 | """ A Mail Folder representation """
12 |
13 | _endpoints = {
14 | 'root_folders': '/mailFolders',
15 | 'child_folders': '/mailFolders/{id}/childFolders',
16 | 'get_folder': '/mailFolders/{id}',
17 | 'root_messages': '/messages',
18 | 'folder_messages': '/mailFolders/{id}/messages',
19 | 'copy_folder': '/mailFolders/{id}/copy',
20 | 'move_folder': '/mailFolders/{id}/move',
21 | 'delete_message': '/messages/{id}',
22 | }
23 | message_constructor = Message
24 |
25 | def __init__(self, *, parent=None, con=None, **kwargs):
26 | assert parent or con, 'Need a parent or a connection'
27 | self.con = parent.con if parent else con
28 | self.parent = parent if isinstance(parent, Folder) else None
29 |
30 | self.root = kwargs.pop('root', False) # This folder has no parents if root = True.
31 |
32 | # Choose the main_resource passed in kwargs over the parent main_resource
33 | main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None
34 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource)
35 |
36 | cloud_data = kwargs.get(self._cloud_data_key, {})
37 |
38 | self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name', '')) # Fallback to manual folder
39 | if self.root is False:
40 | self.folder_id = cloud_data.get(self._cc('id'), kwargs.get('folder_id', None)) # Fallback to manual folder
41 | self.parent_id = cloud_data.get(self._cc('parentFolderId'), None)
42 | self.child_folders_count = cloud_data.get(self._cc('childFolderCount'), 0)
43 | self.unread_items_count = cloud_data.get(self._cc('unreadItemCount'), 0)
44 | self.total_items_count = cloud_data.get(self._cc('totalItemCount'), 0)
45 | self.updated_at = dt.datetime.now()
46 |
47 | def __str__(self):
48 | return self.__repr__()
49 |
50 | def __repr__(self):
51 | return '{} from resource: {}'.format(self.name, self.main_resource)
52 |
53 | def get_folders(self, limit=None, *, query=None, order_by=None, batch=None):
54 | """
55 | Returns a list of child folders
56 |
57 | :param limit: limits the result set. Over 999 uses batch.
58 | :param query: applies a filter to the request such as "displayName eq 'HelloFolder'"
59 | :param order_by: orders the result set based on this condition
60 | :param batch: Returns a custom iterator that retrieves items in batches allowing to retrieve more items than the limit.
61 | """
62 |
63 | if self.root:
64 | url = self.build_url(self._endpoints.get('root_folders'))
65 | else:
66 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
67 |
68 | if limit is None or limit > self.protocol.max_top_value:
69 | batch = self.protocol.max_top_value
70 |
71 | params = {'$top': batch if batch else limit}
72 |
73 | if order_by:
74 | params['$orderby'] = order_by
75 |
76 | if query:
77 | if isinstance(query, str):
78 | params['$filter'] = query
79 | else:
80 | params.update(query.as_params())
81 |
82 | response = self.con.get(url, params=params)
83 | if not response:
84 | return []
85 |
86 | data = response.json()
87 |
88 | # Everything received from the cloud must be passed with self._cloud_data_key
89 | self_class = getattr(self, 'folder_constructor', type(self))
90 | folders = [self_class(parent=self, **{self._cloud_data_key: folder}) for folder in data.get('value', [])]
91 | next_link = data.get(NEXT_LINK_KEYWORD, None)
92 | if batch and next_link:
93 | return Pagination(parent=self, data=folders, constructor=self_class,
94 | next_link=next_link, limit=limit)
95 | else:
96 | return folders
97 |
98 | def get_message(self, query=None, *, download_attachments=False):
99 | """ A shorcut to get_messages with limit=1 """
100 | messages = self.get_messages(limit=1, query=query, download_attachments=download_attachments)
101 |
102 | return messages[0] if messages else None
103 |
104 | def get_messages(self, limit=25, *, query=None, order_by=None, batch=None, download_attachments=False):
105 | """
106 | Downloads messages from this folder
107 |
108 | :param limit: limits the result set. Over 999 uses batch.
109 | :param query: applies a filter to the request such as 'displayName:HelloFolder'
110 | :param order_by: orders the result set based on this condition
111 | :param batch: Returns a custom iterator that retrieves items in batches allowing
112 | to retrieve more items than the limit. Download_attachments is ignored.
113 | :param download_attachments: downloads message attachments
114 | """
115 |
116 | if self.root:
117 | url = self.build_url(self._endpoints.get('root_messages'))
118 | else:
119 | url = self.build_url(self._endpoints.get('folder_messages').format(id=self.folder_id))
120 |
121 | if limit is None or limit > self.protocol.max_top_value:
122 | batch = self.protocol.max_top_value
123 |
124 | if batch:
125 | download_attachments = False
126 |
127 | params = {'$top': batch if batch else limit}
128 |
129 | if order_by:
130 | params['$orderby'] = order_by
131 |
132 | if query:
133 | if isinstance(query, str):
134 | params['$filter'] = query
135 | else:
136 | params.update(query.as_params())
137 |
138 | response = self.con.get(url, params=params)
139 | if not response:
140 | return []
141 |
142 | data = response.json()
143 |
144 | # Everything received from the cloud must be passed with self._cloud_data_key
145 | messages = [self.message_constructor(parent=self, download_attachments=download_attachments,
146 | **{self._cloud_data_key: message})
147 | for message in data.get('value', [])]
148 |
149 | next_link = data.get(NEXT_LINK_KEYWORD, None)
150 | if batch and next_link:
151 | return Pagination(parent=self, data=messages, constructor=self.message_constructor,
152 | next_link=next_link, limit=limit)
153 | else:
154 | return messages
155 |
156 | def create_child_folder(self, folder_name):
157 | """
158 | Creates a new child folder
159 | :return the new Folder Object or None
160 | """
161 | if not folder_name:
162 | return None
163 |
164 | if self.root:
165 | url = self.build_url(self._endpoints.get('root_folders'))
166 | else:
167 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
168 |
169 | response = self.con.post(url, data={self._cc('displayName'): folder_name})
170 | if not response:
171 | return None
172 |
173 | folder = response.json()
174 |
175 | self_class = getattr(self, 'folder_constructor', type(self))
176 | # Everything received from the cloud must be passed with self._cloud_data_key
177 | return self_class(parent=self, **{self._cloud_data_key: folder})
178 |
179 | def get_folder(self, *, folder_id=None, folder_name=None):
180 | """
181 | Returns a folder by it's id or name
182 | :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not)
183 | :param folder_name: the folder name to be retrieved. Must be a child of this folder.
184 | """
185 | if folder_id and folder_name:
186 | raise RuntimeError('Provide only one of the options')
187 |
188 | if not folder_id and not folder_name:
189 | raise RuntimeError('Provide one of the options')
190 |
191 | if folder_id:
192 | # get folder by it's id, independent of the parent of this folder_id
193 | url = self.build_url(self._endpoints.get('get_folder').format(id=folder_id))
194 | params = None
195 | else:
196 | # get folder by name. Only looks up in child folders.
197 | if self.root:
198 | url = self.build_url(self._endpoints.get('root_folders'))
199 | else:
200 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
201 | params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), folder_name), '$top': 1}
202 |
203 | response = self.con.get(url, params=params)
204 | if not response:
205 | return None
206 |
207 | if folder_id:
208 | folder = response.json()
209 | else:
210 | folder = response.json().get('value')
211 | folder = folder[0] if folder else None
212 | if folder is None:
213 | return None
214 |
215 | self_class = getattr(self, 'folder_constructor', type(self))
216 | # Everything received from the cloud must be passed with self._cloud_data_key
217 | # we don't pass parent, as this folder may not be a child of self.
218 | return self_class(con=self.con, protocol=self.protocol, main_resource=self.main_resource, **{self._cloud_data_key: folder})
219 |
220 | def refresh_folder(self, update_parent_if_changed=False):
221 | """
222 | Re-donwload folder data
223 | Inbox Folder will be unable to download its own data (no folder_id)
224 | :param update_parent_if_changed: updates self.parent with the new parent Folder if changed
225 | """
226 | folder_id = getattr(self, 'folder_id', None)
227 | if self.root or folder_id is None:
228 | return False
229 |
230 | folder = self.get_folder(folder_id=folder_id)
231 | if folder is None:
232 | return False
233 |
234 | self.name = folder.name
235 | if folder.parent_id and self.parent_id:
236 | if folder.parent_id != self.parent_id:
237 | self.parent_id = folder.parent_id
238 | self.parent = self.get_parent_folder() if update_parent_if_changed else None
239 | self.child_folders_count = folder.child_folders_count
240 | self.unread_items_count = folder.unread_items_count
241 | self.total_items_count = folder.total_items_count
242 | self.updated_at = folder.updated_at
243 |
244 | return True
245 |
246 | def get_parent_folder(self):
247 | """ Returns the parent folder from attribute self.parent or getting it from the cloud"""
248 | if self.root:
249 | return None
250 | if self.parent:
251 | return self.parent
252 |
253 | if self.parent_id:
254 | self.parent = self.get_folder(folder_id=self.parent_id)
255 | return self.parent
256 |
257 | def update_folder_name(self, name, update_folder_data=True):
258 | """ Change this folder name """
259 | if self.root:
260 | return False
261 | if not name:
262 | return False
263 |
264 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
265 |
266 | response = self.con.patch(url, data={self._cc('displayName'): name})
267 | if not response:
268 | return False
269 |
270 | self.name = name
271 | if not update_folder_data:
272 | return True
273 |
274 | folder = response.json()
275 |
276 | self.name = folder.get(self._cc('displayName'), '')
277 | self.parent_id = folder.get(self._cc('parentFolderId'), None)
278 | self.child_folders_count = folder.get(self._cc('childFolderCount'), 0)
279 | self.unread_items_count = folder.get(self._cc('unreadItemCount'), 0)
280 | self.total_items_count = folder.get(self._cc('totalItemCount'), 0)
281 | self.updated_at = dt.datetime.now()
282 |
283 | return True
284 |
285 | def delete(self):
286 | """ Deletes this folder """
287 |
288 | if self.root or not self.folder_id:
289 | return False
290 |
291 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
292 |
293 | response = self.con.delete(url)
294 | if not response:
295 | return False
296 |
297 | self.folder_id = None
298 | return True
299 |
300 | def copy_folder(self, to_folder):
301 | """
302 | Copy this folder and it's contents to into another folder
303 | :param to_folder: the destination Folder instance or a string folder_id
304 | :return The copied folder object
305 | """
306 | to_folder_id = to_folder.folder_id if isinstance(to_folder, Folder) else to_folder
307 |
308 | if self.root or not self.folder_id or not to_folder_id:
309 | return None
310 |
311 | url = self.build_url(self._endpoints.get('copy_folder').format(id=self.folder_id))
312 |
313 | response = self.con.post(url, data={self._cc('destinationId'): to_folder_id})
314 | if not response:
315 | return None
316 |
317 | folder = response.json()
318 |
319 | self_class = getattr(self, 'folder_constructor', type(self))
320 | # Everything received from the cloud must be passed with self._cloud_data_key
321 | return self_class(con=self.con, main_resource=self.main_resource, **{self._cloud_data_key: folder})
322 |
323 | def move_folder(self, to_folder, *, update_parent_if_changed=False):
324 | """
325 | Move this folder to another folder
326 | :param to_folder: the destination Folder instance or a string folder_id
327 | :param update_parent_if_changed: updates self.parent with the new parent Folder if changed
328 | """
329 | to_folder_id = to_folder.folder_id if isinstance(to_folder, Folder) else to_folder
330 |
331 | if self.root or not self.folder_id or not to_folder_id:
332 | return False
333 |
334 | url = self.build_url(self._endpoints.get('move_folder').format(id=self.folder_id))
335 |
336 | response = self.con.post(url, data={self._cc('destinationId'): to_folder_id})
337 | if not response:
338 | return False
339 |
340 | folder = response.json()
341 |
342 | parent_id = folder.get(self._cc('parentFolderId'), None)
343 |
344 | if parent_id and self.parent_id:
345 | if parent_id != self.parent_id:
346 | self.parent_id = parent_id
347 | self.parent = self.get_parent_folder() if update_parent_if_changed else None
348 |
349 | return True
350 |
351 | def new_message(self):
352 | """ Creates a new draft message in this folder """
353 |
354 | draft_message = self.message_constructor(parent=self, is_draft=True)
355 |
356 | if self.root:
357 | draft_message.folder_id = OutlookWellKnowFolderNames.DRAFTS.value
358 | else:
359 | draft_message.folder_id = self.folder_id
360 |
361 | return draft_message
362 |
363 | def delete_message(self, message):
364 | """ Deletes a stored message by it's id """
365 |
366 | message_id = message.object_id if isinstance(message, Message) else message
367 |
368 | if message_id is None:
369 | raise RuntimeError('Provide a valid Message or a message id')
370 |
371 | url = self.build_url(self._endpoints.get('delete_message').format(id=message_id))
372 |
373 | response = self.con.delete(url)
374 |
375 | return bool(response)
376 |
377 |
378 | class MailBox(Folder):
379 |
380 | folder_constructor = Folder
381 |
382 | def __init__(self, *, parent=None, con=None, **kwargs):
383 | super().__init__(parent=parent, con=con, root=True, **kwargs)
384 |
385 | def inbox_folder(self):
386 | """ Returns this mailbox Inbox """
387 | return self.folder_constructor(parent=self, name='Inbox', folder_id=OutlookWellKnowFolderNames.INBOX.value)
388 |
389 | def junk_folder(self):
390 | """ Returns this mailbox Junk Folder """
391 | return self.folder_constructor(parent=self, name='Junk', folder_id=OutlookWellKnowFolderNames.JUNK.value)
392 |
393 | def deleted_folder(self):
394 | """ Returns this mailbox DeletedItems Folder """
395 | return self.folder_constructor(parent=self, name='DeletedItems', folder_id=OutlookWellKnowFolderNames.DELETED.value)
396 |
397 | def drafts_folder(self):
398 | """ Returns this mailbox Drafs Folder """
399 | return self.folder_constructor(parent=self, name='Drafs', folder_id=OutlookWellKnowFolderNames.DRAFTS.value)
400 |
401 | def sent_folder(self):
402 | """ Returns this mailbox SentItems Folder """
403 | return self.folder_constructor(parent=self, name='SentItems', folder_id=OutlookWellKnowFolderNames.SENT.value)
404 |
405 | def outbox_folder(self):
406 | """ Returns this mailbox Outbox Folder """
407 | return self.folder_constructor(parent=self, name='Outbox', folder_id=OutlookWellKnowFolderNames.OUTBOX.value)
408 |
--------------------------------------------------------------------------------
/pyo365/address_book.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dateutil.parser import parse
3 | from enum import Enum
4 |
5 | from pyo365.message import HandleRecipientsMixin, Recipients, Message
6 | from pyo365.utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent, AttachableMixin
7 |
8 | GAL_MAIN_RESOURCE = 'users'
9 |
10 | log = logging.getLogger(__name__)
11 |
12 |
13 | class RecipientType(Enum):
14 | TO = 'to'
15 | CC = 'cc'
16 | BCC = 'bcc'
17 |
18 |
19 | class Contact(ApiComponent, AttachableMixin, HandleRecipientsMixin):
20 | """ Contact manages lists of events on an associated contact on office365. """
21 |
22 | _mapping = {'display_name': 'displayName', 'name': 'givenName', 'surname': 'surname', 'title': 'title', 'job_title': 'jobTitle',
23 | 'company_name': 'companyName', 'department': 'department', 'office_location': 'officeLocation',
24 | 'business_phones': 'businessPhones', 'mobile_phone': 'mobilePhone', 'home_phones': 'homePhones',
25 | 'emails': 'emailAddresses', 'business_addresses': 'businessAddress', 'home_addresses': 'homesAddress',
26 | 'other_addresses': 'otherAddress', 'categories': 'categories'}
27 |
28 | _endpoints = {
29 | 'root_contact': '/contacts/{id}',
30 | 'child_contact': '/contactFolders/{id}/contacts'
31 | }
32 | message_constructor = Message
33 |
34 | def __init__(self, *, parent=None, con=None, **kwargs):
35 |
36 | assert parent or con, 'Need a parent or a connection'
37 | self.con = parent.con if parent else con
38 |
39 | # Choose the main_resource passed in kwargs over the parent main_resource
40 | main_resource = kwargs.pop('main_resource', None) or (getattr(parent, 'main_resource', None) if parent else None)
41 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource)
42 |
43 | cloud_data = kwargs.get(self._cloud_data_key, {})
44 | cc = self._cc # alias to shorten the code
45 |
46 | self.object_id = cloud_data.get(cc('id'), None)
47 | self.created = cloud_data.get(cc('createdDateTime'), None)
48 | self.modified = cloud_data.get(cc('lastModifiedDateTime'), None)
49 |
50 | local_tz = self.protocol.timezone
51 | self.created = parse(self.created).astimezone(local_tz) if self.created else None
52 | self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None
53 |
54 | self.display_name = cloud_data.get(cc('displayName'), '')
55 | self.name = cloud_data.get(cc('givenName'), '')
56 | self.surname = cloud_data.get(cc('surname'), '')
57 |
58 | self.title = cloud_data.get(cc('title'), '')
59 | self.job_title = cloud_data.get(cc('jobTitle'), '')
60 | self.company_name = cloud_data.get(cc('companyName'), '')
61 | self.department = cloud_data.get(cc('department'), '')
62 | self.office_location = cloud_data.get(cc('officeLocation'), '')
63 | self.business_phones = cloud_data.get(cc('businessPhones'), []) or []
64 | self.mobile_phone = cloud_data.get(cc('mobilePhone'), '')
65 | self.home_phones = cloud_data.get(cc('homePhones'), []) or []
66 | self.__emails = self._recipients_from_cloud(cloud_data.get(cc('emailAddresses'), []))
67 | email = cloud_data.get(cc('email'))
68 | if email and email not in self.__emails:
69 | # a Contact from OneDrive?
70 | self.__emails.add(email)
71 | self.business_addresses = cloud_data.get(cc('businessAddress'), {})
72 | self.home_addresses = cloud_data.get(cc('homesAddress'), {})
73 | self.other_addresses = cloud_data.get(cc('otherAddress'), {})
74 | self.preferred_language = cloud_data.get(cc('preferredLanguage'), None)
75 |
76 | self.categories = cloud_data.get(cc('categories'), [])
77 | self.folder_id = cloud_data.get(cc('parentFolderId'), None)
78 |
79 | # when using Users endpoints (GAL) : missing keys: ['mail', 'userPrincipalName']
80 | mail = cloud_data.get(cc('mail'), None)
81 | user_principal_name = cloud_data.get(cc('userPrincipalName'), None)
82 | if mail and mail not in self.emails:
83 | self.emails.add(mail)
84 | if user_principal_name and user_principal_name not in self.emails:
85 | self.emails.add(user_principal_name)
86 |
87 | @property
88 | def emails(self):
89 | return self.__emails
90 |
91 | @property
92 | def main_email(self):
93 | """ Returns the first email on the emails"""
94 | if not self.emails:
95 | return None
96 | return self.emails[0].address
97 |
98 | @property
99 | def full_name(self):
100 | """ Returns name + surname """
101 | return '{} {}'.format(self.name, self.surname).strip()
102 |
103 | def __str__(self):
104 | return self.__repr__()
105 |
106 | def __repr__(self):
107 | return self.display_name or self.full_name or 'Unknwon Name'
108 |
109 | def to_api_data(self):
110 | """ Returns a dictionary in cloud format """
111 |
112 | data = {
113 | 'displayName': self.display_name,
114 | 'givenName': self.name,
115 | 'surname': self.surname,
116 | 'title': self.title,
117 | 'jobTitle': self.job_title,
118 | 'companyName': self.company_name,
119 | 'department': self.department,
120 | 'officeLocation': self.office_location,
121 | 'businessPhones': self.business_phones,
122 | 'mobilePhone': self.mobile_phone,
123 | 'homePhones': self.home_phones,
124 | 'emailAddresses': [self._recipient_to_cloud(recipient) for recipient in self.emails],
125 | 'businessAddress': self.business_addresses,
126 | 'homesAddress': self.home_addresses,
127 | 'otherAddress': self.other_addresses,
128 | 'categories': self.categories}
129 | return data
130 |
131 | def delete(self):
132 | """ Deletes this contact """
133 |
134 | if not self.object_id:
135 | raise RuntimeError('Attemping to delete an usaved Contact')
136 |
137 | url = self.build_url(self._endpoints.get('contact').format(id=self.object_id))
138 |
139 | response = self.con.delete(url)
140 |
141 | return bool(response)
142 |
143 | def update(self, fields):
144 | """ Updates a contact
145 | :param fields: a dict of fields to update (field: value).
146 | """
147 |
148 | if not self.object_id:
149 | raise RuntimeError('Attemping to update an usaved Contact')
150 |
151 | if fields is None or not isinstance(fields, (list, tuple)):
152 | raise ValueError('Must provide fields to update as a list or tuple')
153 |
154 | data = {}
155 | for field in fields:
156 | mapping = self._mapping.get(field)
157 | if mapping is None:
158 | raise ValueError('{} is not a valid updatable field from Contact'.format(field))
159 | update_value = getattr(self, field)
160 | if isinstance(update_value, Recipients):
161 | data[self._cc(mapping)] = [self._recipient_to_cloud(recipient) for recipient in update_value]
162 | else:
163 | data[self._cc(mapping)] = update_value
164 |
165 | url = self.build_url(self._endpoints.get('contact'.format(id=self.object_id)))
166 |
167 | response = self.con.patch(url, data=data)
168 |
169 | return bool(response)
170 |
171 | def save(self):
172 | """ Saves this Contact to the cloud """
173 | if self.object_id:
174 | raise RuntimeError("Can't save an existing Contact. Use Update instead. ")
175 |
176 | if self.folder_id:
177 | url = self.build_url(self._endpoints.get('child_contact').format(self.folder_id))
178 | else:
179 | url = self.build_url(self._endpoints.get('root_contact'))
180 |
181 | response = self.con.post(url, data=self.to_api_data())
182 | if not response:
183 | return False
184 |
185 | contact = response.json()
186 |
187 | self.object_id = contact.get(self._cc('id'), None)
188 | self.created = contact.get(self._cc('createdDateTime'), None)
189 | self.modified = contact.get(self._cc('lastModifiedDateTime'), None)
190 |
191 | local_tz = self.protocol.timezone
192 | self.created = parse(self.created).astimezone(local_tz) if self.created else None
193 | self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None
194 |
195 | return True
196 |
197 | def new_message(self, recipient=None, *, recipient_type=RecipientType.TO):
198 | """
199 | This method returns a new draft Message instance with this contact first email as a recipient
200 | :param recipient: a Recipient instance where to send this message. If None, first recipient with address.
201 | :param recipient_type: a RecipientType Enum.
202 | :return: a new draft Message or None if recipient has no addresses
203 | """
204 | if self.main_resource == GAL_MAIN_RESOURCE:
205 | # preventing the contact lookup to explode for big organizations..
206 | raise RuntimeError('Sending a message to all users within an Organization is not allowed')
207 |
208 | if isinstance(recipient_type, str):
209 | recipient_type = RecipientType(recipient_type)
210 |
211 | recipient = recipient or self.emails.get_first_recipient_with_address()
212 | if not recipient:
213 | return None
214 |
215 | new_message = self.message_constructor(parent=self, is_draft=True)
216 |
217 | target_recipients = getattr(new_message, str(recipient_type.value))
218 | target_recipients.add(recipient)
219 |
220 | return new_message
221 |
222 |
223 | class BaseContactFolder(ApiComponent):
224 | """ Base Contact Folder Grouping Functionality """
225 |
226 | _endpoints = {
227 | 'gal': '',
228 | 'root_contacts': '/contacts',
229 | 'folder_contacts': '/contactFolders/{id}/contacts',
230 | 'get_folder': '/contactFolders/{id}',
231 | 'root_folders': '/contactFolders',
232 | 'child_folders': '/contactFolders/{id}/childFolders'
233 | }
234 |
235 | contact_constructor = Contact
236 | message_constructor = Message
237 |
238 | def __init__(self, *, parent=None, con=None, **kwargs):
239 | assert parent or con, 'Need a parent or a connection'
240 | self.con = parent.con if parent else con
241 |
242 | # Choose the main_resource passed in kwargs over the parent main_resource
243 | main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None
244 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource)
245 |
246 | self.root = kwargs.pop('root', False) # This folder has no parents if root = True.
247 |
248 | cloud_data = kwargs.get(self._cloud_data_key, {})
249 |
250 | self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name', None)) # Fallback to manual folder
251 | self.folder_id = cloud_data.get(self._cc('id'), None)
252 | self.parent_id = cloud_data.get(self._cc('parentFolderId'), None)
253 |
254 | def __str__(self):
255 | return self.__repr__()
256 |
257 | def __repr__(self):
258 | return 'Contact Folder: {}'.format(self.name)
259 |
260 | def get_contacts(self, limit=100, *, query=None, order_by=None, batch=None):
261 | """
262 | Gets a list of contacts from this address book
263 |
264 | When quering the Global Address List the Users enpoint will be used.
265 | Only a limited set of information will be available unless you have acces to
266 | scope 'User.Read.All' wich requires App Administration Consent.
267 | Also using the Users enpoint has some limitations on the quering capabilites.
268 |
269 | To use query an order_by check the OData specification here:
270 | 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
271 |
272 | :param limit: Number of elements to return. Over 999 uses batch.
273 | :param query: a OData valid filter clause
274 | :param order_by: OData valid order by clause
275 | :param batch: Returns a custom iterator that retrieves items in batches allowing
276 | to retrieve more items than the limit.
277 | """
278 |
279 | if self.main_resource == GAL_MAIN_RESOURCE:
280 | # using Users endpoint to access the Global Address List
281 | url = self.build_url(self._endpoints.get('gal'))
282 | else:
283 | if self.root:
284 | url = self.build_url(self._endpoints.get('root_contacts'))
285 | else:
286 | url = self.build_url(self._endpoints.get('folder_contacts').format(id=self.folder_id))
287 |
288 | if limit is None or limit > self.protocol.max_top_value:
289 | batch = self.protocol.max_top_value
290 |
291 | params = {'$top': batch if batch else limit}
292 |
293 | if order_by:
294 | params['$orderby'] = order_by
295 |
296 | if query:
297 | if isinstance(query, str):
298 | params['$filter'] = query
299 | else:
300 | params.update(query.as_params())
301 |
302 | response = self.con.get(url, params=params)
303 | if not response:
304 | return []
305 |
306 | data = response.json()
307 |
308 | # Everything received from the cloud must be passed with self._cloud_data_key
309 | contacts = [self.contact_constructor(parent=self, **{self._cloud_data_key: contact})
310 | for contact in data.get('value', [])]
311 |
312 | next_link = data.get(NEXT_LINK_KEYWORD, None)
313 |
314 | if batch and next_link:
315 | return Pagination(parent=self, data=contacts, constructor=self.contact_constructor,
316 | next_link=next_link, limit=limit)
317 | else:
318 | return contacts
319 |
320 |
321 | class ContactFolder(BaseContactFolder):
322 | """ A Contact Folder representation """
323 |
324 | def get_folder(self, folder_id=None, folder_name=None):
325 | """
326 | Returns a ContactFolder by it's id or name
327 | :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not)
328 | :param folder_name: the folder name to be retrieved. Must be a child of this folder.
329 | """
330 |
331 | if folder_id and folder_name:
332 | raise RuntimeError('Provide only one of the options')
333 |
334 | if not folder_id and not folder_name:
335 | raise RuntimeError('Provide one of the options')
336 |
337 | if folder_id:
338 | # get folder by it's id, independent of the parent of this folder_id
339 | url = self.build_url(self._endpoints.get('get_folder').format(id=folder_id))
340 | params = None
341 | else:
342 | # get folder by name. Only looks up in child folders.
343 | if self.root:
344 | url = self.build_url(self._endpoints.get('root_folders'))
345 | else:
346 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
347 |
348 | params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), folder_name), '$top': 1}
349 |
350 | response = self.con.get(url, params=params)
351 | if not response:
352 | return None
353 |
354 | if folder_id:
355 | folder = response.json()
356 | else:
357 | folder = response.json().get('value')
358 | folder = folder[0] if folder else None
359 | if folder is None:
360 | return None
361 |
362 | # Everything received from the cloud must be passed with self._cloud_data_key
363 | # we don't pass parent, as this folder may not be a child of self.
364 | return ContactFolder(con=self.con, protocol=self.protocol, main_resource=self.main_resource, **{self._cloud_data_key: folder})
365 |
366 | def get_folders(self, limit=None, *, query=None, order_by=None):
367 | """
368 | Returns a list of child folders
369 |
370 | :param limit: Number of elements to return.
371 | :param query: a OData valid filter clause
372 | :param order_by: OData valid order by clause
373 | """
374 | if self.root:
375 | url = self.build_url(self._endpoints.get('root_folders'))
376 | else:
377 | url = self.build_url(self._endpoints.get('child_folders').format(self.folder_id))
378 |
379 | params = {}
380 |
381 | if limit:
382 | params['$top'] = limit
383 |
384 | if order_by:
385 | params['$orderby'] = order_by
386 |
387 | if query:
388 | if isinstance(query, str):
389 | params['$filter'] = query
390 | else:
391 | params.update(query.as_params())
392 |
393 | response = self.con.get(url, params=params or None)
394 | if not response:
395 | return []
396 |
397 | data = response.json()
398 |
399 | return [ContactFolder(parent=self, **{self._cloud_data_key: folder})
400 | for folder in data.get('value', [])]
401 |
402 | def create_child_folder(self, folder_name):
403 | """
404 | Creates a new child folder
405 | :return the new Folder Object or None
406 | """
407 |
408 | if not folder_name:
409 | return None
410 |
411 | if self.root:
412 | url = self.build_url(self._endpoints.get('root_folders'))
413 | else:
414 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
415 |
416 | response = self.con.post(url, data={self._cc('displayName'): folder_name})
417 | if not response:
418 | return None
419 |
420 | folder = response.json()
421 |
422 | # Everything received from the cloud must be passed with self._cloud_data_key
423 | return ContactFolder(parent=self, **{self._cloud_data_key: folder})
424 |
425 | def update_folder_name(self, name):
426 | """ Change this folder name """
427 | if self.root:
428 | return False
429 | if not name:
430 | return False
431 |
432 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
433 |
434 | response = self.con.patch(url, data={self._cc('displayName'): name})
435 | if not response:
436 | return False
437 |
438 | folder = response.json()
439 |
440 | self.name = folder.get(self._cc('displayName'), '')
441 | self.parent_id = folder.get(self._cc('parentFolderId'), None)
442 |
443 | return True
444 |
445 | def move_folder(self, to_folder):
446 | """
447 | Change this folder name
448 | :param to_folder: a folder_id str or a ContactFolder
449 | """
450 | if self.root:
451 | return False
452 | if not to_folder:
453 | return False
454 |
455 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
456 |
457 | if isinstance(to_folder, ContactFolder):
458 | folder_id = to_folder.folder_id
459 | elif isinstance(to_folder, str):
460 | folder_id = to_folder
461 | else:
462 | return False
463 |
464 | response = self.con.patch(url, data={self._cc('parentFolderId'): folder_id})
465 | if not response:
466 | return False
467 |
468 | folder = response.json()
469 |
470 | self.name = folder.get(self._cc('displayName'), '')
471 | self.parent_id = folder.get(self._cc('parentFolderId'), None)
472 |
473 | return True
474 |
475 | def delete(self):
476 | """ Deletes this folder """
477 |
478 | if self.root or not self.folder_id:
479 | return False
480 |
481 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
482 |
483 | response = self.con.delete(url)
484 | if not response:
485 | return False
486 |
487 | self.folder_id = None
488 |
489 | return True
490 |
491 | def new_contact(self):
492 | """ Creates a new contact to be saved into it's parent folder """
493 | contact = self.contact_constructor(parent=self)
494 | if not self.root:
495 | contact.folder_id = self.folder_id
496 |
497 | return contact
498 |
499 | def new_message(self, recipient_type=RecipientType.TO, *, query=None):
500 | """
501 | This method returns a new draft Message instance with all the contacts first email as a recipient
502 | :param recipient_type: a RecipientType Enum.
503 | :param query: a query to filter the contacts (passed to get_contacts)
504 | :return: a draft Message or None if no contacts could be retrieved
505 | """
506 |
507 | if isinstance(recipient_type, str):
508 | recipient_type = RecipientType(recipient_type)
509 |
510 | recipients = [contact.emails[0]
511 | for contact in self.get_contacts(limit=None, query=query)
512 | if contact.emails and contact.emails[0].address]
513 |
514 | if not recipients:
515 | return None
516 |
517 | new_message = self.message_constructor(parent=self, is_draft=True)
518 | target_recipients = getattr(new_message, str(recipient_type.value))
519 | target_recipients.add(recipients)
520 |
521 | return new_message
522 |
523 |
524 | class AddressBook(ContactFolder):
525 | """ A class representing an address book """
526 |
527 | def __init__(self, *, parent=None, con=None, **kwargs):
528 | # set instance to be a root instance
529 | super().__init__(parent=parent, con=con, root=True, **kwargs)
530 |
531 | def __repr__(self):
532 | return 'Address Book resource: {}'.format(self.main_resource)
533 |
534 |
535 | class GlobalAddressList(BaseContactFolder):
536 | """ A class representing the Global Address List (Users API) """
537 |
538 | def __init__(self, *, parent=None, con=None, **kwargs):
539 | # set instance to be a root instance and the main_resource to be the GAL_MAIN_RESOURCE
540 | super().__init__(parent=parent, con=con, root=True, main_resource=GAL_MAIN_RESOURCE,
541 | name='Global Address List', **kwargs)
542 |
543 | def __repr__(self):
544 | return 'Global Address List'
545 |
546 | def get_contact_by_email(self, email):
547 | """ Returns a Contact by it's email """
548 |
549 | if not email:
550 | return None
551 |
552 | email = email.strip()
553 |
554 | url = self.build_url('{}/{}'.format(self._endpoints.get('gal'), email))
555 |
556 | response = self.con.get(url)
557 | if not response:
558 | return []
559 |
560 | data = response.json()
561 |
562 | # Everything received from the cloud must be passed with self._cloud_data_key
563 | return self.contact_constructor(parent=self, **{self._cloud_data_key: data})
564 |
--------------------------------------------------------------------------------
/pyo365/utils/windows_tz.py:
--------------------------------------------------------------------------------
1 | """
2 | Mapping from iana timezones to windows timezones and vice versa
3 | """
4 |
5 | IANA_TO_WIN = {
6 | 'Africa/Abidjan': 'Greenwich Standard Time',
7 | 'Africa/Accra': 'Greenwich Standard Time',
8 | 'Africa/Addis_Ababa': 'E. Africa Standard Time',
9 | 'Africa/Algiers': 'W. Central Africa Standard Time',
10 | 'Africa/Asmera': 'E. Africa Standard Time',
11 | 'Africa/Bamako': 'Greenwich Standard Time',
12 | 'Africa/Bangui': 'W. Central Africa Standard Time',
13 | 'Africa/Banjul': 'Greenwich Standard Time',
14 | 'Africa/Bissau': 'Greenwich Standard Time',
15 | 'Africa/Blantyre': 'South Africa Standard Time',
16 | 'Africa/Brazzaville': 'W. Central Africa Standard Time',
17 | 'Africa/Bujumbura': 'South Africa Standard Time',
18 | 'Africa/Cairo': 'Egypt Standard Time',
19 | 'Africa/Casablanca': 'Morocco Standard Time',
20 | 'Africa/Ceuta': 'Romance Standard Time',
21 | 'Africa/Conakry': 'Greenwich Standard Time',
22 | 'Africa/Dakar': 'Greenwich Standard Time',
23 | 'Africa/Dar_es_Salaam': 'E. Africa Standard Time',
24 | 'Africa/Djibouti': 'E. Africa Standard Time',
25 | 'Africa/Douala': 'W. Central Africa Standard Time',
26 | 'Africa/El_Aaiun': 'Morocco Standard Time',
27 | 'Africa/Freetown': 'Greenwich Standard Time',
28 | 'Africa/Gaborone': 'South Africa Standard Time',
29 | 'Africa/Harare': 'South Africa Standard Time',
30 | 'Africa/Johannesburg': 'South Africa Standard Time',
31 | 'Africa/Juba': 'E. Africa Standard Time',
32 | 'Africa/Kampala': 'E. Africa Standard Time',
33 | 'Africa/Khartoum': 'Sudan Standard Time',
34 | 'Africa/Kigali': 'South Africa Standard Time',
35 | 'Africa/Kinshasa': 'W. Central Africa Standard Time',
36 | 'Africa/Lagos': 'W. Central Africa Standard Time',
37 | 'Africa/Libreville': 'W. Central Africa Standard Time',
38 | 'Africa/Lome': 'Greenwich Standard Time',
39 | 'Africa/Luanda': 'W. Central Africa Standard Time',
40 | 'Africa/Lubumbashi': 'South Africa Standard Time',
41 | 'Africa/Lusaka': 'South Africa Standard Time',
42 | 'Africa/Malabo': 'W. Central Africa Standard Time',
43 | 'Africa/Maputo': 'South Africa Standard Time',
44 | 'Africa/Maseru': 'South Africa Standard Time',
45 | 'Africa/Mbabane': 'South Africa Standard Time',
46 | 'Africa/Mogadishu': 'E. Africa Standard Time',
47 | 'Africa/Monrovia': 'Greenwich Standard Time',
48 | 'Africa/Nairobi': 'E. Africa Standard Time',
49 | 'Africa/Ndjamena': 'W. Central Africa Standard Time',
50 | 'Africa/Niamey': 'W. Central Africa Standard Time',
51 | 'Africa/Nouakchott': 'Greenwich Standard Time',
52 | 'Africa/Ouagadougou': 'Greenwich Standard Time',
53 | 'Africa/Porto-Novo': 'W. Central Africa Standard Time',
54 | 'Africa/Sao_Tome': 'W. Central Africa Standard Time',
55 | 'Africa/Tripoli': 'Libya Standard Time',
56 | 'Africa/Tunis': 'W. Central Africa Standard Time',
57 | 'Africa/Windhoek': 'Namibia Standard Time',
58 | 'America/Adak': 'Aleutian Standard Time',
59 | 'America/Anchorage': 'Alaskan Standard Time',
60 | 'America/Anguilla': 'SA Western Standard Time',
61 | 'America/Antigua': 'SA Western Standard Time',
62 | 'America/Araguaina': 'Tocantins Standard Time',
63 | 'America/Argentina/La_Rioja': 'Argentina Standard Time',
64 | 'America/Argentina/Rio_Gallegos': 'Argentina Standard Time',
65 | 'America/Argentina/Salta': 'Argentina Standard Time',
66 | 'America/Argentina/San_Juan': 'Argentina Standard Time',
67 | 'America/Argentina/San_Luis': 'Argentina Standard Time',
68 | 'America/Argentina/Tucuman': 'Argentina Standard Time',
69 | 'America/Argentina/Ushuaia': 'Argentina Standard Time',
70 | 'America/Aruba': 'SA Western Standard Time',
71 | 'America/Asuncion': 'Paraguay Standard Time',
72 | 'America/Bahia': 'Bahia Standard Time',
73 | 'America/Bahia_Banderas': 'Central Standard Time (Mexico)',
74 | 'America/Barbados': 'SA Western Standard Time',
75 | 'America/Belem': 'SA Eastern Standard Time',
76 | 'America/Belize': 'Central America Standard Time',
77 | 'America/Blanc-Sablon': 'SA Western Standard Time',
78 | 'America/Boa_Vista': 'SA Western Standard Time',
79 | 'America/Bogota': 'SA Pacific Standard Time',
80 | 'America/Boise': 'Mountain Standard Time',
81 | 'America/Buenos_Aires': 'Argentina Standard Time',
82 | 'America/Cambridge_Bay': 'Mountain Standard Time',
83 | 'America/Campo_Grande': 'Central Brazilian Standard Time',
84 | 'America/Cancun': 'Eastern Standard Time (Mexico)',
85 | 'America/Caracas': 'Venezuela Standard Time',
86 | 'America/Catamarca': 'Argentina Standard Time',
87 | 'America/Cayenne': 'SA Eastern Standard Time',
88 | 'America/Cayman': 'SA Pacific Standard Time',
89 | 'America/Chicago': 'Central Standard Time',
90 | 'America/Chihuahua': 'Mountain Standard Time (Mexico)',
91 | 'America/Coral_Harbour': 'SA Pacific Standard Time',
92 | 'America/Cordoba': 'Argentina Standard Time',
93 | 'America/Costa_Rica': 'Central America Standard Time',
94 | 'America/Creston': 'US Mountain Standard Time',
95 | 'America/Cuiaba': 'Central Brazilian Standard Time',
96 | 'America/Curacao': 'SA Western Standard Time',
97 | 'America/Danmarkshavn': 'UTC',
98 | 'America/Dawson': 'Pacific Standard Time',
99 | 'America/Dawson_Creek': 'US Mountain Standard Time',
100 | 'America/Denver': 'Mountain Standard Time',
101 | 'America/Detroit': 'Eastern Standard Time',
102 | 'America/Dominica': 'SA Western Standard Time',
103 | 'America/Edmonton': 'Mountain Standard Time',
104 | 'America/Eirunepe': 'SA Pacific Standard Time',
105 | 'America/El_Salvador': 'Central America Standard Time',
106 | 'America/Fort_Nelson': 'US Mountain Standard Time',
107 | 'America/Fortaleza': 'SA Eastern Standard Time',
108 | 'America/Glace_Bay': 'Atlantic Standard Time',
109 | 'America/Godthab': 'Greenland Standard Time',
110 | 'America/Goose_Bay': 'Atlantic Standard Time',
111 | 'America/Grand_Turk': 'Turks And Caicos Standard Time',
112 | 'America/Grenada': 'SA Western Standard Time',
113 | 'America/Guadeloupe': 'SA Western Standard Time',
114 | 'America/Guatemala': 'Central America Standard Time',
115 | 'America/Guayaquil': 'SA Pacific Standard Time',
116 | 'America/Guyana': 'SA Western Standard Time',
117 | 'America/Halifax': 'Atlantic Standard Time',
118 | 'America/Havana': 'Cuba Standard Time',
119 | 'America/Hermosillo': 'US Mountain Standard Time',
120 | 'America/Indiana/Knox': 'Central Standard Time',
121 | 'America/Indiana/Marengo': 'US Eastern Standard Time',
122 | 'America/Indiana/Petersburg': 'Eastern Standard Time',
123 | 'America/Indiana/Tell_City': 'Central Standard Time',
124 | 'America/Indiana/Vevay': 'US Eastern Standard Time',
125 | 'America/Indiana/Vincennes': 'Eastern Standard Time',
126 | 'America/Indiana/Winamac': 'Eastern Standard Time',
127 | 'America/Indianapolis': 'US Eastern Standard Time',
128 | 'America/Inuvik': 'Mountain Standard Time',
129 | 'America/Iqaluit': 'Eastern Standard Time',
130 | 'America/Jamaica': 'SA Pacific Standard Time',
131 | 'America/Jujuy': 'Argentina Standard Time',
132 | 'America/Juneau': 'Alaskan Standard Time',
133 | 'America/Kentucky/Monticello': 'Eastern Standard Time',
134 | 'America/Kralendijk': 'SA Western Standard Time',
135 | 'America/La_Paz': 'SA Western Standard Time',
136 | 'America/Lima': 'SA Pacific Standard Time',
137 | 'America/Los_Angeles': 'Pacific Standard Time',
138 | 'America/Louisville': 'Eastern Standard Time',
139 | 'America/Lower_Princes': 'SA Western Standard Time',
140 | 'America/Maceio': 'SA Eastern Standard Time',
141 | 'America/Managua': 'Central America Standard Time',
142 | 'America/Manaus': 'SA Western Standard Time',
143 | 'America/Marigot': 'SA Western Standard Time',
144 | 'America/Martinique': 'SA Western Standard Time',
145 | 'America/Matamoros': 'Central Standard Time',
146 | 'America/Mazatlan': 'Mountain Standard Time (Mexico)',
147 | 'America/Mendoza': 'Argentina Standard Time',
148 | 'America/Menominee': 'Central Standard Time',
149 | 'America/Merida': 'Central Standard Time (Mexico)',
150 | 'America/Metlakatla': 'Alaskan Standard Time',
151 | 'America/Mexico_City': 'Central Standard Time (Mexico)',
152 | 'America/Miquelon': 'Saint Pierre Standard Time',
153 | 'America/Moncton': 'Atlantic Standard Time',
154 | 'America/Monterrey': 'Central Standard Time (Mexico)',
155 | 'America/Montevideo': 'Montevideo Standard Time',
156 | 'America/Montreal': 'Eastern Standard Time',
157 | 'America/Montserrat': 'SA Western Standard Time',
158 | 'America/Nassau': 'Eastern Standard Time',
159 | 'America/New_York': 'Eastern Standard Time',
160 | 'America/Nipigon': 'Eastern Standard Time',
161 | 'America/Nome': 'Alaskan Standard Time',
162 | 'America/Noronha': 'UTC-02',
163 | 'America/North_Dakota/Beulah': 'Central Standard Time',
164 | 'America/North_Dakota/Center': 'Central Standard Time',
165 | 'America/North_Dakota/New_Salem': 'Central Standard Time',
166 | 'America/Ojinaga': 'Mountain Standard Time',
167 | 'America/Panama': 'SA Pacific Standard Time',
168 | 'America/Pangnirtung': 'Eastern Standard Time',
169 | 'America/Paramaribo': 'SA Eastern Standard Time',
170 | 'America/Phoenix': 'US Mountain Standard Time',
171 | 'America/Port-au-Prince': 'Haiti Standard Time',
172 | 'America/Port_of_Spain': 'SA Western Standard Time',
173 | 'America/Porto_Velho': 'SA Western Standard Time',
174 | 'America/Puerto_Rico': 'SA Western Standard Time',
175 | 'America/Punta_Arenas': 'Magallanes Standard Time',
176 | 'America/Rainy_River': 'Central Standard Time',
177 | 'America/Rankin_Inlet': 'Central Standard Time',
178 | 'America/Recife': 'SA Eastern Standard Time',
179 | 'America/Regina': 'Canada Central Standard Time',
180 | 'America/Resolute': 'Central Standard Time',
181 | 'America/Rio_Branco': 'SA Pacific Standard Time',
182 | 'America/Santa_Isabel': 'Pacific Standard Time (Mexico)',
183 | 'America/Santarem': 'SA Eastern Standard Time',
184 | 'America/Santiago': 'Pacific SA Standard Time',
185 | 'America/Santo_Domingo': 'SA Western Standard Time',
186 | 'America/Sao_Paulo': 'E. South America Standard Time',
187 | 'America/Scoresbysund': 'Azores Standard Time',
188 | 'America/Sitka': 'Alaskan Standard Time',
189 | 'America/St_Barthelemy': 'SA Western Standard Time',
190 | 'America/St_Johns': 'Newfoundland Standard Time',
191 | 'America/St_Kitts': 'SA Western Standard Time',
192 | 'America/St_Lucia': 'SA Western Standard Time',
193 | 'America/St_Thomas': 'SA Western Standard Time',
194 | 'America/St_Vincent': 'SA Western Standard Time',
195 | 'America/Swift_Current': 'Canada Central Standard Time',
196 | 'America/Tegucigalpa': 'Central America Standard Time',
197 | 'America/Thule': 'Atlantic Standard Time',
198 | 'America/Thunder_Bay': 'Eastern Standard Time',
199 | 'America/Tijuana': 'Pacific Standard Time (Mexico)',
200 | 'America/Toronto': 'Eastern Standard Time',
201 | 'America/Tortola': 'SA Western Standard Time',
202 | 'America/Vancouver': 'Pacific Standard Time',
203 | 'America/Whitehorse': 'Pacific Standard Time',
204 | 'America/Winnipeg': 'Central Standard Time',
205 | 'America/Yakutat': 'Alaskan Standard Time',
206 | 'America/Yellowknife': 'Mountain Standard Time',
207 | 'Antarctica/Casey': 'W. Australia Standard Time',
208 | 'Antarctica/Davis': 'SE Asia Standard Time',
209 | 'Antarctica/DumontDUrville': 'West Pacific Standard Time',
210 | 'Antarctica/Macquarie': 'Central Pacific Standard Time',
211 | 'Antarctica/Mawson': 'West Asia Standard Time',
212 | 'Antarctica/McMurdo': 'New Zealand Standard Time',
213 | 'Antarctica/Palmer': 'Magallanes Standard Time',
214 | 'Antarctica/Rothera': 'SA Eastern Standard Time',
215 | 'Antarctica/Syowa': 'E. Africa Standard Time',
216 | 'Antarctica/Vostok': 'Central Asia Standard Time',
217 | 'Arctic/Longyearbyen': 'W. Europe Standard Time',
218 | 'Asia/Aden': 'Arab Standard Time',
219 | 'Asia/Almaty': 'Central Asia Standard Time',
220 | 'Asia/Amman': 'Jordan Standard Time',
221 | 'Asia/Anadyr': 'Russia Time Zone 11',
222 | 'Asia/Aqtau': 'West Asia Standard Time',
223 | 'Asia/Aqtobe': 'West Asia Standard Time',
224 | 'Asia/Ashgabat': 'West Asia Standard Time',
225 | 'Asia/Atyrau': 'West Asia Standard Time',
226 | 'Asia/Baghdad': 'Arabic Standard Time',
227 | 'Asia/Bahrain': 'Arab Standard Time',
228 | 'Asia/Baku': 'Azerbaijan Standard Time',
229 | 'Asia/Bangkok': 'SE Asia Standard Time',
230 | 'Asia/Barnaul': 'Altai Standard Time',
231 | 'Asia/Beirut': 'Middle East Standard Time',
232 | 'Asia/Bishkek': 'Central Asia Standard Time',
233 | 'Asia/Brunei': 'Singapore Standard Time',
234 | 'Asia/Calcutta': 'India Standard Time',
235 | 'Asia/Chita': 'Transbaikal Standard Time',
236 | 'Asia/Choibalsan': 'Ulaanbaatar Standard Time',
237 | 'Asia/Colombo': 'Sri Lanka Standard Time',
238 | 'Asia/Damascus': 'Syria Standard Time',
239 | 'Asia/Dhaka': 'Bangladesh Standard Time',
240 | 'Asia/Dili': 'Tokyo Standard Time',
241 | 'Asia/Dubai': 'Arabian Standard Time',
242 | 'Asia/Dushanbe': 'West Asia Standard Time',
243 | 'Asia/Famagusta': 'Turkey Standard Time',
244 | 'Asia/Gaza': 'West Bank Standard Time',
245 | 'Asia/Hebron': 'West Bank Standard Time',
246 | 'Asia/Hong_Kong': 'China Standard Time',
247 | 'Asia/Hovd': 'W. Mongolia Standard Time',
248 | 'Asia/Irkutsk': 'North Asia East Standard Time',
249 | 'Asia/Jakarta': 'SE Asia Standard Time',
250 | 'Asia/Jayapura': 'Tokyo Standard Time',
251 | 'Asia/Jerusalem': 'Israel Standard Time',
252 | 'Asia/Kabul': 'Afghanistan Standard Time',
253 | 'Asia/Kamchatka': 'Russia Time Zone 11',
254 | 'Asia/Karachi': 'Pakistan Standard Time',
255 | 'Asia/Katmandu': 'Nepal Standard Time',
256 | 'Asia/Khandyga': 'Yakutsk Standard Time',
257 | 'Asia/Kolkata': 'India Standard Time',
258 | 'Asia/Krasnoyarsk': 'North Asia Standard Time',
259 | 'Asia/Kuala_Lumpur': 'Singapore Standard Time',
260 | 'Asia/Kuching': 'Singapore Standard Time',
261 | 'Asia/Kuwait': 'Arab Standard Time',
262 | 'Asia/Macau': 'China Standard Time',
263 | 'Asia/Magadan': 'Magadan Standard Time',
264 | 'Asia/Makassar': 'Singapore Standard Time',
265 | 'Asia/Manila': 'Singapore Standard Time',
266 | 'Asia/Muscat': 'Arabian Standard Time',
267 | 'Asia/Nicosia': 'GTB Standard Time',
268 | 'Asia/Novokuznetsk': 'North Asia Standard Time',
269 | 'Asia/Novosibirsk': 'N. Central Asia Standard Time',
270 | 'Asia/Omsk': 'Omsk Standard Time',
271 | 'Asia/Oral': 'West Asia Standard Time',
272 | 'Asia/Phnom_Penh': 'SE Asia Standard Time',
273 | 'Asia/Pontianak': 'SE Asia Standard Time',
274 | 'Asia/Pyongyang': 'North Korea Standard Time',
275 | 'Asia/Qatar': 'Arab Standard Time',
276 | 'Asia/Qyzylorda': 'Central Asia Standard Time',
277 | 'Asia/Rangoon': 'Myanmar Standard Time',
278 | 'Asia/Riyadh': 'Arab Standard Time',
279 | 'Asia/Saigon': 'SE Asia Standard Time',
280 | 'Asia/Sakhalin': 'Sakhalin Standard Time',
281 | 'Asia/Samarkand': 'West Asia Standard Time',
282 | 'Asia/Seoul': 'Korea Standard Time',
283 | 'Asia/Shanghai': 'China Standard Time',
284 | 'Asia/Singapore': 'Singapore Standard Time',
285 | 'Asia/Srednekolymsk': 'Russia Time Zone 10',
286 | 'Asia/Taipei': 'Taipei Standard Time',
287 | 'Asia/Tashkent': 'West Asia Standard Time',
288 | 'Asia/Tbilisi': 'Georgian Standard Time',
289 | 'Asia/Tehran': 'Iran Standard Time',
290 | 'Asia/Thimphu': 'Bangladesh Standard Time',
291 | 'Asia/Tokyo': 'Tokyo Standard Time',
292 | 'Asia/Tomsk': 'Tomsk Standard Time',
293 | 'Asia/Ulaanbaatar': 'Ulaanbaatar Standard Time',
294 | 'Asia/Urumqi': 'Central Asia Standard Time',
295 | 'Asia/Ust-Nera': 'Vladivostok Standard Time',
296 | 'Asia/Vientiane': 'SE Asia Standard Time',
297 | 'Asia/Vladivostok': 'Vladivostok Standard Time',
298 | 'Asia/Yakutsk': 'Yakutsk Standard Time',
299 | 'Asia/Yekaterinburg': 'Ekaterinburg Standard Time',
300 | 'Asia/Yerevan': 'Caucasus Standard Time',
301 | 'Atlantic/Azores': 'Azores Standard Time',
302 | 'Atlantic/Bermuda': 'Atlantic Standard Time',
303 | 'Atlantic/Canary': 'GMT Standard Time',
304 | 'Atlantic/Cape_Verde': 'Cape Verde Standard Time',
305 | 'Atlantic/Faeroe': 'GMT Standard Time',
306 | 'Atlantic/Madeira': 'GMT Standard Time',
307 | 'Atlantic/Reykjavik': 'Greenwich Standard Time',
308 | 'Atlantic/South_Georgia': 'UTC-02',
309 | 'Atlantic/St_Helena': 'Greenwich Standard Time',
310 | 'Atlantic/Stanley': 'SA Eastern Standard Time',
311 | 'Australia/Adelaide': 'Cen. Australia Standard Time',
312 | 'Australia/Brisbane': 'E. Australia Standard Time',
313 | 'Australia/Broken_Hill': 'Cen. Australia Standard Time',
314 | 'Australia/Currie': 'Tasmania Standard Time',
315 | 'Australia/Darwin': 'AUS Central Standard Time',
316 | 'Australia/Eucla': 'Aus Central W. Standard Time',
317 | 'Australia/Hobart': 'Tasmania Standard Time',
318 | 'Australia/Lindeman': 'E. Australia Standard Time',
319 | 'Australia/Lord_Howe': 'Lord Howe Standard Time',
320 | 'Australia/Melbourne': 'AUS Eastern Standard Time',
321 | 'Australia/Perth': 'W. Australia Standard Time',
322 | 'Australia/Sydney': 'AUS Eastern Standard Time',
323 | 'CST6CDT': 'Central Standard Time',
324 | 'EST5EDT': 'Eastern Standard Time',
325 | 'Etc/GMT': 'UTC',
326 | 'Etc/GMT+1': 'Cape Verde Standard Time',
327 | 'Etc/GMT+10': 'Hawaiian Standard Time',
328 | 'Etc/GMT+11': 'UTC-11',
329 | 'Etc/GMT+12': 'Dateline Standard Time',
330 | 'Etc/GMT+2': 'UTC-02',
331 | 'Etc/GMT+3': 'SA Eastern Standard Time',
332 | 'Etc/GMT+4': 'SA Western Standard Time',
333 | 'Etc/GMT+5': 'SA Pacific Standard Time',
334 | 'Etc/GMT+6': 'Central America Standard Time',
335 | 'Etc/GMT+7': 'US Mountain Standard Time',
336 | 'Etc/GMT+8': 'UTC-08',
337 | 'Etc/GMT+9': 'UTC-09',
338 | 'Etc/GMT-1': 'W. Central Africa Standard Time',
339 | 'Etc/GMT-10': 'West Pacific Standard Time',
340 | 'Etc/GMT-11': 'Central Pacific Standard Time',
341 | 'Etc/GMT-12': 'UTC+12',
342 | 'Etc/GMT-13': 'UTC+13',
343 | 'Etc/GMT-14': 'Line Islands Standard Time',
344 | 'Etc/GMT-2': 'South Africa Standard Time',
345 | 'Etc/GMT-3': 'E. Africa Standard Time',
346 | 'Etc/GMT-4': 'Arabian Standard Time',
347 | 'Etc/GMT-5': 'West Asia Standard Time',
348 | 'Etc/GMT-6': 'Central Asia Standard Time',
349 | 'Etc/GMT-7': 'SE Asia Standard Time',
350 | 'Etc/GMT-8': 'Singapore Standard Time',
351 | 'Etc/GMT-9': 'Tokyo Standard Time',
352 | 'Etc/UTC': 'UTC',
353 | 'Europe/Amsterdam': 'W. Europe Standard Time',
354 | 'Europe/Andorra': 'W. Europe Standard Time',
355 | 'Europe/Astrakhan': 'Astrakhan Standard Time',
356 | 'Europe/Athens': 'GTB Standard Time',
357 | 'Europe/Belgrade': 'Central Europe Standard Time',
358 | 'Europe/Berlin': 'W. Europe Standard Time',
359 | 'Europe/Bratislava': 'Central Europe Standard Time',
360 | 'Europe/Brussels': 'Romance Standard Time',
361 | 'Europe/Bucharest': 'GTB Standard Time',
362 | 'Europe/Budapest': 'Central Europe Standard Time',
363 | 'Europe/Busingen': 'W. Europe Standard Time',
364 | 'Europe/Chisinau': 'E. Europe Standard Time',
365 | 'Europe/Copenhagen': 'Romance Standard Time',
366 | 'Europe/Dublin': 'GMT Standard Time',
367 | 'Europe/Gibraltar': 'W. Europe Standard Time',
368 | 'Europe/Guernsey': 'GMT Standard Time',
369 | 'Europe/Helsinki': 'FLE Standard Time',
370 | 'Europe/Isle_of_Man': 'GMT Standard Time',
371 | 'Europe/Istanbul': 'Turkey Standard Time',
372 | 'Europe/Jersey': 'GMT Standard Time',
373 | 'Europe/Kaliningrad': 'Kaliningrad Standard Time',
374 | 'Europe/Kiev': 'FLE Standard Time',
375 | 'Europe/Kirov': 'Russian Standard Time',
376 | 'Europe/Lisbon': 'GMT Standard Time',
377 | 'Europe/Ljubljana': 'Central Europe Standard Time',
378 | 'Europe/London': 'GMT Standard Time',
379 | 'Europe/Luxembourg': 'W. Europe Standard Time',
380 | 'Europe/Madrid': 'Romance Standard Time',
381 | 'Europe/Malta': 'W. Europe Standard Time',
382 | 'Europe/Mariehamn': 'FLE Standard Time',
383 | 'Europe/Minsk': 'Belarus Standard Time',
384 | 'Europe/Monaco': 'W. Europe Standard Time',
385 | 'Europe/Moscow': 'Russian Standard Time',
386 | 'Europe/Oslo': 'W. Europe Standard Time',
387 | 'Europe/Paris': 'Romance Standard Time',
388 | 'Europe/Podgorica': 'Central Europe Standard Time',
389 | 'Europe/Prague': 'Central Europe Standard Time',
390 | 'Europe/Riga': 'FLE Standard Time',
391 | 'Europe/Rome': 'W. Europe Standard Time',
392 | 'Europe/Samara': 'Russia Time Zone 3',
393 | 'Europe/San_Marino': 'W. Europe Standard Time',
394 | 'Europe/Sarajevo': 'Central European Standard Time',
395 | 'Europe/Saratov': 'Saratov Standard Time',
396 | 'Europe/Simferopol': 'Russian Standard Time',
397 | 'Europe/Skopje': 'Central European Standard Time',
398 | 'Europe/Sofia': 'FLE Standard Time',
399 | 'Europe/Stockholm': 'W. Europe Standard Time',
400 | 'Europe/Tallinn': 'FLE Standard Time',
401 | 'Europe/Tirane': 'Central Europe Standard Time',
402 | 'Europe/Ulyanovsk': 'Astrakhan Standard Time',
403 | 'Europe/Uzhgorod': 'FLE Standard Time',
404 | 'Europe/Vaduz': 'W. Europe Standard Time',
405 | 'Europe/Vatican': 'W. Europe Standard Time',
406 | 'Europe/Vienna': 'W. Europe Standard Time',
407 | 'Europe/Vilnius': 'FLE Standard Time',
408 | 'Europe/Volgograd': 'Russian Standard Time',
409 | 'Europe/Warsaw': 'Central European Standard Time',
410 | 'Europe/Zagreb': 'Central European Standard Time',
411 | 'Europe/Zaporozhye': 'FLE Standard Time',
412 | 'Europe/Zurich': 'W. Europe Standard Time',
413 | 'GMT': 'GMT Standard Time',
414 | 'GB': 'GMT Standard Time',
415 | 'Indian/Antananarivo': 'E. Africa Standard Time',
416 | 'Indian/Chagos': 'Central Asia Standard Time',
417 | 'Indian/Christmas': 'SE Asia Standard Time',
418 | 'Indian/Cocos': 'Myanmar Standard Time',
419 | 'Indian/Comoro': 'E. Africa Standard Time',
420 | 'Indian/Kerguelen': 'West Asia Standard Time',
421 | 'Indian/Mahe': 'Mauritius Standard Time',
422 | 'Indian/Maldives': 'West Asia Standard Time',
423 | 'Indian/Mauritius': 'Mauritius Standard Time',
424 | 'Indian/Mayotte': 'E. Africa Standard Time',
425 | 'Indian/Reunion': 'Mauritius Standard Time',
426 | 'MST7MDT': 'Mountain Standard Time',
427 | 'PST8PDT': 'Pacific Standard Time',
428 | 'Pacific/Apia': 'Samoa Standard Time',
429 | 'Pacific/Auckland': 'New Zealand Standard Time',
430 | 'Pacific/Bougainville': 'Bougainville Standard Time',
431 | 'Pacific/Chatham': 'Chatham Islands Standard Time',
432 | 'Pacific/Easter': 'Easter Island Standard Time',
433 | 'Pacific/Efate': 'Central Pacific Standard Time',
434 | 'Pacific/Enderbury': 'UTC+13',
435 | 'Pacific/Fakaofo': 'UTC+13',
436 | 'Pacific/Fiji': 'Fiji Standard Time',
437 | 'Pacific/Funafuti': 'UTC+12',
438 | 'Pacific/Galapagos': 'Central America Standard Time',
439 | 'Pacific/Gambier': 'UTC-09',
440 | 'Pacific/Guadalcanal': 'Central Pacific Standard Time',
441 | 'Pacific/Guam': 'West Pacific Standard Time',
442 | 'Pacific/Honolulu': 'Hawaiian Standard Time',
443 | 'Pacific/Johnston': 'Hawaiian Standard Time',
444 | 'Pacific/Kiritimati': 'Line Islands Standard Time',
445 | 'Pacific/Kosrae': 'Central Pacific Standard Time',
446 | 'Pacific/Kwajalein': 'UTC+12',
447 | 'Pacific/Majuro': 'UTC+12',
448 | 'Pacific/Marquesas': 'Marquesas Standard Time',
449 | 'Pacific/Midway': 'UTC-11',
450 | 'Pacific/Nauru': 'UTC+12',
451 | 'Pacific/Niue': 'UTC-11',
452 | 'Pacific/Norfolk': 'Norfolk Standard Time',
453 | 'Pacific/Noumea': 'Central Pacific Standard Time',
454 | 'Pacific/Pago_Pago': 'UTC-11',
455 | 'Pacific/Palau': 'Tokyo Standard Time',
456 | 'Pacific/Pitcairn': 'UTC-08',
457 | 'Pacific/Ponape': 'Central Pacific Standard Time',
458 | 'Pacific/Port_Moresby': 'West Pacific Standard Time',
459 | 'Pacific/Rarotonga': 'Hawaiian Standard Time',
460 | 'Pacific/Saipan': 'West Pacific Standard Time',
461 | 'Pacific/Tahiti': 'Hawaiian Standard Time',
462 | 'Pacific/Tarawa': 'UTC+12',
463 | 'Pacific/Tongatapu': 'Tonga Standard Time',
464 | 'Pacific/Truk': 'West Pacific Standard Time',
465 | 'Pacific/Wake': 'UTC+12',
466 | 'Pacific/Wallis': 'UTC+12',
467 | 'UTC': 'UTC',
468 | }
469 |
470 | WIN_TO_IANA = {v: k for k, v in IANA_TO_WIN.items()}
471 |
--------------------------------------------------------------------------------
/pyo365/message.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import datetime as dt
3 | from dateutil.parser import parse
4 | import pytz
5 | from bs4 import BeautifulSoup as bs
6 |
7 | from pyo365.utils import OutlookWellKnowFolderNames, ApiComponent, BaseAttachments, BaseAttachment, AttachableMixin, ImportanceLevel, TrackerSet
8 |
9 | log = logging.getLogger(__name__)
10 |
11 |
12 | class Recipient:
13 | """ A single Recipient """
14 |
15 | def __init__(self, address=None, name=None, parent=None, field=None):
16 | self._address = address or ''
17 | self._name = name or ''
18 | self._parent = parent
19 | self._field = field
20 |
21 | def __bool__(self):
22 | return bool(self.address)
23 |
24 | def __str__(self):
25 | return self.__repr__()
26 |
27 | def __repr__(self):
28 | if self.name:
29 | return '{} ({})'.format(self.name, self.address)
30 | else:
31 | return self.address
32 |
33 | def _track_changes(self):
34 | """ Update the track_changes on the parent to reflect a needed update on this field """
35 | if self._field and getattr(self._parent, '_track_changes', None) is not None:
36 | self._parent._track_changes.add(self._field)
37 |
38 | @property
39 | def address(self):
40 | return self._address
41 |
42 | @address.setter
43 | def address(self, value):
44 | self._address = value
45 | self._track_changes()
46 |
47 | @property
48 | def name(self):
49 | return self._name
50 |
51 | @name.setter
52 | def name(self, value):
53 | self._name = value
54 | self._track_changes()
55 |
56 |
57 | class Recipients:
58 | """ A Sequence of Recipients """
59 |
60 | def __init__(self, recipients=None, parent=None, field=None):
61 | """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """
62 | self._parent = parent
63 | self._field = field
64 | self._recipients = []
65 | self.untrack = True
66 | if recipients:
67 | self.add(recipients)
68 | self.untrack = False
69 |
70 | def __iter__(self):
71 | return iter(self._recipients)
72 |
73 | def __getitem__(self, key):
74 | return self._recipients[key]
75 |
76 | def __contains__(self, item):
77 | return item in {recipient.address for recipient in self._recipients}
78 |
79 | def __bool__(self):
80 | return bool(len(self._recipients))
81 |
82 | def __len__(self):
83 | return len(self._recipients)
84 |
85 | def __str__(self):
86 | return self.__repr__()
87 |
88 | def __repr__(self):
89 | return 'Recipients count: {}'.format(len(self._recipients))
90 |
91 | def _track_changes(self):
92 | """ Update the track_changes on the parent to reflect a needed update on this field """
93 | if self._field and getattr(self._parent, '_track_changes', None) is not None and self.untrack is False:
94 | self._parent._track_changes.add(self._field)
95 |
96 | def clear(self):
97 | self._recipients = []
98 | self._track_changes()
99 |
100 | def add(self, recipients):
101 | """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """
102 |
103 | if recipients:
104 | if isinstance(recipients, str):
105 | self._recipients.append(Recipient(address=recipients, parent=self._parent, field=self._field))
106 | elif isinstance(recipients, Recipient):
107 | self._recipients.append(recipients)
108 | elif isinstance(recipients, tuple):
109 | name, address = recipients
110 | if address:
111 | self._recipients.append(Recipient(address=address, name=name, parent=self._parent, field=self._field))
112 | elif isinstance(recipients, list):
113 | for recipient in recipients:
114 | self.add(recipient)
115 | else:
116 | raise ValueError('Recipients must be an address string, a'
117 | ' Recipient instance, a (name, address) tuple or a list')
118 | self._track_changes()
119 |
120 | def remove(self, address):
121 | """ Remove an address or multiple addreses """
122 | recipients = []
123 | if isinstance(address, str):
124 | address = {address} # set
125 | elif isinstance(address, (list, tuple)):
126 | address = set(address)
127 |
128 | for recipient in self._recipients:
129 | if recipient.address not in address:
130 | recipients.append(recipient)
131 | if len(recipients) != len(self._recipients):
132 | self._track_changes()
133 | self._recipients = recipients
134 |
135 | def get_first_recipient_with_address(self):
136 | """ Returns the first recipient found with a non blank address"""
137 | recipients_with_address = [recipient for recipient in self._recipients if recipient.address]
138 | if recipients_with_address:
139 | return recipients_with_address[0]
140 | else:
141 | return None
142 |
143 |
144 | class MessageAttachment(BaseAttachment):
145 |
146 | _endpoints = {
147 | 'attach': '/messages/{id}/attachments',
148 | 'attachment': '/messages/{id}/attachments/{ida}'
149 | }
150 |
151 |
152 | class MessageAttachments(BaseAttachments):
153 |
154 | _endpoints = {
155 | 'attachments': '/messages/{id}/attachments',
156 | 'attachment': '/messages/{id}/attachments/{ida}'
157 | }
158 | _attachment_constructor = MessageAttachment
159 |
160 |
161 | class HandleRecipientsMixin:
162 |
163 | def _recipients_from_cloud(self, recipients, field=None):
164 | """ Transform a recipient from cloud data to object data """
165 | recipients_data = []
166 | for recipient in recipients:
167 | recipients_data.append(self._recipient_from_cloud(recipient, field=field))
168 | return Recipients(recipients_data, parent=self, field=field)
169 |
170 | def _recipient_from_cloud(self, recipient, field=None):
171 | """ Transform a recipient from cloud data to object data """
172 |
173 | if recipient:
174 | recipient = recipient.get(self._cc('emailAddress'), recipient if isinstance(recipient, dict) else {})
175 | address = recipient.get(self._cc('address'), '')
176 | name = recipient.get(self._cc('name'), '')
177 | return Recipient(address=address, name=name, parent=self, field=field)
178 | else:
179 | return Recipient()
180 |
181 | def _recipient_to_cloud(self, recipient):
182 | """ Transforms a Recipient object to a cloud dict """
183 | data = None
184 | if recipient:
185 | data = {self._cc('emailAddress'): {self._cc('address'): recipient.address}}
186 | if recipient.name:
187 | data[self._cc('emailAddress')][self._cc('name')] = recipient.name
188 | return data
189 |
190 |
191 | class Message(ApiComponent, AttachableMixin, HandleRecipientsMixin):
192 | """ Management of the process of sending, receiving, reading, and editing emails. """
193 |
194 | _endpoints = {
195 | 'create_draft': '/messages',
196 | 'create_draft_folder': '/mailFolders/{id}/messages',
197 | 'send_mail': '/sendMail',
198 | 'send_draft': '/messages/{id}/send',
199 | 'get_message': '/messages/{id}',
200 | 'move_message': '/messages/{id}/move',
201 | 'copy_message': '/messages/{id}/copy',
202 | 'create_reply': '/messages/{id}/createReply',
203 | 'create_reply_all': '/messages/{id}/createReplyAll',
204 | 'forward_message': '/messages/{id}/createForward'
205 | }
206 |
207 | def __init__(self, *, parent=None, con=None, **kwargs):
208 | """
209 | Makes a new message wrapper for sending and receiving messages.
210 |
211 | :param parent: the parent object
212 | :param con: the id of this message if it exists
213 | """
214 | assert parent or con, 'Need a parent or a connection'
215 | self.con = parent.con if parent else con
216 |
217 | # Choose the main_resource passed in kwargs over the parent main_resource
218 | main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None
219 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource,
220 | attachment_name_property='subject', attachment_type='message_type')
221 |
222 | download_attachments = kwargs.get('download_attachments')
223 |
224 | cloud_data = kwargs.get(self._cloud_data_key, {})
225 | cc = self._cc # alias to shorten the code
226 |
227 | self._track_changes = TrackerSet(casing=cc) # internal to know which properties need to be updated on the server
228 | self.object_id = cloud_data.get(cc('id'), None)
229 |
230 | self.__created = cloud_data.get(cc('createdDateTime'), None)
231 | self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None)
232 | self.__received = cloud_data.get(cc('receivedDateTime'), None)
233 | self.__sent = cloud_data.get(cc('sentDateTime'), None)
234 |
235 | local_tz = self.protocol.timezone
236 | self.__created = parse(self.__created).astimezone(local_tz) if self.__created else None
237 | self.__modified = parse(self.__modified).astimezone(local_tz) if self.__modified else None
238 | self.__received = parse(self.__received).astimezone(local_tz) if self.__received else None
239 | self.__sent = parse(self.__sent).astimezone(local_tz) if self.__sent else None
240 |
241 | self.__attachments = MessageAttachments(parent=self, attachments=[])
242 | self.has_attachments = cloud_data.get(cc('hasAttachments'), 0)
243 | if self.has_attachments and download_attachments:
244 | self.attachments.download_attachments()
245 | self.__subject = cloud_data.get(cc('subject'), '')
246 | body = cloud_data.get(cc('body'), {})
247 | self.__body = body.get(cc('content'), '')
248 | self.body_type = body.get(cc('contentType'), 'HTML') # default to HTML for new messages
249 | self.__sender = self._recipient_from_cloud(cloud_data.get(cc('from'), None), field='from')
250 | self.__to = self._recipients_from_cloud(cloud_data.get(cc('toRecipients'), []), field='toRecipients')
251 | self.__cc = self._recipients_from_cloud(cloud_data.get(cc('ccRecipients'), []), field='ccRecipients')
252 | self.__bcc = self._recipients_from_cloud(cloud_data.get(cc('bccRecipients'), []), field='bccRecipients')
253 | self.__reply_to = self._recipients_from_cloud(cloud_data.get(cc('replyTo'), []), field='replyTo')
254 | self.__categories = cloud_data.get(cc('categories'), [])
255 | self.__importance = ImportanceLevel((cloud_data.get(cc('importance'), 'normal') or 'normal').lower()) # lower because of office365 v1.0
256 | self.__is_read = cloud_data.get(cc('isRead'), None)
257 | self.__is_draft = cloud_data.get(cc('isDraft'), kwargs.get('is_draft', True)) # a message is a draft by default
258 | self.conversation_id = cloud_data.get(cc('conversationId'), None)
259 | self.folder_id = cloud_data.get(cc('parentFolderId'), None)
260 |
261 | def _clear_tracker(self):
262 | # reset the tracked changes. Usually after a server update
263 | self._track_changes = TrackerSet(casing=self._cc)
264 |
265 | @property
266 | def is_read(self):
267 | return self.__is_read
268 |
269 | @is_read.setter
270 | def is_read(self, value):
271 | self.__is_read = value
272 | self._track_changes.add('isRead')
273 |
274 | @property
275 | def is_draft(self):
276 | return self.__is_draft
277 |
278 | @property
279 | def subject(self):
280 | return self.__subject
281 |
282 | @subject.setter
283 | def subject(self, value):
284 | self.__subject = value
285 | self._track_changes.add('subject')
286 |
287 | @property
288 | def body(self):
289 | return self.__body
290 |
291 | @body.setter
292 | def body(self, value):
293 | if self.__body:
294 | if not value:
295 | self.__body = ''
296 | else:
297 | soup = bs(self.__body, 'html.parser')
298 | soup.body.insert(0, bs(value, 'html.parser'))
299 | self.__body = str(soup)
300 | else:
301 | self.__body = value
302 | self._track_changes.add('body')
303 |
304 | @property
305 | def created(self):
306 | return self.__created
307 |
308 | @property
309 | def modified(self):
310 | return self.__modified
311 |
312 | @property
313 | def received(self):
314 | return self.__received
315 |
316 | @property
317 | def sent(self):
318 | return self.__sent
319 |
320 | @property
321 | def attachments(self):
322 | """ Just to avoid api misuse by assigning to 'attachments' """
323 | return self.__attachments
324 |
325 | @property
326 | def sender(self):
327 | """ sender is a property to force to be allways a Recipient class """
328 | return self.__sender
329 |
330 | @sender.setter
331 | def sender(self, value):
332 | """ sender is a property to force to be allways a Recipient class """
333 | if isinstance(value, Recipient):
334 | if value._parent is None:
335 | value._parent = self
336 | value._field = 'from'
337 | self.__sender = value
338 | elif isinstance(value, str):
339 | self.__sender.address = value
340 | self.__sender.name = ''
341 | else:
342 | raise ValueError('sender must be an address string or a Recipient object')
343 | self._track_changes.add('from')
344 |
345 | @property
346 | def to(self):
347 | """ Just to avoid api misuse by assigning to 'to' """
348 | return self.__to
349 |
350 | @property
351 | def cc(self):
352 | """ Just to avoid api misuse by assigning to 'cc' """
353 | return self.__cc
354 |
355 | @property
356 | def bcc(self):
357 | """ Just to avoid api misuse by assigning to 'bcc' """
358 | return self.__bcc
359 |
360 | @property
361 | def reply_to(self):
362 | """ Just to avoid api misuse by assigning to 'reply_to' """
363 | return self.__reply_to
364 |
365 | @property
366 | def categories(self):
367 | return self.__categories
368 |
369 | @categories.setter
370 | def categories(self, value):
371 | if isinstance(value, list):
372 | self.__categories = value
373 | elif isinstance(value, str):
374 | self.__categories = [value]
375 | elif isinstance(value, tuple):
376 | self.__categories = list(value)
377 | else:
378 | raise ValueError('categories must be a list')
379 | self._track_changes.add('categories')
380 |
381 | @property
382 | def importance(self):
383 | return self.__importance
384 |
385 | @importance.setter
386 | def importance(self, value):
387 | self.__importance = value if isinstance(value, ImportanceLevel) else ImportanceLevel(value.lower())
388 | self._track_changes.add('importance')
389 |
390 | def to_api_data(self, restrict_keys=None):
391 | """ Returns a dict representation of this message prepared to be send to the cloud
392 | :param restrict_keys: a set of keys to restrict the returned data to.
393 | """
394 |
395 | cc = self._cc # alias to shorten the code
396 |
397 | message = {
398 | cc('subject'): self.subject,
399 | cc('body'): {
400 | cc('contentType'): self.body_type,
401 | cc('content'): self.body},
402 | cc('importance'): self.importance.value
403 | }
404 |
405 | if self.to:
406 | message[cc('toRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.to]
407 | if self.cc:
408 | message[cc('ccRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.cc]
409 | if self.bcc:
410 | message[cc('bccRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.bcc]
411 | if self.reply_to:
412 | message[cc('replyTo')] = [self._recipient_to_cloud(recipient) for recipient in self.reply_to]
413 | if self.attachments:
414 | message[cc('attachments')] = self.attachments.to_api_data()
415 | if self.sender and self.sender.address:
416 | message[cc('from')] = self._recipient_to_cloud(self.sender)
417 |
418 | if self.object_id and not self.__is_draft:
419 | # return the whole signature of this message
420 |
421 | message[cc('id')] = self.object_id
422 | message[cc('createdDateTime')] = self.created.astimezone(pytz.utc).isoformat()
423 | message[cc('receivedDateTime')] = self.received.astimezone(pytz.utc).isoformat()
424 | message[cc('sentDateTime')] = self.sent.astimezone(pytz.utc).isoformat()
425 | message[cc('hasAttachments')] = len(self.attachments) > 0
426 | message[cc('categories')] = self.categories
427 | message[cc('isRead')] = self.is_read
428 | message[cc('isDraft')] = self.__is_draft
429 | message[cc('conversationId')] = self.conversation_id
430 | message[cc('parentFolderId')] = self.folder_id # this property does not form part of the message itself
431 |
432 | if restrict_keys:
433 | for key in list(message.keys()):
434 | if key not in restrict_keys:
435 | del message[key]
436 |
437 | return message
438 |
439 | def send(self, save_to_sent_folder=True):
440 | """ Sends this message. """
441 |
442 | if self.object_id and not self.__is_draft:
443 | return RuntimeError('Not possible to send a message that is not new or a draft. Use Reply or Forward instead.')
444 |
445 | if self.__is_draft and self.object_id:
446 | url = self.build_url(self._endpoints.get('send_draft').format(id=self.object_id))
447 | data = None
448 | else:
449 | url = self.build_url(self._endpoints.get('send_mail'))
450 | data = {self._cc('message'): self.to_api_data()}
451 | if save_to_sent_folder is False:
452 | data[self._cc('saveToSentItems')] = False
453 |
454 | response = self.con.post(url, data=data)
455 | if not response: # response evaluates to false if 4XX or 5XX status codes are returned
456 | return False
457 |
458 | self.object_id = 'sent_message' if not self.object_id else self.object_id
459 | self.__is_draft = False
460 |
461 | return True
462 |
463 | def reply(self, to_all=True):
464 | """
465 | Creates a new message that is a reply to this message.
466 | :param to_all: replies to all the recipients instead to just the sender
467 | """
468 | if not self.object_id or self.__is_draft:
469 | raise RuntimeError("Can't reply to this message")
470 |
471 | if to_all:
472 | url = self.build_url(self._endpoints.get('create_reply_all').format(id=self.object_id))
473 | else:
474 | url = self.build_url(self._endpoints.get('create_reply').format(id=self.object_id))
475 |
476 | response = self.con.post(url)
477 | if not response:
478 | return None
479 |
480 | message = response.json()
481 |
482 | # Everything received from the cloud must be passed with self._cloud_data_key
483 | return self.__class__(parent=self, **{self._cloud_data_key: message})
484 |
485 | def forward(self):
486 | """
487 | Creates a new message that is a forward of this message.
488 | """
489 | if not self.object_id or self.__is_draft:
490 | raise RuntimeError("Can't forward this message")
491 |
492 | url = self.build_url(self._endpoints.get('forward_message').format(id=self.object_id))
493 |
494 | response = self.con.post(url)
495 | if not response:
496 | return None
497 |
498 | message = response.json()
499 |
500 | # Everything received from the cloud must be passed with self._cloud_data_key
501 | return self.__class__(parent=self, **{self._cloud_data_key: message})
502 |
503 | def delete(self):
504 | """ Deletes a stored message """
505 | if self.object_id is None:
506 | raise RuntimeError('Attempting to delete an unsaved Message')
507 |
508 | url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id))
509 |
510 | response = self.con.delete(url)
511 |
512 | return bool(response)
513 |
514 | def mark_as_read(self):
515 | """ Marks this message as read in the cloud."""
516 | if self.object_id is None or self.__is_draft:
517 | raise RuntimeError('Attempting to mark as read an unsaved Message')
518 |
519 | data = {self._cc('isRead'): True}
520 |
521 | url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id))
522 |
523 | response = self.con.patch(url, data=data)
524 | if not response:
525 | return False
526 |
527 | self.__is_read = True
528 |
529 | return True
530 |
531 | def move(self, folder):
532 | """
533 | Move the message to a given folder
534 |
535 | :param folder: Folder object or Folder id or Well-known name to move this message to
536 | :returns: True on success
537 | """
538 | if self.object_id is None:
539 | raise RuntimeError('Attempting to move an unsaved Message')
540 |
541 | url = self.build_url(self._endpoints.get('move_message').format(id=self.object_id))
542 |
543 | if isinstance(folder, str):
544 | folder_id = folder
545 | else:
546 | folder_id = getattr(folder, 'folder_id', None)
547 |
548 | if not folder_id:
549 | raise RuntimeError('Must Provide a valid folder_id')
550 |
551 | data = {self._cc('destinationId'): folder_id}
552 |
553 | response = self.con.post(url, data=data)
554 | if not response:
555 | return False
556 |
557 | self.folder_id = folder_id
558 |
559 | return True
560 |
561 | def copy(self, folder):
562 | """
563 | Copy the message to a given folder
564 |
565 | :param folder: Folder object or Folder id or Well-known name to move this message to
566 | :returns: the copied message
567 | """
568 | if self.object_id is None:
569 | raise RuntimeError('Attempting to move an unsaved Message')
570 |
571 | url = self.build_url(self._endpoints.get('copy_message').format(id=self.object_id))
572 |
573 | if isinstance(folder, str):
574 | folder_id = folder
575 | else:
576 | folder_id = getattr(folder, 'folder_id', None)
577 |
578 | if not folder_id:
579 | raise RuntimeError('Must Provide a valid folder_id')
580 |
581 | data = {self._cc('destinationId'): folder_id}
582 |
583 | response = self.con.post(url, data=data)
584 | if not response:
585 | return None
586 |
587 | message = response.json()
588 |
589 | # Everything received from the cloud must be passed with self._cloud_data_key
590 | return self.__class__(parent=self, **{self._cloud_data_key: message})
591 |
592 | def save_draft(self, target_folder=OutlookWellKnowFolderNames.DRAFTS):
593 | """ Save this message as a draft on the cloud """
594 |
595 | if self.object_id:
596 | # update message. Attachments are NOT included nor saved.
597 | if not self.__is_draft:
598 | raise RuntimeError('Only draft messages can be updated')
599 | if not self._track_changes:
600 | return True # there's nothing to update
601 | url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id))
602 | method = self.con.patch
603 | data = self.to_api_data(restrict_keys=self._track_changes)
604 |
605 | data.pop(self._cc('attachments'), None) # attachments are handled by the next method call
606 | self.attachments._update_attachments_to_cloud()
607 | else:
608 | # new message. Attachments are included and saved.
609 | if not self.__is_draft:
610 | raise RuntimeError('Only draft messages can be saved as drafts')
611 |
612 | target_folder = target_folder or OutlookWellKnowFolderNames.DRAFTS
613 | if isinstance(target_folder, OutlookWellKnowFolderNames):
614 | target_folder = target_folder.value
615 | elif not isinstance(target_folder, str):
616 | # a Folder instance
617 | target_folder = getattr(target_folder, 'folder_id', OutlookWellKnowFolderNames.DRAFTS.value)
618 |
619 | url = self.build_url(self._endpoints.get('create_draft_folder').format(id=target_folder))
620 | method = self.con.post
621 | data = self.to_api_data()
622 |
623 | self._clear_tracker() # reset the tracked changes as they are all saved.
624 | if not data:
625 | return True
626 |
627 | response = method(url, data=data)
628 | if not response:
629 | return False
630 |
631 | if not self.object_id:
632 | # new message
633 | message = response.json()
634 |
635 | self.object_id = message.get(self._cc('id'), None)
636 | self.folder_id = message.get(self._cc('parentFolderId'), None)
637 |
638 | self.__created = message.get(self._cc('createdDateTime'), message.get(self._cc('dateTimeCreated'), None)) # fallback to office365 v1.0
639 | self.__modified = message.get(self._cc('lastModifiedDateTime'), message.get(self._cc('dateTimeModified'), None)) # fallback to office365 v1.0
640 |
641 | self.__created = parse(self.__created).astimezone(self.protocol.timezone) if self.__created else None
642 | self.__modified = parse(self.__modified).astimezone(self.protocol.timezone) if self.__modified else None
643 |
644 | else:
645 | self.__modified = self.protocol.timezone.localize(dt.datetime.now())
646 |
647 | return True
648 |
649 | def get_body_text(self):
650 | """ Parse the body html and returns the body text using bs4 """
651 | if self.body_type != 'HTML':
652 | return self.body
653 |
654 | try:
655 | soup = bs(self.body, 'html.parser')
656 | except Exception as e:
657 | return self.body
658 | else:
659 | return soup.body.text
660 |
661 | def get_body_soup(self):
662 | """ Returns the beautifulsoup4 of the html body"""
663 | if self.body_type != 'HTML':
664 | return None
665 | else:
666 | return bs(self.body, 'html.parser')
667 |
668 | def __str__(self):
669 | return self.__repr__()
670 |
671 | def __repr__(self):
672 | return 'Subject: {}'.format(self.subject)
673 |
--------------------------------------------------------------------------------
/pyo365/connection.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import json
3 | import os
4 | import time
5 | from pathlib import Path
6 | from tzlocal import get_localzone
7 | from datetime import tzinfo
8 | import pytz
9 |
10 | from stringcase import pascalcase, camelcase, snakecase
11 | from requests import Session
12 | from requests.adapters import HTTPAdapter
13 | from requests.packages.urllib3.util.retry import Retry # dynamic loading of module Retry by requests.packages
14 | from requests.exceptions import HTTPError, RequestException, ProxyError, SSLError, Timeout, ConnectionError
15 | from oauthlib.oauth2 import TokenExpiredError
16 | from requests_oauthlib import OAuth2Session
17 |
18 | from pyo365.utils import ME_RESOURCE, IANA_TO_WIN, WIN_TO_IANA
19 |
20 | log = logging.getLogger(__name__)
21 |
22 | O365_API_VERSION = 'v2.0' # v2.0 does not allow basic auth
23 | GRAPH_API_VERSION = 'v1.0'
24 | OAUTH_REDIRECT_URL = 'https://outlook.office365.com/owa/'
25 |
26 | RETRIES_STATUS_LIST = (429, 500, 502, 503, 504) # 429 is the TooManyRequests status code.
27 | RETRIES_BACKOFF_FACTOR = 0.5
28 |
29 |
30 | DEFAULT_SCOPES = {
31 | 'basic': [('offline_access',), 'User.Read'], # wrap any scope in a 1 element tuple to avoid prefixing
32 | 'mailbox': ['Mail.Read'],
33 | 'mailbox_shared': ['Mail.Read.Shared'],
34 | 'message_send': ['Mail.Send'],
35 | 'message_send_shared': ['Mail.Send.Shared'],
36 | 'message_all': ['Mail.ReadWrite', 'Mail.Send'],
37 | 'message_all_shared': ['Mail.ReadWrite.Shared', 'Mail.Send.Shared'],
38 | 'address_book': ['Contacts.Read'],
39 | 'address_book_shared': ['Contacts.Read.Shared'],
40 | 'address_book_all': ['Contacts.ReadWrite'],
41 | 'address_book_all_shared': ['Contacts.ReadWrite.Shared'],
42 | 'calendar': ['Calendars.ReadWrite'],
43 | 'users': ['User.ReadBasic.All'],
44 | 'onedrive': ['Files.ReadWrite.All'],
45 | 'sharepoint_dl': ['Sites.ReadWrite.All'],
46 | }
47 |
48 | DEFAULT_SCOPES_OFFICE365 = {scope: value for scope, value in DEFAULT_SCOPES.items() if scope not in {'onedrive', 'sharepoint_dl'}}
49 |
50 |
51 | class Protocol:
52 | """ Base class for all protocols """
53 |
54 | _protocol_url = 'not_defined' # Main url to request. Override in subclass
55 | _oauth_scope_prefix = '' # prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE)
56 | _oauth_scopes = {} # dictionary of {scopes_name: [scope1, scope2]}
57 |
58 | def __init__(self, *, protocol_url=None, api_version=None, default_resource=ME_RESOURCE,
59 | casing_function=None, protocol_scope_prefix=None, timezone=None, **kwargs):
60 | """
61 | :param protocol_url: the base url used to comunicate with the server
62 | :param api_version: the api version
63 | :param default_resource: the default resource to use when there's no other option
64 | :param casing_function: the casing transform function to be used on api keywords
65 | :param protocol_scope_prefix: prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE)
66 | :param timezone: prefered timezone, defaults to the system timezone
67 | """
68 | if protocol_url is None or api_version is None:
69 | raise ValueError('Must provide valid protocol_url and api_version values')
70 | self.protocol_url = protocol_url or self._protocol_url
71 | self.protocol_scope_prefix = protocol_scope_prefix or ''
72 | self.api_version = api_version
73 | self.service_url = '{}{}/'.format(protocol_url, api_version)
74 | self.default_resource = default_resource
75 | self.use_default_casing = True if casing_function is None else False # if true just returns the key without transform
76 | self.casing_function = casing_function or camelcase
77 | self.timezone = timezone or get_localzone() # pytz timezone
78 | self.max_top_value = 500 # Max $top parameter value
79 |
80 | # define any keyword that can be different in this protocol
81 | self.keyword_data_store = {}
82 |
83 | def get_service_keyword(self, keyword):
84 | """ Returns the data set to the key in the internal data-key dict """
85 | return self.keyword_data_store.get(keyword, None)
86 |
87 | def convert_case(self, dict_key):
88 | """ Returns a key converted with this protocol casing method
89 |
90 | Converts case to send/read from the cloud
91 | When using Microsoft Graph API, the keywords of the API use lowerCamelCase Casing.
92 | When using Office 365 API, the keywords of the API use PascalCase Casing.
93 |
94 | Default case in this API is lowerCamelCase.
95 |
96 | :param dict_key: a dictionary key to convert
97 | """
98 | return dict_key if self.use_default_casing else self.casing_function(dict_key)
99 |
100 | @staticmethod
101 | def to_api_case(dict_key):
102 | """ Converts keys to snake case """
103 | return snakecase(dict_key)
104 |
105 | def get_scopes_for(self, user_provided_scopes):
106 | """ Returns a list of scopes needed for each of the scope_helpers provided
107 | :param user_provided_scopes: a list of scopes or scope helpers
108 | """
109 | if user_provided_scopes is None:
110 | # return all available scopes
111 | user_provided_scopes = [app_part for app_part in self._oauth_scopes]
112 | elif isinstance(user_provided_scopes, str):
113 | user_provided_scopes = [user_provided_scopes]
114 |
115 | if not isinstance(user_provided_scopes, (list, tuple)):
116 | raise ValueError("'user_provided_scopes' must be a list or a tuple of strings")
117 |
118 | scopes = set()
119 | for app_part in user_provided_scopes:
120 | for scope in self._oauth_scopes.get(app_part, [app_part]):
121 | scopes.add(self._prefix_scope(scope))
122 |
123 | return list(scopes)
124 |
125 | def _prefix_scope(self, scope):
126 | """ Inserts the protocol scope prefix """
127 | if self.protocol_scope_prefix:
128 | if isinstance(scope, tuple):
129 | return scope[0]
130 | elif scope.startswith(self.protocol_scope_prefix):
131 | return scope
132 | else:
133 | return '{}{}'.format(self.protocol_scope_prefix, scope)
134 | else:
135 | if isinstance(scope, tuple):
136 | return scope[0]
137 | else:
138 | return scope
139 |
140 | @staticmethod
141 | def get_iana_tz(windows_tz):
142 | """ Returns a valid pytz TimeZone (Iana/Olson Timezones) from a given windows TimeZone
143 | Note: Windows Timezones are SHIT!
144 | """
145 | timezone = WIN_TO_IANA.get(windows_tz)
146 | if timezone is None:
147 | # Nope, that didn't work. Try adding "Standard Time",
148 | # it seems to work a lot of times:
149 | timezone = WIN_TO_IANA.get(windows_tz + ' Standard Time')
150 |
151 | # Return what we have.
152 | if timezone is None:
153 | raise pytz.UnknownTimeZoneError("Can't find Windows TimeZone " + windows_tz)
154 |
155 | return timezone
156 |
157 | def get_windows_tz(self, iana_tz=None):
158 | """ Returns a valid windows TimeZone from a given pytz TimeZone (Iana/Olson Timezones)
159 | Note: Windows Timezones are SHIT!... no ... really THEY ARE HOLY FUCKING SHIT!.
160 | """
161 | iana_tz = iana_tz or self.timezone
162 | timezone = IANA_TO_WIN.get(iana_tz.zone if isinstance(iana_tz, tzinfo) else iana_tz)
163 | if timezone is None:
164 | raise pytz.UnknownTimeZoneError("Can't find Iana TimeZone " + iana_tz.zone)
165 |
166 | return timezone
167 |
168 |
169 | class MSGraphProtocol(Protocol):
170 | """ A Microsoft Graph Protocol Implementation
171 | https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook
172 | """
173 |
174 | _protocol_url = 'https://graph.microsoft.com/'
175 | _oauth_scope_prefix = 'https://graph.microsoft.com/'
176 | _oauth_scopes = DEFAULT_SCOPES
177 |
178 | def __init__(self, api_version='v1.0', default_resource=ME_RESOURCE, **kwargs):
179 | super().__init__(protocol_url=self._protocol_url, api_version=api_version,
180 | default_resource=default_resource, casing_function=camelcase,
181 | protocol_scope_prefix=self._oauth_scope_prefix, **kwargs)
182 |
183 | self.keyword_data_store['message_type'] = 'microsoft.graph.message'
184 | self.keyword_data_store['file_attachment_type'] = '#microsoft.graph.fileAttachment'
185 | self.keyword_data_store['item_attachment_type'] = '#microsoft.graph.itemAttachment'
186 | self.max_top_value = 999 # Max $top parameter value
187 |
188 |
189 | class MSOffice365Protocol(Protocol):
190 | """ A Microsoft Office 365 Protocol Implementation
191 | https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook
192 | """
193 |
194 | _protocol_url = 'https://outlook.office.com/api/'
195 | _oauth_scope_prefix = 'https://outlook.office.com/'
196 | _oauth_scopes = DEFAULT_SCOPES_OFFICE365
197 |
198 | def __init__(self, api_version='v2.0', default_resource=ME_RESOURCE, **kwargs):
199 | super().__init__(protocol_url=self._protocol_url, api_version=api_version,
200 | default_resource=default_resource, casing_function=pascalcase,
201 | protocol_scope_prefix=self._oauth_scope_prefix, **kwargs)
202 |
203 | self.keyword_data_store['message_type'] = 'Microsoft.OutlookServices.Message'
204 | self.keyword_data_store['file_attachment_type'] = '#Microsoft.OutlookServices.FileAttachment'
205 | self.keyword_data_store['item_attachment_type'] = '#Microsoft.OutlookServices.ItemAttachment'
206 | self.max_top_value = 999 # Max $top parameter value
207 |
208 |
209 | class Connection:
210 | """ Handles all comunication (requests) between the app and the server """
211 |
212 | _oauth2_authorize_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
213 | _oauth2_token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
214 | _default_token_file = 'o365_token.txt'
215 | _default_token_path = Path() / _default_token_file
216 | _allowed_methods = ['get', 'post', 'put', 'patch', 'delete']
217 |
218 | def __init__(self, credentials, *, scopes=None,
219 | proxy_server=None, proxy_port=8080, proxy_username=None, proxy_password=None,
220 | requests_delay=200, raise_http_errors=True, request_retries=3, token_file_name=None):
221 | """ Creates an API connection object
222 |
223 | :param credentials: a tuple containing the credentials for this connection.
224 | This could be either (username, password) using basic authentication or (client_id, client_secret) using oauth.
225 | Generate client_id and client_secret in https://apps.dev.microsoft.com.
226 | :param scopes: oauth2: a list of scopes permissions to request access to
227 | :param proxy_server: the proxy server
228 | :param proxy_port: the proxy port, defaults to 8080
229 | :param proxy_username: the proxy username
230 | :param proxy_password: the proxy password
231 | :param requests_delay: number of miliseconds to wait between api calls
232 | The Api will respond with 429 Too many requests if more than 17 requests are made per second.
233 | Defaults to 200 miliseconds just in case more than 1 connection is making requests across multiple processes.
234 | :param raise_http_errors: If True Http 4xx and 5xx status codes will raise as exceptions
235 | :param request_retries: number of retries done when the server responds with 5xx error codes.
236 | :param token_file_name: custom token file name to be used when storing the token credentials.
237 | """
238 | if not isinstance(credentials, tuple) or len(credentials) != 2 or (not credentials[0] and not credentials[1]):
239 | raise ValueError('Provide valid auth credentials')
240 |
241 | self.auth = credentials
242 | self.scopes = scopes
243 | self.store_token = True
244 | self.token_path = (Path() / token_file_name) if token_file_name else self._default_token_path
245 | self.token = None
246 |
247 | self.session = None # requests Oauth2Session object
248 |
249 | self.proxy = {}
250 | self.set_proxy(proxy_server, proxy_port, proxy_username, proxy_password)
251 | self.requests_delay = requests_delay or 0
252 | self.previous_request_at = None # store the time of the previous request
253 | self.raise_http_errors = raise_http_errors
254 | self.request_retries = request_retries
255 |
256 | self.naive_session = Session() # requests Session object
257 | self.naive_session.proxies = self.proxy
258 |
259 | if self.request_retries:
260 | retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries,
261 | backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST)
262 | adapter = HTTPAdapter(max_retries=retry)
263 | self.naive_session.mount('http://', adapter)
264 | self.naive_session.mount('https://', adapter)
265 |
266 | def set_proxy(self, proxy_server, proxy_port, proxy_username, proxy_password):
267 | """ Sets a proxy on the Session """
268 | if proxy_server and proxy_port:
269 | if proxy_username and proxy_password:
270 | self.proxy = {
271 | "http": "http://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port),
272 | "https": "https://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port),
273 | }
274 | else:
275 | self.proxy = {
276 | "http": "http://{}:{}".format(proxy_server, proxy_port),
277 | "https": "https://{}:{}".format(proxy_server, proxy_port),
278 | }
279 |
280 | def check_token_file(self):
281 | """ Checks if the token file exists at the given position"""
282 | if self.token_path:
283 | path = Path(self.token_path)
284 | else:
285 | path = self._default_token_path
286 |
287 | return path.exists()
288 |
289 | def get_authorization_url(self, requested_scopes=None, redirect_uri=OAUTH_REDIRECT_URL):
290 | """
291 | Inicialices the oauth authorization flow, getting the authorization url that the user must approve.
292 | This is a two step process, first call this function. Then get the url result from the user and then
293 | call 'request_token' to get and store the access token.
294 | """
295 |
296 | client_id, client_secret = self.auth
297 |
298 | if requested_scopes:
299 | scopes = requested_scopes
300 | elif self.scopes is not None:
301 | scopes = self.scopes
302 | else:
303 | raise ValueError('Must provide at least one scope')
304 |
305 | self.session = oauth = OAuth2Session(client_id=client_id, redirect_uri=redirect_uri, scope=scopes)
306 | self.session.proxies = self.proxy
307 | if self.request_retries:
308 | retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries,
309 | backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST)
310 | adapter = HTTPAdapter(max_retries=retry)
311 | self.session.mount('http://', adapter)
312 | self.session.mount('https://', adapter)
313 |
314 | # TODO: access_type='offline' has no effect acording to documentation. This is done through scope 'offline_access'.
315 | auth_url, state = oauth.authorization_url(url=self._oauth2_authorize_url, access_type='offline')
316 |
317 | return auth_url
318 |
319 | def request_token(self, authorizated_url, store_token=True, token_path=None):
320 | """
321 | Returns and saves the token with the authorizated_url provided by the user
322 |
323 | :param authorizated_url: url given by the authorization flow
324 | :param store_token: whether or not to store the token in file system,
325 | so u don't have to keep opening the auth link and authenticating every time
326 | :param token_path: full path to where the token should be saved to
327 | """
328 |
329 | if self.session is None:
330 | raise RuntimeError("Fist call 'get_authorization_url' to generate a valid oauth object")
331 |
332 | client_id, client_secret = self.auth
333 |
334 | # Allow token scope to not match requested scope. (Other auth libraries allow
335 | # this, but Requests-OAuthlib raises exception on scope mismatch by default.)
336 | os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1'
337 | os.environ['OAUTHLIB_IGNORE_SCOPE_CHANGE'] = '1'
338 |
339 | try:
340 | self.token = self.session.fetch_token(token_url=self._oauth2_token_url,
341 | authorization_response=authorizated_url,
342 | client_id=client_id,
343 | client_secret=client_secret)
344 | except Exception as e:
345 | log.error('Unable to fetch auth token. Error: {}'.format(str(e)))
346 | return None
347 |
348 | if token_path:
349 | self.token_path = token_path
350 | self.store_token = store_token
351 | if self.store_token:
352 | self._save_token(self.token, self.token_path)
353 |
354 | return True
355 |
356 | def get_session(self, token_path=None):
357 | """ Create a requests Session object
358 |
359 | :param token_path: Only oauth: full path to where the token should be load from
360 | """
361 | self.token = self.token or self._load_token(token_path or self.token_path)
362 |
363 | if self.token:
364 | client_id, _ = self.auth
365 | self.session = OAuth2Session(client_id=client_id, token=self.token)
366 | else:
367 | raise RuntimeError('No auth token found. Authentication Flow needed')
368 |
369 | self.session.proxies = self.proxy
370 |
371 | if self.request_retries:
372 | retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries,
373 | backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST)
374 | adapter = HTTPAdapter(max_retries=retry)
375 | self.session.mount('http://', adapter)
376 | self.session.mount('https://', adapter)
377 |
378 | return self.session
379 |
380 | def refresh_token(self):
381 | """ Gets another token """
382 |
383 | client_id, client_secret = self.auth
384 | self.token = token = self.session.refresh_token(self._oauth2_token_url, client_id=client_id,
385 | client_secret=client_secret)
386 | if self.store_token:
387 | self._save_token(token)
388 |
389 | def _check_delay(self):
390 | """ Checks if a delay is needed between requests and sleeps if True """
391 | if self.previous_request_at:
392 | dif = round(time.time() - self.previous_request_at, 2) * 1000 # difference in miliseconds
393 | if dif < self.requests_delay:
394 | time.sleep((self.requests_delay - dif) / 1000) # sleep needs seconds
395 | self.previous_request_at = time.time()
396 |
397 | def _internal_request(self, request_obj, url, method, **kwargs):
398 | """
399 | Internal handling of requests. Handles Exceptions.
400 |
401 | :param request_obj: a requests session.
402 | :param url: the url to be requested
403 | :param method: the method used on the request
404 | :param kwargs: any other payload to be passed to requests
405 | """
406 |
407 | method = method.lower()
408 | assert method in self._allowed_methods, 'Method must be one of the allowed ones'
409 |
410 | if method == 'get':
411 | kwargs.setdefault('allow_redirects', True)
412 | elif method in ['post', 'put', 'patch']:
413 | if 'headers' not in kwargs:
414 | kwargs['headers'] = {}
415 | if kwargs.get('headers') is not None and kwargs['headers'].get('Content-type') is None:
416 | kwargs['headers']['Content-type'] = 'application/json'
417 | if 'data' in kwargs and kwargs['headers'].get('Content-type') == 'application/json':
418 | kwargs['data'] = json.dumps(kwargs['data']) # autoconvert to json
419 |
420 | request_done = False
421 | token_refreshed = False
422 |
423 | while not request_done:
424 | self._check_delay() # sleeps if needed
425 | try:
426 | log.info('Requesting ({}) URL: {}'.format(method.upper(), url))
427 | log.info('Request parameters: {}'.format(kwargs))
428 | response = request_obj.request(method, url, **kwargs) # auto_retry will occur inside this funcion call if enabled
429 | response.raise_for_status() # raise 4XX and 5XX error codes.
430 | log.info('Received response ({}) from URL {}'.format(response.status_code, response.url))
431 | request_done = True
432 | return response
433 | except TokenExpiredError:
434 | # Token has expired refresh token and try again on the next loop
435 | if token_refreshed:
436 | # Refresh token done but still TolenExpiredError raise
437 | raise RuntimeError('Token Refresh Operation not working')
438 | log.info('Oauth Token is expired, fetching a new token')
439 | self.refresh_token()
440 | log.info('New oauth token fetched')
441 | token_refreshed = True
442 | except (ConnectionError, ProxyError, SSLError, Timeout) as e:
443 | # We couldn't connect to the target url, raise error
444 | log.debug('Connection Error calling: {}.{}'.format(url, 'Using proxy: {}'.format(self.proxy) if self.proxy else ''))
445 | raise e # re-raise exception
446 | except HTTPError as e:
447 | # Server response with 4XX or 5XX error status codes
448 | status_code = int(e.response.status_code / 100)
449 | if status_code == 4:
450 | # Client Error
451 | log.error('Client Error: {}'.format(str(e))) # logged as error. Could be a library error or Api changes
452 | else:
453 | # Server Error
454 | log.debug('Server Error: {}'.format(str(e)))
455 | if self.raise_http_errors:
456 | raise e
457 | else:
458 | return e.response
459 | except RequestException as e:
460 | # catch any other exception raised by requests
461 | log.debug('Request Exception: {}'.format(str(e)))
462 | raise e
463 |
464 | def naive_request(self, url, method, **kwargs):
465 | """ A naive request without any Authorization headers """
466 | return self._internal_request(self.naive_session, url, method, **kwargs)
467 |
468 | def oauth_request(self, url, method, **kwargs):
469 | """ Makes a request to url using an oauth session """
470 |
471 | # oauth authentication
472 | if not self.session:
473 | self.get_session()
474 |
475 | return self._internal_request(self.session, url, method, **kwargs)
476 |
477 | def get(self, url, params=None, **kwargs):
478 | """ Shorthand for self.request(url, 'get') """
479 | return self.oauth_request(url, 'get', params=params, **kwargs)
480 |
481 | def post(self, url, data=None, **kwargs):
482 | """ Shorthand for self.request(url, 'post') """
483 | return self.oauth_request(url, 'post', data=data, **kwargs)
484 |
485 | def put(self, url, data=None, **kwargs):
486 | """ Shorthand for self.request(url, 'put') """
487 | return self.oauth_request(url, 'put', data=data, **kwargs)
488 |
489 | def patch(self, url, data=None, **kwargs):
490 | """ Shorthand for self.request(url, 'patch') """
491 | return self.oauth_request(url, 'patch', data=data, **kwargs)
492 |
493 | def delete(self, url, **kwargs):
494 | """ Shorthand for self.request(url, 'delete') """
495 | return self.oauth_request(url, 'delete', **kwargs)
496 |
497 | def _save_token(self, token, token_path=None):
498 | """ Save the specified token dictionary to a specified file path
499 |
500 | :param token: token dictionary returned by the oauth token request
501 | :param token_path: Path object to where the file is to be saved
502 | """
503 |
504 | if not token_path:
505 | token_path = self.token_path or self._default_token_path
506 | else:
507 | if not isinstance(token_path, Path):
508 | raise ValueError('token_path must be a valid Path from pathlib')
509 |
510 | with token_path.open('w') as token_file:
511 | json.dump(token, token_file, indent=True)
512 |
513 | return True
514 |
515 | def _load_token(self, token_path=None):
516 | """ Load the specified token dictionary from specified file path
517 |
518 | :param token_path: Path object to the file with token information saved
519 | """
520 |
521 | if not token_path:
522 | token_path = self.token_path or self._default_token_path
523 | else:
524 | if not isinstance(token_path, Path):
525 | raise ValueError('token_path must be a valid Path from pathlib')
526 |
527 | token = None
528 | if token_path.exists():
529 | with token_path.open('r') as token_file:
530 | token = json.load(token_file)
531 | return token
532 |
533 | def _delete_token(self, token_path=None):
534 | """ Delete the specified token dictionary from specified file path
535 |
536 | :param token_path: Path object to where the token is saved
537 | """
538 |
539 | if not token_path:
540 | token_path = self.token_path or self._default_token_path
541 | else:
542 | if not isinstance(token_path, Path):
543 | raise ValueError('token_path must be a valid Path from pathlib')
544 |
545 | if token_path.exists():
546 | token_path.unlink()
547 | return True
548 | return False
549 |
550 |
551 | def oauth_authentication_flow(client_id, client_secret, scopes=None, protocol=None, **kwargs):
552 | """
553 | A helper method to authenticate and get the oauth token
554 | :param client_id: the client_id
555 | :param client_secret: the client_secret
556 | :param scopes: a list of protocol user scopes to be converted by the protocol
557 | :param protocol: the protocol to be used. Defaults to MSGraphProtocol
558 | :param kwargs: other configuration to be passed to the Connection instance
559 | """
560 |
561 | credentials = (client_id, client_secret)
562 |
563 | protocol = protocol or MSGraphProtocol
564 |
565 | if not isinstance(protocol, Protocol):
566 | protocol = protocol()
567 |
568 | con = Connection(credentials, scopes=protocol.get_scopes_for(scopes), **kwargs)
569 |
570 | consent_url = con.get_authorization_url()
571 | print('Visit the following url to give consent:')
572 | print(consent_url)
573 |
574 | token_url = input('Paste the authenticated url here: ')
575 |
576 | if token_url:
577 | result = con.request_token(token_url)
578 | if result:
579 | print('Authentication Flow Completed. Oauth Access Token Stored. You can now use the API.')
580 | else:
581 | print('Something go wrong. Please try again.')
582 |
583 | return bool(result)
584 | else:
585 | print('Authentication Flow aborted.')
586 | return False
587 |
588 |
589 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # This project has been merged into the original one. Check [O365](https://github.com/O365/python-o365) repository.
2 |
3 | ## This fork is no longer maintained. Bugs, and improvements will occurr in [O365](https://github.com/O365/python-o365).
4 |
5 |
6 |
7 |
8 | ___
9 |
10 |
11 |
12 | # pyo365 - Microsoft Graph and Office 365 API made easy
13 | This project aims is to make it easy to interact with Microsoft Graph and Office 365 Email, Contacts, Calendar, OneDrive, etc.
14 |
15 | This project is inspired on the super work done by [Toben Archer](https://github.com/Narcolapser) [Python-O365](https://github.com/Narcolapser/python-o365).
16 | The oauth part is based on the work done by [Royce Melborn](https://github.com/roycem90) which is now integrated with the original project.
17 |
18 | I just want to make this project different in almost every sense, and make it also more pythonic.
19 | So I ended up rewriting the whole project from scratch.
20 |
21 | The result is a package that provides a lot of the Microsoft Graph and Office 365 API capabilities.
22 |
23 | This is for example how you send a message:
24 |
25 | ```python
26 | from pyo365 import Account
27 |
28 | credentials = ('client_id', 'client_secret')
29 |
30 | account = Account(credentials)
31 | m = account.new_message()
32 | m.to.add('to_example@example.com')
33 | m.subject = 'Testing!'
34 | m.body = "George Best quote: I've stopped drinking, but only while I'm asleep."
35 | m.send()
36 | ```
37 |
38 |
39 | **Python 3.4 is the minimum required**... I was very tempted to just go for 3.6 and use f-strings. Those are fantastic!
40 |
41 | This project was also a learning resource for me. This is a list of not so common python characteristics used in this project:
42 | - New unpacking technics: `def method(argument, *, with_name=None, **other_params):`
43 | - Enums: `from enum import Enum`
44 | - Factory paradigm
45 | - Package organization
46 | - Timezone conversion and timezone aware datetimes
47 | - Etc. (see the code!)
48 |
49 | > **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.
50 |
51 |
52 | What follows is kind of a wiki... but you will get more insights by looking at the code.
53 |
54 | ## Table of contents
55 |
56 | - [Install](#install)
57 | - [Protocols](#protocols)
58 | - [Authentication](#authentication)
59 | - [Account Class and Modularity](#account)
60 | - [MailBox](#mailbox)
61 | - [AddressBook](#addressbook)
62 | - [Calendar](#calendar)
63 | - [OneDrive](#onedrive)
64 | - [Sharepoint](#sharepoint)
65 | - [Utils](#utils)
66 |
67 |
68 | ## Install
69 | pyo365 is available on pypi.org. Simply run `pip install pyo365` to install it.
70 |
71 | Project dependencies installed by pip:
72 | - requests
73 | - requests-oauthlib
74 | - beatifulsoup4
75 | - stringcase
76 | - python-dateutil
77 | - tzlocal
78 | - pytz
79 |
80 | The first step to be able to work with this library is to register an application and retrieve the auth token. See [Authentication](#authentication).
81 |
82 | ## Protocols
83 | Protocols handles the aspects of comunications between different APIs.
84 | This project uses by default either the Office 365 APIs or Microsoft Graph APIs.
85 | But, you can use many other Microsoft APIs as long as you implement the protocol needed.
86 |
87 | You can use one or the other:
88 |
89 | - `MSGraphProtocol` to use the [Microsoft Graph API](https://developer.microsoft.com/en-us/graph/docs/concepts/overview)
90 | - `MSOffice365Protocol` to use the [Office 365 API](https://msdn.microsoft.com/en-us/office/office365/api/api-catalog)
91 |
92 | Both protocols are similar but consider the following:
93 |
94 | Reasons to use `MSGraphProtocol`:
95 | - It is the recommended Protocol by Microsoft.
96 | - It can access more resources over Office 365 (for example OneDrive)
97 |
98 | Reasons to use `MSOffice365Protocol`:
99 | - It can send emails with attachments up to 150 MB. MSGraph only allows 4MB on each request.
100 |
101 | The default protocol used by the `Account` Class is `MSGraphProtocol`.
102 |
103 | You can implement your own protocols by inheriting from `Protocol` to communicate with other Microsoft APIs.
104 |
105 | You can instantiate protocols like this:
106 | ```python
107 | from pyo365 import MSGraphProtocol
108 |
109 | # try the api version beta of the Microsoft Graph endpoint.
110 | protocol = MSGraphProtocol(api_version='beta') # MSGraphProtocol defaults to v1.0 api version
111 | ```
112 |
113 | ##### Resources:
114 | Each API endpoint requires a resource. This usually defines the owner of the data.
115 | 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.
116 |
117 | For example when accesing a shared mailbox:
118 |
119 |
120 | ```python
121 | # ...
122 | account = Account(credentials=my_credentials, main_resource='shared_mailbox@example.com')
123 | # Any instance created using account will inherit the resource defined for account.
124 | ```
125 |
126 | This can be done however at any point. For example at the protocol level:
127 | ```python
128 | # ...
129 | my_protocol = MSGraphProtocol(default_resource='shared_mailbox@example.com')
130 |
131 | account = Account(credentials=my_credentials, protocol=my_protocol)
132 |
133 | # now account is accesing the shared_mailbox@example.com in every api call.
134 | shared_mailbox_messages = account.mailbox().get_messages()
135 | ```
136 |
137 |
138 |
139 | Instead of defining the resource used at the account or protocol level, you can provide it per use case as follows:
140 | ```python
141 | # ...
142 | account = Account(credentials=my_credentials) # account defaults to 'ME' resource
143 |
144 | mailbox = account.mailbox('shared_mailbox@example.com') # mailbox is using 'shared_mailbox@example.com' resource instead of 'ME'
145 |
146 | # or:
147 |
148 | message = Message(parent=account, main_resource='shared_mailbox@example.com') # message is using 'shared_mailbox@example.com' resource
149 | ```
150 |
151 | Usually you will work with the default 'ME' resuorce, but you can also use one of the following:
152 |
153 | - **'me'**: the user which has given consent. the default for every protocol.
154 | - **'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.
155 | - **'sharepoint:sharepoint-site-id'**: a sharepoint site id.
156 | - **'group:group-site-id'**: a office365 group id.
157 |
158 | ## Authentication
159 | You can only authenticate using oauth athentication as Microsoft deprecated basic oauth on November 1st 2018.
160 |
161 | - Oauth authentication: using an authentication token provided after user consent.
162 |
163 | The `Connection` Class handles the authentication.
164 |
165 | #### Oauth Authentication
166 | This section is explained using Microsoft Graph Protocol, almost the same applies to the Office 365 REST API.
167 |
168 |
169 | ##### Permissions and Scopes:
170 | When using oauth you create an application and allow some resources to be accesed and used by it's users.
171 | Then the user can request access to one or more of this resources by providing scopes to the oauth provider.
172 |
173 | 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.
174 | This is done by providing scopes to the connection object like so:
175 | ```python
176 | from pyo365 import Connection
177 |
178 | credentials = ('client_id', 'client_secret')
179 |
180 | scopes = ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send']
181 |
182 | con = Connection(credentials, scopes=scopes)
183 | ```
184 |
185 | Scope implementation depends on the protocol used. So by using protocol data you can automatically set the scopes needed:
186 |
187 | You can get the same scopes as before using protocols like this:
188 |
189 | ```python
190 | protocol_graph = MSGraphProtocol()
191 |
192 | scopes_graph = protocol.get_scopes_for('message all')
193 | # scopes here are: ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send']
194 |
195 | protocol_office = MSOffice365Protocol()
196 |
197 | scopes_office = protocol.get_scopes_for('message all')
198 | # scopes here are: ['https://outlook.office.com/Mail.ReadWrite', 'https://outlook.office.com/Mail.Send']
199 |
200 | con = Connection(credentials, scopes=scopes_graph)
201 | ```
202 |
203 |
204 | ##### Authentication Flow
205 | 1. To work with oauth you first need to register your application at [Microsoft Application Registration Portal](https://apps.dev.microsoft.com/).
206 |
207 | 1. Login at [Microsoft Application Registration Portal](https://apps.dev.microsoft.com/)
208 | 2. Create an app, note your app id (client_id)
209 | 3. Generate a new password (client_secret) under "Application Secrets" section
210 | 4. Under the "Platform" section, add a new Web platform and set "https://outlook.office365.com/owa/" as the redirect URL
211 | 5. Under "Microsoft Graph Permissions" section, add the delegated permissions you want (see scopes), as an example, to read and send emails use:
212 | 1. Mail.ReadWrite
213 | 2. Mail.Send
214 | 3. User.Read
215 |
216 | 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.
217 | 1. First get the authorization url.
218 | ```python
219 | url = account.connection.get_authorization_url()
220 | ```
221 | 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/".
222 |
223 | Then the user must copy the resulting page url and give it to the connection object:
224 |
225 | ```python
226 | result_url = input('Paste the result url here...')
227 |
228 | account.connection.request_token(result_url) # This, if succesful, will store the token in a txt file on the user project folder.
229 | ```
230 |
231 | Take care, the access token must remain protected from unauthorized users.
232 |
233 | 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.
234 |
235 | 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).
236 |
237 | 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.
238 |
239 |
240 | ##### Using pyo365 to authenticate
241 |
242 | You can manually authenticate by using a single `Connection` instance as described before or use the helper methods provided by the library.
243 |
244 | 1. `account.authenticate`:
245 |
246 | This is the preferred way for performing authentication.
247 |
248 | Create an `Account` instance and authenticate using the `authenticate` method:
249 | ```python
250 | from pyo365 import Account
251 |
252 | account = Account(credentials=('client_id', 'client_secret'))
253 | result = account.authenticate(scopes=['basic', 'message_all']) # request a token for this scopes
254 |
255 | # this will ask to visit the app consent screen where the user will be asked to give consent on the requested scopes.
256 | # then the user will have to provide the result url afeter consent.
257 | # if all goes as expected, result will be True and a token will be stored in the default location.
258 | ```
259 |
260 | 2. `oauth_authentication_flow`:
261 |
262 | ```python
263 | from pyo365 import oauth_authentication_flow
264 |
265 | result = oauth_authentication_flow('client_id', 'client_secret', ['scopes_required'])
266 | ```
267 |
268 | ## Account Class and Modularity
269 | Usually you will only need to work with the `Account` Class. This is a wrapper around all functionality.
270 |
271 | But you can also work only with the pieces you want.
272 |
273 | For example, instead of:
274 | ```python
275 | from pyo365 import Account
276 |
277 | account = Account(('client_id', 'client_secret'))
278 | message = account.new_message()
279 | # ...
280 | mailbox = account.mailbox()
281 | # ...
282 | ```
283 |
284 | You can work only with the required pieces:
285 |
286 | ```python
287 | from pyo365 import Connection, MSGraphProtocol, Message, MailBox
288 |
289 | my_protocol = MSGraphProtocol()
290 | con = Connection(('client_id', 'client_secret'))
291 |
292 | message = Message(con=con, protocol=my_protocol)
293 | # ...
294 | mailbox = MailBox(con=con, protocol=my_protocol)
295 | message2 = Message(parent=mailbox) # message will inherit the connection and protocol from mailbox when using parent.
296 | # ...
297 | ```
298 |
299 | It's also easy to implement a custom Class.
300 |
301 | 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.
302 |
303 | ```python
304 | from pyo365.utils import ApiComponent
305 |
306 | class CustomClass(ApiComponent):
307 | _endpoints = {'my_url_key': '/customendpoint'}
308 |
309 | def __init__(self, *, parent=None, con=None, **kwargs):
310 | super().__init__(parent=parent, con=con, **kwargs)
311 | # ...
312 |
313 | def do_some_stuff(self):
314 |
315 | # self.build_url just merges the protocol service_url with the enpoint passed as a parameter
316 | # to change the service_url implement your own protocol inherinting from Protocol Class
317 | url = self.build_url(self._endpoints.get('my_url_key'))
318 |
319 | my_params = {'param1': 'param1'}
320 |
321 | response = self.con.get(url, params=my_params) # note the use of the connection here.
322 |
323 | # handle response and return to the user...
324 | ```
325 |
326 | ## MailBox
327 | Mailbox groups the funcionality of both the messages and the email folders.
328 |
329 | ```python
330 | mailbox = account.mailbox()
331 |
332 | inbox = mailbox.inbox_folder()
333 |
334 | for message in inbox.get_messages():
335 | print(message)
336 |
337 | sent_folder = mailbox.sent_folder()
338 |
339 | for message in sent_folder.get_messages():
340 | print(message)
341 |
342 | m = mailbox.new_message()
343 |
344 | m.to.add('to_example@example.com')
345 | m.body = 'George Best quote: In 1969 I gave up women and alcohol - it was the worst 20 minutes of my life.'
346 | m.save_draft()
347 | ```
348 |
349 | #### Email Folder
350 | Represents a `Folder` within your email mailbox.
351 |
352 | You can get any folder in your mailbox by requesting child folders or filtering by name.
353 |
354 | ```python
355 | mailbox = account.mailbox()
356 |
357 | archive = mailbox.get_folder(folder_name='archive') # get a folder with 'archive' name
358 |
359 | child_folders = archive.get_folders(25) # get at most 25 child folders of 'archive' folder
360 |
361 | for folder in child_folders:
362 | print(folder.name, folder.parent_id)
363 |
364 | new_folder = archive.create_child_folder('George Best Quotes')
365 | ```
366 |
367 | #### Message
368 | An email object with all it's data and methods.
369 |
370 | Creating a draft message is as easy as this:
371 | ```python
372 | message = mailbox.new_message()
373 | message.to.add(['example1@example.com', 'example2@example.com'])
374 | message.sender.address = 'my_shared_account@example.com' # changing the from address
375 | message.body = 'George Best quote: I might go to Alcoholics Anonymous, but I think it would be difficult for me to remain anonymous'
376 | message.attachments.add('george_best_quotes.txt')
377 | message.save_draft() # save the message on the cloud as a draft in the drafts folder
378 | ```
379 |
380 | Working with saved emails is also easy:
381 | ```python
382 | query = mailbox.new_query().on_attribute('subject').contains('george best') # see Query object in Utils
383 | messages = mailbox.get_messages(limit=25, query=query)
384 |
385 | message = messages[0] # get the first one
386 |
387 | message.mark_as_read()
388 | reply_msg = message.reply()
389 |
390 | if 'example@example.com' in reply_msg.to: # magic methods implemented
391 | reply_msg.body = 'George Best quote: I spent a lot of money on booze, birds and fast cars. The rest I just squandered.'
392 | else:
393 | reply_msg.body = 'George Best quote: I used to go missing a lot... Miss Canada, Miss United Kingdom, Miss World.'
394 |
395 | reply_msg.send()
396 | ```
397 |
398 | ## AddressBook
399 | AddressBook groups the funcionality of both the Contact Folders and Contacts. Outlook Distribution Groups are not supported (By the Microsoft API's).
400 |
401 | #### Contact Folders
402 | Represents a Folder within your Contacts Section in Office 365.
403 | AddressBook class represents the parent folder (it's a folder itself).
404 |
405 | You can get any folder in your address book by requesting child folders or filtering by name.
406 |
407 | ```python
408 | address_book = account.address_book()
409 |
410 | contacts = address_book.get_contacts(limit=None) # get all the contacts in the Personal Contacts root folder
411 |
412 | work_contacts_folder = address_book.get_folder(folder_name='Work Contacts') # get a folder with 'Work Contacts' name
413 |
414 | message_to_all_contats_in_folder = work_contacts_folder.new_message() # creates a draft message with all the contacts as recipients
415 |
416 | message_to_all_contats_in_folder.subject = 'Hallo!'
417 | message_to_all_contats_in_folder.body = """
418 | George Best quote:
419 |
420 | If you'd given me the choice of going out and beating four men and smashing a goal in
421 | from thirty yards against Liverpool or going to bed with Miss World,
422 | it would have been a difficult choice. Luckily, I had both.
423 | """
424 | message_to_all_contats_in_folder.send()
425 |
426 | # querying folders is easy:
427 | child_folders = address_book.get_folders(25) # get at most 25 child folders
428 |
429 | for folder in child_folders:
430 | print(folder.name, folder.parent_id)
431 |
432 | # creating a contact folder:
433 | address_book.create_child_folder('new folder')
434 | ```
435 |
436 | #### The Global Address List
437 | Office 365 API (Nor MS Graph API) has no concept such as the Outlook Global Address List.
438 | However you can use the [Users API](https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/users) to access all the users within your organization.
439 |
440 | Without admin consent you can only access a few properties of each user such as name and email and litte more.
441 | You can search by name or retrieve a contact specifying the complete email.
442 |
443 | - Basic Permision needed is Users.ReadBasic.All (limit info)
444 | - Full Permision is Users.Read.All but needs admin consent.
445 |
446 | To search the Global Address List (Users API):
447 |
448 | ```python
449 | global_address_list = account.address_book(address_book='gal')
450 |
451 | # start a new query:
452 | q = global_address_list.new_query('display_name')
453 | q.startswith('George Best')
454 |
455 | print(global_address_list.get_contacts(query=q))
456 | ```
457 |
458 |
459 | To retrieve a contact by it's email:
460 |
461 | ```python
462 | contact = global_address_list.get_contact_by_email('example@example.com')
463 | ```
464 |
465 | #### Contacts
466 | Everything returned from an `AddressBook` instance is a `Contact` instance.
467 | Contacts have all the information stored as attributes
468 |
469 | Creating a contact from an `AddressBook`:
470 |
471 | ```python
472 | new_contact = address_book.new_contact()
473 |
474 | new_contact.name = 'George Best'
475 | new_contact.job_title = 'football player'
476 | new_contact.emails.add('george@best.com')
477 |
478 | new_contact.save() # saved on the cloud
479 |
480 | message = new_contact.new_message() # Bonus: send a message to this contact
481 |
482 | # ...
483 |
484 | new_contact.delete() # Bonus: deteled from the cloud
485 | ```
486 |
487 |
488 | ## Calendar
489 | The calendar and events functionality is group in a `Schedule` object.
490 |
491 | A `Schedule` instance can list and create calendars. It can also list or create events on the default user calendar.
492 | To use other calendars use a `Calendar` instance.
493 |
494 | Working with the `Schedule` instance:
495 | ```python
496 | import datetime as dt
497 |
498 | # ...
499 | schedule = account.schedule()
500 |
501 | new_event = schedule.new_event() # creates a new event in the user default calendar
502 | new_event.subject = 'Recruit George Best!'
503 | new_event.location = 'England'
504 |
505 | # naive datetimes will automatically be converted to timezone aware datetime
506 | # objects using the local timezone detected or the protocol provided timezone
507 |
508 | new_event.start = dt.datetime(2018, 9, 5, 19, 45)
509 | # so new_event.start becomes: datetime.datetime(2018, 9, 5, 19, 45, tzinfo=)
510 |
511 | new_event.recurrence.set_daily(1, end=dt.datetime(2018, 9, 10))
512 | new_event.remind_before_minutes = 45
513 |
514 | new_event.save()
515 | ```
516 |
517 | Working with `Calendar` instances:
518 | ```python
519 | calendar = schedule.get_calendar(calendar_name='Birthdays')
520 |
521 | calendar.name = 'Football players birthdays'
522 | calendar.update()
523 |
524 | q = calendar.new_query('start').ge(dt.datetime(2018, 5, 20)).chain('and').on_attribute('end').le(dt.datetime(2018, 5, 24))
525 |
526 | birthdays = calendar.get_events(query=q)
527 |
528 | for event in birthdays:
529 | if event.subject == 'George Best Birthday':
530 | # He died in 2005... but we celebrate anyway!
531 | event.accept("I'll attend!") # send a response accepting
532 | else:
533 | 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
534 | ```
535 |
536 | ## OneDrive
537 | The `Storage` class handles all functionality around One Drive and Document Library Storage in Sharepoint.
538 |
539 | The `Storage` instance allows to retrieve `Drive` instances which handles all the Files and Folders from within the selected `Storage`.
540 | Usually you will only need to work with the default drive. But the `Storage` instances can handle multiple drives.
541 |
542 |
543 | A `Drive` will allow you to work with Folders and Files.
544 |
545 | ```python
546 | account = Account(credentials=my_credentials)
547 |
548 | storage = account.storage() # here we get the storage instance that handles all the storage options.
549 |
550 | # list all the drives:
551 | drives = storage.get_drives()
552 |
553 | # get the default drive
554 | my_drive = storage.get_default_drive() # or get_drive('drive-id')
555 |
556 | # get some folders:
557 | root_folder = my_drive.get_root_folder()
558 | attachments_folder = my_drive.get_special_folder('attachments')
559 |
560 | # iterate over the first 25 items on the root folder
561 | for item in root_folder.get_items(limit=25):
562 | if item.is_folder:
563 | print(item.get_items(2)) # print the first to element on this folder.
564 | elif item.is_file:
565 | if item.is_photo:
566 | print(item.camera_model) # print some metadata of this photo
567 | elif item.is_image:
568 | print(item.dimensione) # print the image dimensions
569 | else:
570 | # regular file:
571 | print(item.mime_type) # print the mime type
572 | ```
573 |
574 | 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.
575 | Take care when using 'is_xxxx'.
576 |
577 | 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.
578 |
579 | ```python
580 | # copy a file to the documents special folder
581 |
582 | documents_folder = drive.get_special_folder('documents')
583 |
584 | files = drive.search('george best quotes', limit=1)
585 |
586 | if files:
587 | george_best_quotes = files[0]
588 | operation = george_best_quotes.copy(target=documents_folder) # operation here is an instance of CopyOperation
589 |
590 | # to check for the result just loop over check_status.
591 | # check_status is a generator that will yield a new status and progress until the file is finally copied
592 | 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
593 | print('{} - {}'.format(status, progress)) # prints 'in progress - 77.3' until finally completed: 'completed - 100.0'
594 | copied_item = operation.get_item() # the copy operation is completed so you can get the item.
595 | if copied_item:
596 | copied_item.delete() # ... oops!
597 | ```
598 |
599 | You can also work with share permissions:
600 |
601 | ```python
602 | current_permisions = file.get_permissions() # get all the current permissions on this drive_item (some may be inherited)
603 |
604 | # share with link
605 | permission = file.share_with_link(share_type='edit')
606 | if permission:
607 | print(permission.share_link) # the link you can use to share this drive item
608 | # share with invite
609 | permission = file.share_with_invite(recipients='george_best@best.com', send_email=True, message='Greetings!!', share_type='edit')
610 | if permission:
611 | print(permission.granted_to) # the person you share this item with
612 | ```
613 |
614 | You can also:
615 | ```python
616 | # download files:
617 | file.download(to_path='/quotes/')
618 |
619 | # upload files:
620 |
621 | # if the uploaded file is bigger than 4MB the file will be uploaded in chunks of 5 MB until completed.
622 | # this can take several requests and can be time consuming.
623 | uploaded_file = folder.upload_file(item='path_to_my_local_file')
624 |
625 | # restore versions:
626 | versiones = file.get_versions()
627 | for version in versions:
628 | if version.name == '2.0':
629 | version.restore() # restore the version 2.0 of this file
630 |
631 | # ... and much more ...
632 | ```
633 |
634 |
635 | ## Sharepoint
636 | Work in progress
637 |
638 |
639 | ## Utils
640 |
641 | #### Pagination
642 |
643 | When using certain methods, it is possible that you request more items than the api can return in a single api call.
644 | In this case the Api, returns a "next link" url where you can pull more data.
645 |
646 | When this is the case, the methods in this library will return a `Pagination` object which abstracts all this into a single iterator.
647 | The pagination object will request "next links" as soon as they are needed.
648 |
649 | For example:
650 |
651 | ```python
652 | maibox = account.mailbox()
653 |
654 | messages = mailbox.get_messages(limit=1500) # the Office 365 and MS Graph API have a 999 items limit returned per api call.
655 |
656 | # Here messages is a Pagination instance. It's an Iterator so you can iterate over.
657 |
658 | # The first 999 iterations will be normal list iterations, returning one item at a time.
659 | # When the iterator reaches the 1000 item, the Pagination instance will call the api again requesting exactly 500 items
660 | # or the items specified in the batch parameter (see later).
661 |
662 | for message in messages:
663 | print(message.subject)
664 | ```
665 |
666 | 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.
667 | This option will indicate the method to request data to the api in batches until the limit is reached or the data consumed.
668 | This is usefull when you want to optimize memory or network latency.
669 |
670 | For example:
671 |
672 | ```python
673 | messages = mailbox.get_messages(limit=100, batch=25)
674 |
675 | # messages here is a Pagination instance
676 | # when iterating over it will call the api 4 times (each requesting 25 items).
677 |
678 | for message in messages: # 100 loops with 4 requests to the api server
679 | print(message.subject)
680 | ```
681 |
682 | #### The Query helper
683 |
684 | When using the Office 365 API you can filter some fields.
685 | 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).
686 |
687 | Every `ApiComponent` (such as `MailBox`) implements a new_query method that will return a `Query` instance.
688 | This `Query` instance can handle the filtering (and sorting and selecting) very easily.
689 |
690 | For example:
691 |
692 | ```python
693 | query = mailbox.new_query()
694 |
695 | query = query.on_attribute('subject').contains('george best').chain('or').startswith('quotes')
696 |
697 | # 'created_date_time' will automatically be converted to the protocol casing.
698 | # For example when using MS Graph this will become 'createdDateTime'.
699 |
700 | query = query.chain('and').on_attribute('created_date_time').greater(datetime(2018, 3, 21))
701 |
702 | print(query)
703 |
704 | # contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z'
705 | # 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
706 |
707 | # To use Query objetcs just pass it to the query parameter:
708 | filtered_messages = mailbox.get_messages(query=query)
709 | ```
710 |
711 | You can also specify specific data to be retrieved with "select":
712 |
713 | ```python
714 | # select only some properties for the retrieved messages:
715 | query = mailbox.new_query().select('subject', 'to_recipients', 'created_date_time')
716 |
717 | messages_with_selected_properties = mailbox.get_messages(query=query)
718 | ```
719 |
720 | #### Request Error Handling and Custom Errors
721 |
722 | Whenever a Request error raises, the connection object will raise an exception.
723 | Then the exception will be captured and logged it to the stdout with it's message, an return Falsy (None, False, [], etc...)
724 |
725 | 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).
726 |
727 |
--------------------------------------------------------------------------------